From 4373bc36978d1ea58829360d7fc85fb89418482a Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Mon, 14 Sep 2026 13:38:51 +0800 Subject: [PATCH 01/17] Add a Nightly Parity Workflow Runs orca-test-repo's full override-sweep effect stage (two shards) and the GUI-vs-CLI parity harness every night against the latest successful build_all Linux AppImage, with sources checked out at that build's commit. Kept out of the per-build regression step, whose time budget it would exceed, and never gates a build. --- .github/workflows/parity_nightly.yml | 219 +++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 .github/workflows/parity_nightly.yml diff --git a/.github/workflows/parity_nightly.yml b/.github/workflows/parity_nightly.yml new file mode 100644 index 0000000000..79f5c9b514 --- /dev/null +++ b/.github/workflows/parity_nightly.yml @@ -0,0 +1,219 @@ +# Nightly parity checks from OrcaSlicer/orca-test-repo, kept out of the +# per-build "Run external slicer regression tests" step because they take far +# longer than that step's budget: +# effect - the CLI override sweep's full effect stage: every landed option +# re-sliced on its own to see whether it changes the G-code +# harness - the GUI-vs-CLI parity harness (metrics only, never fails) +# Both test the latest successful build_all.yml Linux AppImage from main, with +# sources checked out at the commit that build was made from. Nothing here +# gates a build or a PR. +name: Parity Nightly + +on: + schedule: + # build_all.yml starts at 17:00 UTC and has finished by ~20:00 + - cron: "0 21 * * *" + workflow_dispatch: + inputs: + test_repo_ref: + description: "orca-test-repo ref to run" + required: false + default: "main" + build_branch: + description: "branch whose latest successful build_all artifact to test" + required: false + default: "main" + fixtures: + description: "harness fixture ids, space-separated (empty = all)" + required: false + default: "" + cli_presets: + description: "harness lane C presets: flat = flatten inherits first, raw = leaf profile as-is" + required: false + default: "flat" + +permissions: + contents: read + actions: read + +jobs: + build: + name: Find the build to test + # Don't run scheduled checks on forks + if: github.event_name != 'schedule' || github.repository == 'OrcaSlicer/OrcaSlicer' + runs-on: ubuntu-24.04 + outputs: + run_id: ${{ steps.find.outputs.run_id }} + head_sha: ${{ steps.find.outputs.head_sha }} + steps: + - id: find + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + gh run list --workflow build_all.yml \ + --branch "${{ inputs.build_branch || 'main' }}" \ + --status success --limit 1 --json databaseId,headSha \ + --jq '"run_id=\(.[0].databaseId)\nhead_sha=\(.[0].headSha)"' \ + >> "$GITHUB_OUTPUT" + cat "$GITHUB_OUTPUT" + + effect: + name: Override sweep effect stage (shard ${{ matrix.shard }}) + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + # orca-test-repo's parity/effect_routing.json holds a 2-way split, + # ~12.5 min a shard on this runner + shard: [0, 1] + steps: + - &checkout-suite + name: Check out the test suite + uses: actions/checkout@v7 + with: + repository: OrcaSlicer/orca-test-repo + ref: ${{ inputs.test_repo_ref || 'main' }} + path: orca-test-repo + + # The AppImage ships only packed preset caches, so profiles and the CLI + # option surface come from the sources the build was made from + - &checkout-slicer + name: Check out OrcaSlicer at the build's commit + uses: actions/checkout@v7 + with: + ref: ${{ needs.build.outputs.head_sha }} + path: slicer + lfs: 'false' + + - &extract-appimage + name: Download and extract the Linux AppImage + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + gh run download "${{ needs.build.outputs.run_id }}" --dir appimage \ + --pattern "OrcaSlicer_Linux_ubuntu_2404*" + appimage=$(find appimage -name "*.AppImage" ! -name "*aarch64*" | head -1) + [ -n "$appimage" ] || { echo "no x86_64 AppImage in run ${{ needs.build.outputs.run_id }}"; exit 1; } + chmod +x "$appimage" + "$appimage" --appimage-extract > /dev/null + # The bare binary cannot find the AppImage's bundled libraries; AppRun + # sets them up and execs it, so exit codes and signals pass through + [ -x squashfs-root/AppRun ] || { echo "no AppRun in the AppImage"; exit 1; } + echo "ORCA_BIN=$PWD/squashfs-root/AppRun" >> "$GITHUB_ENV" + echo "ORCA_SOURCE=$PWD/slicer" >> "$GITHUB_ENV" + + - name: Install the AppImage's host runtime dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libopengl0 libglu1-mesa libgl1 libegl1 libwebkit2gtk-4.1-0 + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install suite dependencies + run: pip install -r orca-test-repo/requirements.txt + + - name: Run the override sweep with the full effect stage + id: run + continue-on-error: true + working-directory: orca-test-repo + run: | + set -o pipefail + # -rA keeps the per-stage summaries, which pytest otherwise swallows + # for passing tests + python -m pytest test_cli_overrides.py -c pytest.ini -v -rA \ + --effect-full --effect-shard ${{ matrix.shard }}/2 \ + --orca-bin "$ORCA_BIN" --orca-source "$ORCA_SOURCE" \ + 2>&1 | tee ../sweep.log + + - name: Publish job summary + if: always() + run: | + { + echo "## Override sweep effect stage, shard ${{ matrix.shard }}/2" + echo "Build ${{ needs.build.outputs.head_sha }} (run ${{ needs.build.outputs.run_id }})" + echo '```' + grep -E "\[override sweep" sweep.log || echo "no stage summaries, see the log" + grep -E "^=+ .*(passed|failed)" sweep.log | tail -1 || true + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload the override report + if: always() + uses: actions/upload-artifact@v7 + with: + name: override-report-shard${{ matrix.shard }} + path: | + orca-test-repo/.pytest_cache/override_report.json + sweep.log + if-no-files-found: warn + retention-days: 30 + + # The sweep step continues on error so the summary and report still get + # published; this puts the failure back on the job + - name: Fail the job if the sweep failed + if: steps.run.outcome == 'failure' + run: | + echo "the override sweep failed, see the job summary and the uploaded report" >&2 + exit 1 + + harness: + name: GUI-vs-CLI parity harness + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 180 + steps: + - *checkout-suite + - *checkout-slicer + - *extract-appimage + + - name: Install display tooling and the AppImage's host runtime + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + xvfb xdotool imagemagick openbox mesa-utils \ + libopengl0 libglu1-mesa libgl1 libegl1 libwebkit2gtk-4.1-0 + + - name: Run the parity harness + run: | + set -euo pipefail + fixtures=() + for f in ${{ inputs.fixtures || '' }}; do + fixtures+=(--fixture "$f") + done + # 2 GUI displays: ~1.5 cores peak / ~1.9 GB on this 4-vCPU runner, + # and each fixture is fully isolated, so results match a serial run + python3 orca-test-repo/parity/run_parity.py \ + --slicer-root "$ORCA_SOURCE" --bin "$ORCA_BIN" \ + --cli-presets "${{ inputs.cli_presets || 'flat' }}" \ + --gui-workers 2 --out "$PWD/parity-out" "${fixtures[@]}" + + - name: Publish job summary + if: always() + run: | + if [ -f parity-out/report.md ]; then + cat parity-out/report.md >> "$GITHUB_STEP_SUMMARY" + else + echo "the harness produced no report, see the log" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Drop per-lane datadirs before upload + if: always() + run: rm -rf parity-out/*/seed parity-out/*/datadir-* || true + + - name: Upload the scorecard and evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: parity-scorecard + path: parity-out/ + if-no-files-found: warn + retention-days: 30 From 5f01f21661d5bd4002a6b261464ec4cd13cb3c7d Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Mon, 14 Sep 2026 17:44:17 +0800 Subject: [PATCH 02/17] Load Each Vendor Tree Once When the CLI Resolves System Presets Resolving a system preset through its vendor manifest loaded the whole vendor tree and the filament library from JSON, and the CLI did that separately for every --load-settings and --load-filaments file. A run with machine, process and filament presets parsed BBL's 2,879 profile files and the library's 512 three times over, about a second each. Keep the library and vendor bundles loaded by the manifest path on the PresetBundle that resolved them, keyed by source root, vendor and substitution rule, and have the CLI resolve every system preset through one bundle for the whole run. A failed load is not kept, so errors are reported as before. On a cube slice with X1C machine, process and PLA presets: 2.42 s -> 0.93 s, BBL.json opened once instead of three times, identical G-code. --- src/OrcaSlicer.cpp | 10 ++-- src/libslic3r/PresetBundle.cpp | 58 ++++++++++++------- src/libslic3r/PresetBundle.hpp | 14 +++++ .../libslic3r/test_preset_bundle_loading.cpp | 44 ++++++++++++++ 4 files changed, 100 insertions(+), 26 deletions(-) diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index b75c653eda..f2ce73e1f4 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -2010,19 +2010,21 @@ int CLI::run(int argc, char **argv) } }; - auto resolve_preset = [&ensure_cli_preset_bundle](const std::string &file, DynamicPrintConfig &config, + // One resolver for the whole run, so presets from the same vendor tree share its load. + std::unique_ptr system_preset_resolver; + auto resolve_preset = [&ensure_cli_preset_bundle, &system_preset_resolver](const std::string &file, DynamicPrintConfig &config, std::string &config_type, const std::string &config_from, bool probe_type, std::string &error) { const auto *inherits = config.option(BBL_JSON_KEY_INHERITS); if (!probe_type && (inherits == nullptr || inherits->value.empty())) return true; - std::unique_ptr source_bundle; PresetBundle *bundle = nullptr; bool allow_source_manifest = false; if (config_from == "system") { - source_bundle = std::make_unique(); - bundle = source_bundle.get(); + if (!system_preset_resolver) + system_preset_resolver = std::make_unique(); + bundle = system_preset_resolver.get(); allow_source_manifest = true; } else { bundle = ensure_cli_preset_bundle(error); diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 4b8fb03a02..9cef965490 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -549,30 +549,11 @@ bool PresetBundle::resolve_preset_config(DynamicPrintConfig &config, Preset::Typ continue; try { - PresetBundle library_bundle; - const PresetBundle *base_bundle = nullptr; - if (vendor_id != ORCA_FILAMENT_LIBRARY && - boost::filesystem::is_regular_file(root_dir / (std::string(ORCA_FILAMENT_LIBRARY) + ".json"))) { - library_bundle.m_preserve_vendor_source_paths = true; - library_bundle.load_vendor_configs_from_json(root_dir.string(), ORCA_FILAMENT_LIBRARY, LoadSystem, - compatibility_rule, nullptr, false); - if (library_bundle.error_count() != 0) { - error = "OrcaFilamentLibrary contains invalid presets"; - return false; - } - base_bundle = &library_bundle; - } - - PresetBundle source_bundle; - source_bundle.m_preserve_vendor_source_paths = true; - source_bundle.load_vendor_configs_from_json(root_dir.string(), vendor_id, LoadSystem, - compatibility_rule, base_bundle, false); - if (source_bundle.error_count() != 0) { - error = "Vendor bundle contains invalid presets"; + const SourceManifestBundles *loaded = load_source_manifest(root_dir, vendor_id, compatibility_rule, error); + if (loaded == nullptr) return false; - } - const Preset *resolved = find_loaded(source_bundle); + const Preset *resolved = find_loaded(*loaded->vendor); if (resolved == nullptr) { if (error.empty()) error = "Source file is not an instantiated preset in its vendor manifest"; @@ -591,6 +572,39 @@ bool PresetBundle::resolve_preset_config(DynamicPrintConfig &config, Preset::Typ return false; } +const PresetBundle::SourceManifestBundles *PresetBundle::load_source_manifest(const boost::filesystem::path &root_dir, + const std::string &vendor_id, + ForwardCompatibilitySubstitutionRule compatibility_rule, + std::string &error) +{ + auto key = std::make_tuple(root_dir.string(), vendor_id, static_cast(compatibility_rule)); + if (auto it = m_source_manifest_bundles.find(key); it != m_source_manifest_bundles.end()) + return &it->second; + + SourceManifestBundles loaded; + if (vendor_id != ORCA_FILAMENT_LIBRARY && + boost::filesystem::is_regular_file(root_dir / (std::string(ORCA_FILAMENT_LIBRARY) + ".json"))) { + loaded.library = std::make_unique(); + loaded.library->m_preserve_vendor_source_paths = true; + loaded.library->load_vendor_configs_from_json(root_dir.string(), ORCA_FILAMENT_LIBRARY, LoadSystem, + compatibility_rule, nullptr, false); + if (loaded.library->error_count() != 0) { + error = "OrcaFilamentLibrary contains invalid presets"; + return nullptr; + } + } + + loaded.vendor = std::make_unique(); + loaded.vendor->m_preserve_vendor_source_paths = true; + loaded.vendor->load_vendor_configs_from_json(root_dir.string(), vendor_id, LoadSystem, + compatibility_rule, loaded.library.get(), false); + if (loaded.vendor->error_count() != 0) { + error = "Vendor bundle contains invalid presets"; + return nullptr; + } + return &m_source_manifest_bundles.emplace(std::move(key), std::move(loaded)).first->second; +} + bool PresetBundle::resolve_preset_config_type(DynamicPrintConfig &config, Preset::Type &type, const std::string &source_file, ForwardCompatibilitySubstitutionRule compatibility_rule, diff --git a/src/libslic3r/PresetBundle.hpp b/src/libslic3r/PresetBundle.hpp index 353b6dc07d..a0fceb332b 100644 --- a/src/libslic3r/PresetBundle.hpp +++ b/src/libslic3r/PresetBundle.hpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -652,6 +653,19 @@ private: bool m_generate_vendor_caches { false }; bool m_preserve_vendor_source_paths { false }; + // Vendor trees loaded by resolve_preset_config's manifest path, so every preset + // resolved through this bundle shares one load per source root and vendor. + struct SourceManifestBundles { + std::unique_ptr library; + std::unique_ptr vendor; + }; + std::map, SourceManifestBundles> m_source_manifest_bundles; + + const SourceManifestBundles *load_source_manifest(const boost::filesystem::path &root_dir, + const std::string &vendor_id, + ForwardCompatibilitySubstitutionRule compatibility_rule, + std::string &error); + // Orca: validation only - flag any printer with two or more compatible // filament presets sharing one filament_id (ambiguous AMS subtype match). bool check_duplicate_filament_subtypes() const; diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index ecdede7053..5341e6c621 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -987,6 +987,50 @@ TEST_CASE("Resolution terminates when no vendor manifest exists", "[Preset][Bund CHECK(error == "Preset was not found in the loaded bundle"); } +TEST_CASE("Manifest-backed resolution reuses the vendor tree it already loaded", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path process_dir = dir.path() / "Acme" / "process"; + fs::create_directories(process_dir); + std::ofstream((dir.path() / "Acme.json").string()) + << R"({"version":"1.0.0","name":"Acme","process_list":[)" + << R"({"name":"fdm_process_common","sub_path":"process/base.json"},)" + << R"({"name":"Acme First","sub_path":"process/first.json"},)" + << R"({"name":"Acme Second","sub_path":"process/second.json"}]})"; + auto write_base = [&](double travel_speed) { + std::ofstream((process_dir / "base.json").string()) + << R"({"type":"process","name":"fdm_process_common","from":"system",)" + << R"("instantiation":"false","travel_speed":[")" << travel_speed << R"("]})"; + }; + auto write_child = [&](const std::string &file, const std::string &name) { + std::ofstream((process_dir / file).string()) + << R"({"type":"process","name":")" << name << R"(","from":"system",)" + << R"("instantiation":"true","inherits":"fdm_process_common"})"; + }; + write_base(111.0); + write_child("first.json", "Acme First"); + write_child("second.json", "Acme Second"); + + auto travel_speed = [&](PresetBundle &bundle, const std::string &file) { + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common"; + std::string error; + REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, (process_dir / file).string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + return raw.option("travel_speed")->values.front(); + }; + + PresetBundle bundle; + CHECK_THAT(travel_speed(bundle, "first.json"), Catch::Matchers::WithinAbs(111.0, 1e-6)); + + // Only a reload would see this change. + write_base(222.0); + CHECK_THAT(travel_speed(bundle, "second.json"), Catch::Matchers::WithinAbs(111.0, 1e-6)); + + PresetBundle fresh; + CHECK_THAT(travel_speed(fresh, "second.json"), Catch::Matchers::WithinAbs(222.0, 1e-6)); +} + // Orca: a filament in the Orca Filament Library that names its compatible printers has to hide the generic // library filament sharing its alias, the same way a vendor owned filament does. Otherwise both are compatible // with that printer and the plater combo box lists the shared alias twice. From d4840901fc2476e6d141ab46da51a8e361705516 Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Mon, 14 Sep 2026 18:51:48 +0800 Subject: [PATCH 03/17] Test That Failed Vendor Loads Are Not Kept and the Library Base Is Reused Cover the two cache paths the first test left open: a vendor tree that fails to load is retried on the next resolution instead of being served from the cache, and a type-probed filament resolved through resolve_preset_config_type reuses the OrcaFilamentLibrary base already loaded for a sibling. --- .../libslic3r/test_preset_bundle_loading.cpp | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 5341e6c621..29c38395ac 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -1031,6 +1031,93 @@ TEST_CASE("Manifest-backed resolution reuses the vendor tree it already loaded", CHECK_THAT(travel_speed(fresh, "second.json"), Catch::Matchers::WithinAbs(222.0, 1e-6)); } +TEST_CASE("Manifest-backed resolution does not keep a vendor tree that failed to load", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path child_file = dir.path() / "Acme" / "process" / "child.json"; + auto write_manifest = [&](const std::string &leading_entry) { + std::ofstream((dir.path() / "Acme.json").string()) + << R"({"version":"1.0.0","name":"Acme","process_list":[)" << leading_entry + << R"({"name":"Acme Process","sub_path":"process/child.json"}]})"; + }; + write_manifest("123,"); + fs::create_directories(child_file.parent_path()); + std::ofstream(child_file.string()) + << R"({"type":"process","name":"Acme Process","from":"system",)" + << R"("instantiation":"true","layer_height":"0.2"})"; + + PresetBundle bundle; + auto resolve = [&](std::string &error) { + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common"; + return bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, child_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error); + }; + + std::string error; + CHECK_FALSE(resolve(error)); + CHECK_FALSE(error.empty()); + + write_manifest(""); + error.clear(); + CHECK(resolve(error)); + CHECK(error.empty()); +} + +TEST_CASE("Manifest-backed resolution reuses the library base for type-probed files", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path library_pet = dir.path() / PresetBundle::ORCA_FILAMENT_LIBRARY / "filament" / "pet.json"; + const fs::path filament_dir = dir.path() / "Acme" / "filament"; + + std::ofstream((dir.path() / (std::string(PresetBundle::ORCA_FILAMENT_LIBRARY) + ".json")).string()) + << R"({"version":"1.0.0","name":"OrcaFilamentLibrary","filament_list":[)" + << R"({"name":"fdm_filament_pet","sub_path":"filament/pet.json","filament_id":"GFL99"}]})"; + fs::create_directories(library_pet.parent_path()); + auto write_library_pet = [&](double density) { + std::ofstream(library_pet.string()) + << R"({"type":"filament","name":"fdm_filament_pet","from":"system",)" + << R"("filament_id":"GFL99","instantiation":"false",)" + << R"("filament_type":["PETG"],"filament_density":[")" << density << R"("]})"; + }; + write_library_pet(1.27); + + std::ofstream((dir.path() / "Acme.json").string()) + << R"({"version":"1.0.0","name":"Acme","filament_list":[)" + << R"({"name":"Acme PETG","sub_path":"filament/petg.json","filament_id":"GFA00"},)" + << R"({"name":"Acme PETG Matte","sub_path":"filament/petg_matte.json","filament_id":"GFA01"}]})"; + fs::create_directories(filament_dir); + auto write_child = [&](const std::string &file, const std::string &name, const std::string &filament_id) { + std::ofstream((filament_dir / file).string()) + << R"({"type":"filament","name":")" << name << R"(","from":"system",)" + << R"("filament_id":")" << filament_id << R"(","instantiation":"true","inherits":"fdm_filament_pet"})"; + }; + write_child("petg.json", "Acme PETG", "GFA00"); + write_child("petg_matte.json", "Acme PETG Matte", "GFA01"); + + auto density = [](const DynamicPrintConfig &config) { + return config.option("filament_density")->values.front(); + }; + + PresetBundle bundle; + DynamicPrintConfig first; + first.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_filament_pet"; + std::string error; + REQUIRE(bundle.resolve_preset_config(first, Preset::TYPE_FILAMENT, (filament_dir / "petg.json").string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + CHECK_THAT(density(first), Catch::Matchers::WithinAbs(1.27, 1e-6)); + + // Only a reload would see this change. + write_library_pet(1.5); + + DynamicPrintConfig second; + Preset::Type type = Preset::TYPE_INVALID; + REQUIRE(bundle.resolve_preset_config_type(second, type, (filament_dir / "petg_matte.json").string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + CHECK(type == Preset::TYPE_FILAMENT); + CHECK_THAT(density(second), Catch::Matchers::WithinAbs(1.27, 1e-6)); +} + // Orca: a filament in the Orca Filament Library that names its compatible printers has to hide the generic // library filament sharing its alias, the same way a vendor owned filament does. Otherwise both are compatible // with that printer and the plater combo box lists the shared alias twice. From 70247ad298a1087c5d507b27a9f0e95f6c236b09 Mon Sep 17 00:00:00 2001 From: Daniel Williams <35799546+danielwoz@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:37:04 +0800 Subject: [PATCH 04/17] Extract Layer::choose_ironing_extruder for unit-testable ironing routing (#13467) * Extract Layer::choose_ironing_extruder for unit-testable ironing routing The ironing extruder selection in make_ironing() was a 5-line nested conditional inlined at the top of the loop, with no isolated test coverage. Pull the gating into a static helper so the routing decision is unit-testable without spinning up the slicing pipeline. Pure refactor: the helper preserves the original logic bit-for-bit (NoIroning -> -1; AllSolid always enabled; TopSurfaces and TopmostOnly require some top shells or, in spiral mode, more than one bottom shell; TopmostOnly additionally requires being on the topmost layer; enabled ironing routes to solid_infill_filament). Add tests/fff_print/test_choose_ironing_extruder.cpp covering: - AllSolid regardless of layer position - TopSurfaces with top_shell_layers > 0 - TopSurfaces with top_shell_layers=0 + spiral mode + bottom_shell_layers>1 - TopmostOnly + topmost layer - NoIroning short-circuit - TopSurfaces with top_shell_layers=0 (and not spiral) -> disabled - TopSurfaces, spiral, but bottom_shell_layers=1 -> disabled - TopmostOnly on a non-topmost layer -> disabled * Move ironing routing test into the Fill subsystem file Rename the test to tests/libslic3r/test_fill.cpp and tag it [Fill] to match the subsystem it covers, use flat behavioral test cases with GENERATE for the parameterized ones, and drop the history narration from the code comments. * tests: move ironing routing tests into fff_print/test_fill.cpp Keeps the Fill tests in one file, alongside the existing ironing rotation-template test. --- src/libslic3r/Fill/Fill.cpp | 36 ++++++++++++------- src/libslic3r/Layer.hpp | 6 ++++ tests/fff_print/test_fill.cpp | 68 +++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 13 deletions(-) diff --git a/src/libslic3r/Fill/Fill.cpp b/src/libslic3r/Fill/Fill.cpp index f5386b085c..28fabed8af 100644 --- a/src/libslic3r/Fill/Fill.cpp +++ b/src/libslic3r/Fill/Fill.cpp @@ -1595,6 +1595,25 @@ Polylines Layer::generate_sparse_infill_polylines_for_anchoring(FillAdaptive::Oc return sparse_infill_polylines; } +// Returns the filament id (1-based) the region is ironed with, or -1 when the +// region is not ironed. AllSolid always irons. TopSurfaces and TopmostOnly need +// either some top shells or, in spiral mode, more than one bottom shell, and +// TopmostOnly additionally needs the layer to be the topmost one. +int Layer::choose_ironing_extruder(const PrintRegionConfig &cfg, + bool spiral_mode, + bool is_topmost_layer) +{ + if (cfg.ironing_type == IroningType::NoIroning) + return -1; + const bool gate = (cfg.ironing_type == IroningType::AllSolid) + || ((cfg.top_shell_layers > 0 || (spiral_mode && cfg.bottom_shell_layers > 1)) + && (cfg.ironing_type == IroningType::TopSurfaces + || (cfg.ironing_type == IroningType::TopmostOnly && is_topmost_layer))); + if (!gate) + return -1; + return cfg.top_surface_filament_id; +} + // Create ironing extrusions over top surfaces. void Layer::make_ironing() { @@ -1664,19 +1683,10 @@ void Layer::make_ironing() if (! layerm->slices.empty()) { IroningParams ironing_params; const PrintRegionConfig &config = layerm->region().config(); - if (config.ironing_type != IroningType::NoIroning && - (config.ironing_type == IroningType::AllSolid || - ((config.top_shell_layers > 0 || (this->object()->print()->config().spiral_mode && config.bottom_shell_layers > 1)) && - (config.ironing_type == IroningType::TopSurfaces || - (config.ironing_type == IroningType::TopmostOnly && layerm->layer()->upper_layer == nullptr))))) { - if (config.outer_wall_filament_id == config.top_surface_filament_id || config.wall_loops == 0) { - // Iron the whole face. - ironing_params.extruder = config.top_surface_filament_id; - } else { - // Iron just the infill. - ironing_params.extruder = config.top_surface_filament_id; - } - } + ironing_params.extruder = Layer::choose_ironing_extruder( + config, + /*spiral_mode=*/this->object()->print()->config().spiral_mode, + /*is_topmost_layer=*/layerm->layer()->upper_layer == nullptr); if (ironing_params.extruder != -1) { //TODO just_infill is currently not used. ironing_params.just_infill = false; diff --git a/src/libslic3r/Layer.hpp b/src/libslic3r/Layer.hpp index 8a5aa78036..9be6b86139 100644 --- a/src/libslic3r/Layer.hpp +++ b/src/libslic3r/Layer.hpp @@ -16,6 +16,7 @@ using LayerPtrs = std::vector; class LayerRegion; using LayerRegionPtrs = std::vector; class PrintRegion; +class PrintRegionConfig; class PrintObject; class Print; @@ -200,6 +201,11 @@ public: FillAdaptive::Octree *support_fill_octree, FillLightning::Generator* lightning_generator) const; void make_ironing(); + // Returns the filament id (1-based) the region is ironed with, or -1 when the + // region is not ironed. + static int choose_ironing_extruder(const PrintRegionConfig &cfg, + bool spiral_mode, + bool is_topmost_layer); void make_contour_z(const sla::IndexedMesh &mesh); void export_region_slices_to_svg(const char *path) const; diff --git a/tests/fff_print/test_fill.cpp b/tests/fff_print/test_fill.cpp index aa81570e56..a3696c47ad 100644 --- a/tests/fff_print/test_fill.cpp +++ b/tests/fff_print/test_fill.cpp @@ -15,6 +15,7 @@ #include "libslic3r/Geometry.hpp" #include "libslic3r/Layer.hpp" #include "libslic3r/Print.hpp" +#include "libslic3r/PrintConfig.hpp" #include "libslic3r/SVG.hpp" #include "libslic3r/libslic3r.h" @@ -676,6 +677,73 @@ TEST_CASE("Ironing follows the solid infill rotation template", "[Fill]") REQUIRE(compared > int(ironing.size()) / 2); } + +namespace { + +PrintRegionConfig ironing_config(IroningType type, + int top_surface_filament_id = 1, + int top_shell_layers = 3, + int bottom_shell_layers = 1) +{ + PrintRegionConfig cfg; + cfg.ironing_type.value = type; + cfg.top_surface_filament_id.value = top_surface_filament_id; + cfg.top_shell_layers.value = top_shell_layers; + cfg.bottom_shell_layers.value = bottom_shell_layers; + cfg.outer_wall_filament_id.value = 1; + cfg.wall_loops.value = 2; + return cfg; +} + +} // namespace + +TEST_CASE("Ironing an all-solid region uses the top surface filament on every layer", "[Fill]") +{ + const PrintRegionConfig cfg = ironing_config(IroningType::AllSolid, /*top_surface_filament_id=*/2); + const bool is_topmost_layer = GENERATE(false, true); + CAPTURE(is_topmost_layer); + REQUIRE(Layer::choose_ironing_extruder(cfg, /*spiral_mode=*/false, is_topmost_layer) == 2); +} + +TEST_CASE("Ironing top surfaces uses the top surface filament when the region has top shells", "[Fill]") +{ + const PrintRegionConfig cfg = ironing_config(IroningType::TopSurfaces, + /*top_surface_filament_id=*/3, + /*top_shell_layers=*/2); + REQUIRE(Layer::choose_ironing_extruder(cfg, /*spiral_mode=*/false, /*is_topmost_layer=*/false) == 3); +} + +TEST_CASE("Ironing top surfaces without top shells needs spiral mode and more than one bottom shell", "[Fill]") +{ + const PrintRegionConfig one_bottom_shell = ironing_config(IroningType::TopSurfaces, + /*top_surface_filament_id=*/1, + /*top_shell_layers=*/0, + /*bottom_shell_layers=*/1); + const PrintRegionConfig two_bottom_shells = ironing_config(IroningType::TopSurfaces, + /*top_surface_filament_id=*/1, + /*top_shell_layers=*/0, + /*bottom_shell_layers=*/2); + + REQUIRE(Layer::choose_ironing_extruder(two_bottom_shells, /*spiral_mode=*/true, /*is_topmost_layer=*/false) == 1); + REQUIRE(Layer::choose_ironing_extruder(one_bottom_shell, /*spiral_mode=*/true, /*is_topmost_layer=*/false) == -1); + REQUIRE(Layer::choose_ironing_extruder(two_bottom_shells, /*spiral_mode=*/false, /*is_topmost_layer=*/false) == -1); +} + +TEST_CASE("Ironing the topmost surface only applies to the topmost layer", "[Fill]") +{ + const PrintRegionConfig cfg = ironing_config(IroningType::TopmostOnly, /*top_surface_filament_id=*/4); + REQUIRE(Layer::choose_ironing_extruder(cfg, /*spiral_mode=*/false, /*is_topmost_layer=*/true) == 4); + REQUIRE(Layer::choose_ironing_extruder(cfg, /*spiral_mode=*/false, /*is_topmost_layer=*/false) == -1); +} + +TEST_CASE("A region with ironing turned off is never ironed", "[Fill]") +{ + const PrintRegionConfig cfg = ironing_config(IroningType::NoIroning); + const bool spiral_mode = GENERATE(false, true); + CAPTURE(spiral_mode); + REQUIRE(Layer::choose_ironing_extruder(cfg, spiral_mode, /*is_topmost_layer=*/true) == -1); +} + TEST_CASE("Solid infill direction offsets every layer when no template is set", "[Fill]") { auto angles_for = [](int direction) { From 31eb8a2bd1f402da52b4b81af82ef436b2a83705 Mon Sep 17 00:00:00 2001 From: packerlschupfer <83344883+packerlschupfer@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:35:29 +0200 Subject: [PATCH 05/17] CLI: let --export-settings - write the merged config to stdout --export-settings already writes the merged config as JSON at the right point in the CLI flow. Passing - writes the same document to stdout. - ConfigBase::save_to_json gains a stream overload. The file overload serializes through it before opening the file, so the format is unchanged and a config that cannot be serialized leaves an existing file untouched instead of truncating it. - On stdout, invalid UTF-8 in string values is written as U+FFFD instead of ending the process with an uncaught type_error; files keep the strict behaviour. - - is rejected up front when combined with an action or transform that can write to stdout or does real work, so stdout carries only the JSON. - The unconditional "skip locked instance" stdout write during arrange now goes to the log. - Tests in tests/libslic3r/test_config.cpp. --- src/OrcaSlicer.cpp | 27 ++++++++++++++-- src/libslic3r/Config.cpp | 21 +++++++++---- src/libslic3r/Config.hpp | 3 ++ src/libslic3r/PrintConfig.cpp | 2 +- tests/libslic3r/test_config.cpp | 55 +++++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 9 deletions(-) diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index b75c653eda..07009ef46a 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -1387,6 +1387,25 @@ int CLI::run(int argc, char **argv) if (downward_check_option) downward_check = downward_check_option->value; + // --export-settings - writes its JSON to stdout, so reject every action or transform that may write there + // too (--info, --help, --orient, slicing and exporting). The allowed ones do nothing when nothing is + // sliced or exported. + if (std::find(m_actions.begin(), m_actions.end(), "export_settings") != m_actions.end() && m_config.opt_string("export_settings") == "-") { + static const std::set stdout_compatible = { "export_settings", "uptodate", "load_defaultfila", "min_save", + "mtcpp", "mstpp", "no_check", "normative_check", "pipe" }; + for (const std::vector *opt_keys : { &m_actions, &m_transforms }) { + for (const std::string &opt_key : *opt_keys) { + if (stdout_compatible.count(opt_key) == 0) { + std::string flag = opt_key; + std::replace(flag.begin(), flag.end(), '_', '-'); + boost::nowide::cerr << "--export-settings - cannot be combined with --" << flag << std::endl; + record_exit_reson(outfile_dir, CLI_INVALID_PARAMS, 0, cli_errors[CLI_INVALID_PARAMS], sliced_info); + flush_and_exit(CLI_INVALID_PARAMS); + } + } + } + } + bool start_gui = m_actions.empty() && !downward_check; if (start_gui) { BOOST_LOG_TRIVIAL(info) << "no action, start gui directly" << std::endl; @@ -5348,7 +5367,7 @@ int CLI::run(int argc, char **argv) //skip this object due to be locked in plate ap.itemid = locked_aps.size(); locked_aps.emplace_back(ap); - boost::nowide::cout <<__FUNCTION__ << boost::format(": skip locked instance, obj_id %1%, instance_id %2%") % oidx % inst_idx; + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": skip locked instance, obj_id %1%, instance_id %2%") % oidx % inst_idx; } } } @@ -5937,7 +5956,11 @@ int CLI::run(int argc, char **argv) //FIXME check for mixing the FFF / SLA parameters. // or better save fff_print_config vs. sla_print_config //m_print_config.save(m_config.opt_string("save")); - m_print_config.save_to_json(m_config.opt_string(opt_key), std::string("project_settings"), std::string("project"), std::string(SoftFever_VERSION)); + const std::string &settings_file = m_config.opt_string(opt_key); + if (settings_file == "-") + m_print_config.save_to_json(boost::nowide::cout, "project_settings", "project", SoftFever_VERSION, /*replace_invalid_utf8=*/true); + else + m_print_config.save_to_json(settings_file, std::string("project_settings"), std::string("project"), std::string(SoftFever_VERSION)); } else if (opt_key == "info") { // --info works on unrepaired model for (Model &model : m_models) { diff --git a/src/libslic3r/Config.cpp b/src/libslic3r/Config.cpp index 394cfb5b74..52a46dcacf 100644 --- a/src/libslic3r/Config.cpp +++ b/src/libslic3r/Config.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -1515,6 +1516,19 @@ std::optional parse_capability_ref(const std::string& value //BBS: add json support void ConfigBase::save_to_json(const std::string &file, const std::string &name, const std::string &from, const std::string &version) const +{ + // Serialize first: if that throws (invalid UTF-8), the existing file stays untouched. + std::ostringstream ss; + this->save_to_json(ss, name, from, version); + boost::nowide::ofstream c; + c.open(file, std::ios::out | std::ios::trunc); + c << ss.str(); + c.close(); + + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ":" <<__LINE__ << boost::format(", saved config to %1%\n")%file; +} + +void ConfigBase::save_to_json(std::ostream &os, const std::string &name, const std::string &from, const std::string &version, bool replace_invalid_utf8) const { json j; //record the headers @@ -1561,12 +1575,7 @@ void ConfigBase::save_to_json(const std::string &file, const std::string &name, j["plugins"] = unique_refs; } - boost::nowide::ofstream c; - c.open(file, std::ios::out | std::ios::trunc); - c << j.dump(1, '\t') << std::endl; - c.close(); - - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ":" <<__LINE__ << boost::format(", saved config to %1%\n")%file; + os << j.dump(1, '\t', false, replace_invalid_utf8 ? json::error_handler_t::replace : json::error_handler_t::strict) << std::endl; } void ConfigBase::save(const std::string &file) const diff --git a/src/libslic3r/Config.hpp b/src/libslic3r/Config.hpp index ea85cda1e7..6d23ec3770 100644 --- a/src/libslic3r/Config.hpp +++ b/src/libslic3r/Config.hpp @@ -2825,6 +2825,9 @@ public: //BBS: add json support void save_to_json(const std::string &file, const std::string &name, const std::string &from, const std::string &version) const; + // Same document, written to a stream. Invalid UTF-8 in a string value throws nlohmann's type_error unless + // replace_invalid_utf8 is set, which writes U+FFFD instead (for callers such as stdout with no handler). + void save_to_json(std::ostream &os, const std::string &name, const std::string &from, const std::string &version, bool replace_invalid_utf8 = false) const; // Rebuild the in-memory "plugins" manifest (the "name;uuid;capability" references the plugin // dispatchers consume) from the plugin-backed options via the registered resolver. save_to_json() diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index e8ac749bd3..0b0fe71dc0 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -11916,7 +11916,7 @@ CLIActionsConfigDef::CLIActionsConfigDef() def = this->add("export_settings", coString); def->label = L("Export Settings"); - def->tooltip = L("This exports settings to a file."); + def->tooltip = L("This exports settings to a file. Use - to write them to stdout."); def->cli_params = "settings.json"; def->set_default_value(new ConfigOptionString("output.json")); diff --git a/tests/libslic3r/test_config.cpp b/tests/libslic3r/test_config.cpp index 3813e2df3f..208bbc6cf0 100644 --- a/tests/libslic3r/test_config.cpp +++ b/tests/libslic3r/test_config.cpp @@ -15,6 +15,8 @@ #include #include +#include + using namespace Slic3r; SCENARIO("Generic config validation performs as expected.", "[Config]") { @@ -488,6 +490,59 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[ CHECK(reloaded.option("slicing_pipeline_plugin")->values == refs); } +TEST_CASE("save_to_json writes the same document to a stream as to a file", "[Config]") { + DynamicPrintConfig config; + config.set_key_value("layer_height", new ConfigOptionFloat(0.2)); + config.set_key_value("wall_loops", new ConfigOptionInt(3)); + config.set_key_value("filament_type", new ConfigOptionStrings({ "PLA", "PETG" })); + config.set_key_value("machine_start_gcode", new ConfigOptionString("G28\nG1 Z5")); + + ScopedTemporaryFile tmp(".json"); + config.save_to_json(tmp.string(), "test_preset", "User", "1.0.0.0"); + std::string file_contents; + { + boost::nowide::ifstream ifs(tmp.string()); + file_contents.assign(std::istreambuf_iterator(ifs), std::istreambuf_iterator()); + } + // The file format: one tab per nesting level and a trailing newline. + REQUIRE_FALSE(file_contents.empty()); + CHECK(file_contents.rfind("{\n\t\"", 0) == 0); + CHECK(file_contents.back() == '\n'); + + std::ostringstream strict, replaced; + config.save_to_json(strict, "test_preset", "User", "1.0.0.0"); + config.save_to_json(replaced, "test_preset", "User", "1.0.0.0", true); + CHECK(strict.str() == file_contents); + CHECK(replaced.str() == file_contents); + CHECK(nlohmann::json::parse(strict.str())["machine_start_gcode"] == "G28\nG1 Z5"); +} + +TEST_CASE("save_to_json replaces invalid UTF-8 in a stream only when asked", "[Config]") { + DynamicPrintConfig config; + config.set_key_value("machine_start_gcode", new ConfigOptionString("G28 ; \xff")); + + std::ostringstream strict, replaced; + CHECK_THROWS_AS(config.save_to_json(strict, "test_preset", "User", "1.0.0.0"), nlohmann::json::type_error); + REQUIRE_NOTHROW(config.save_to_json(replaced, "test_preset", "User", "1.0.0.0", true)); + CHECK(nlohmann::json::parse(replaced.str())["machine_start_gcode"] == "G28 ; \xEF\xBF\xBD"); +} + +TEST_CASE("save_to_json leaves an existing file untouched when the config cannot be serialized", "[Config]") { + DynamicPrintConfig config; + config.set_key_value("machine_start_gcode", new ConfigOptionString("G28 ; \xff")); + + ScopedTemporaryFile tmp(".json"); + { + boost::nowide::ofstream ofs(tmp.string()); + ofs << "previous"; + } + CHECK_THROWS_AS(config.save_to_json(tmp.string(), "test_preset", "User", "1.0.0.0"), nlohmann::json::type_error); + + boost::nowide::ifstream ifs(tmp.string()); + const std::string contents((std::istreambuf_iterator(ifs)), std::istreambuf_iterator()); + CHECK(contents == "previous"); +} + TEST_CASE("plugin capability references survive string-map serialization", "[Config][plugins]") { const std::vector refs = { "master_plugin;;header-stamp", From 54968834932950f67596e70f64845c1a72ed252c Mon Sep 17 00:00:00 2001 From: Nopraz <12595433+Nopraz@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:44:15 +0200 Subject: [PATCH 06/17] =?UTF-8?q?fix(profiles):=20Snapmaker=20U1=20?= =?UTF-8?q?=E2=80=94=20cap=20ABS/ASA/PPS=20bed=20temps=20at=20100=20=C2=B0?= =?UTF-8?q?C=20(#15483)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The U1's heated bed tops out at 100 °C, but these profiles requested 105-110 °C, which leads to print errors unless the user modifies the printer's firmware configuration. Affected profiles: - Snapmaker ABS @U1 base (110/105 → 100) - Snapmaker ASA @U1 base (110 → 100) - Fiberon ASA-CF08 @Snapmaker U1 base (105 → 100) - Fiberon PPS-GF20 @Snapmaker U1 base (105 → 100) Bumps Snapmaker.json to 02.04.00.10. Co-authored-by: yw4z --- resources/profiles/Snapmaker.json | 2 +- .../Fiberon ASA-CF08 @Snapmaker U1 base.json | 10 +++++----- .../Fiberon PPS-GF20 @Snapmaker U1 base.json | 16 ++++++++-------- .../filament/Snapmaker ABS @U1 base.json | 4 ++-- .../filament/Snapmaker ASA @U1 base.json | 2 +- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/resources/profiles/Snapmaker.json b/resources/profiles/Snapmaker.json index 0407dca2ec..393271d0e5 100644 --- a/resources/profiles/Snapmaker.json +++ b/resources/profiles/Snapmaker.json @@ -1,6 +1,6 @@ { "name": "Snapmaker", - "version": "02.04.00.12", + "version": "02.04.00.13", "force_update": "0", "description": "Snapmaker configurations", "machine_model_list": [ diff --git a/resources/profiles/Snapmaker/filament/Polymaker/Fiberon ASA-CF08 @Snapmaker U1 base.json b/resources/profiles/Snapmaker/filament/Polymaker/Fiberon ASA-CF08 @Snapmaker U1 base.json index 693553bdcb..86648d6096 100644 --- a/resources/profiles/Snapmaker/filament/Polymaker/Fiberon ASA-CF08 @Snapmaker U1 base.json +++ b/resources/profiles/Snapmaker/filament/Polymaker/Fiberon ASA-CF08 @Snapmaker U1 base.json @@ -15,13 +15,13 @@ "1" ], "cool_plate_temp": [ - "105" + "100" ], "cool_plate_temp_initial_layer": [ - "105" + "100" ], "eng_plate_temp": [ - "105" + "100" ], "eng_plate_temp_initial_layer": [ "100" @@ -48,7 +48,7 @@ "Polymaker" ], "hot_plate_temp": [ - "105" + "100" ], "hot_plate_temp_initial_layer": [ "100" @@ -72,7 +72,7 @@ "110.8" ], "textured_plate_temp": [ - "105" + "100" ], "textured_plate_temp_initial_layer": [ "100" diff --git a/resources/profiles/Snapmaker/filament/Polymaker/Fiberon PPS-GF20 @Snapmaker U1 base.json b/resources/profiles/Snapmaker/filament/Polymaker/Fiberon PPS-GF20 @Snapmaker U1 base.json index 5e1cfa7c61..ee28c2b059 100644 --- a/resources/profiles/Snapmaker/filament/Polymaker/Fiberon PPS-GF20 @Snapmaker U1 base.json +++ b/resources/profiles/Snapmaker/filament/Polymaker/Fiberon PPS-GF20 @Snapmaker U1 base.json @@ -15,16 +15,16 @@ "1" ], "cool_plate_temp": [ - "105" + "100" ], "cool_plate_temp_initial_layer": [ - "105" + "100" ], "eng_plate_temp": [ - "105" + "100" ], "eng_plate_temp_initial_layer": [ - "105" + "100" ], "fan_cooling_layer_time": [ "12" @@ -51,10 +51,10 @@ "Polymaker" ], "hot_plate_temp": [ - "105" + "100" ], "hot_plate_temp_initial_layer": [ - "105" + "100" ], "nozzle_temperature": [ "300" @@ -81,10 +81,10 @@ "110" ], "textured_plate_temp": [ - "105" + "100" ], "textured_plate_temp_initial_layer": [ - "105" + "100" ], "filament_type": [ "ABS" diff --git a/resources/profiles/Snapmaker/filament/Snapmaker ABS @U1 base.json b/resources/profiles/Snapmaker/filament/Snapmaker ABS @U1 base.json index 67754ade09..48740f94bc 100644 --- a/resources/profiles/Snapmaker/filament/Snapmaker ABS @U1 base.json +++ b/resources/profiles/Snapmaker/filament/Snapmaker ABS @U1 base.json @@ -9,10 +9,10 @@ "" ], "hot_plate_temp": [ - "110" + "100" ], "hot_plate_temp_initial_layer": [ - "105" + "100" ], "overhang_fan_speed": [ "20" diff --git a/resources/profiles/Snapmaker/filament/Snapmaker ASA @U1 base.json b/resources/profiles/Snapmaker/filament/Snapmaker ASA @U1 base.json index 413c14cebb..b75f8d84d3 100644 --- a/resources/profiles/Snapmaker/filament/Snapmaker ASA @U1 base.json +++ b/resources/profiles/Snapmaker/filament/Snapmaker ASA @U1 base.json @@ -9,7 +9,7 @@ "" ], "hot_plate_temp": [ - "110" + "100" ], "hot_plate_temp_initial_layer": [ "100" From 5c635d5e504c5f88d45ff7f0d66b63a83382d0bc Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Mon, 14 Sep 2026 15:04:03 -0500 Subject: [PATCH 07/17] build: scope -Werror to the Clang family so GCC builds again (#15701) --- CMakeLists.txt | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index d2880a7d4b..6e713d8c88 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -587,10 +587,15 @@ if ((NOT MSVC OR IS_CLANG_CL) AND ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU" OR add_compile_options(-Wno-${w}) endforeach () - # Turn everything else into an error. Dependency headers are exempt because the SYSTEM - # include flag (-imsvc on clang-cl, -isystem elsewhere) keeps their diagnostics out, - # apart from GCC's maybe-uninitialized, demoted below. - add_compile_options(-Werror) + # GCC is not built in CI, so don't throw errors CI won't catch. + if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + add_compile_options(-Werror=return-type) + else () + # Turn everything else into an error. Dependency headers are exempt because the + # SYSTEM include flag (-imsvc on clang-cl, -isystem elsewhere) keeps their + # diagnostics out. + add_compile_options(-Werror) + endif () # Demoted. Remove a name once its category is cleared on every compiler. set(warnings_demoted) @@ -612,20 +617,6 @@ if ((NOT MSVC OR IS_CLANG_CL) AND ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU" OR cast-function-type-mismatch ) endif () - if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU") - list(APPEND warnings_demoted - # maybe-uninitialized runs after inlining and reports inside boost/variant, - # boost/tuple and the bundled clipper header even with -isystem. - maybe-uninitialized - - # array-bounds is reported once, where ConfigOptionVector::set_at inlines - # into OrcaSlicer.cpp on a branch the preceding type test rules out. - array-bounds - - # template-id-cdtor is a GCC 14+ warning in the bundled Clipper2 headers. - template-id-cdtor - ) - endif () if (CMAKE_CXX_COMPILER_ID MATCHES "Clang") list(APPEND warnings_demoted # enum-constexpr-conversion is a Clang warning that defaults to an error, From 292cf0095e698a6e0f96041bd142fd41afd6ccfb Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Mon, 14 Sep 2026 16:31:23 -0500 Subject: [PATCH 08/17] drop the per-frame mouse raycast that only a drag start reads (#15664) --- src/slic3r/GUI/GLCanvas3D.cpp | 11 +++-------- src/slic3r/GUI/GLCanvas3D.hpp | 1 - 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index e63501eec1..76192491bf 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -2098,12 +2098,6 @@ void GLCanvas3D::render(bool only_init) _render_selection_center(); #endif // ENABLE_RENDER_SELECTION_CENTER - // we need to set the mouse's scene position here because the depth buffer - // could be invalidated by the following gizmo render methods - // this position is used later into on_mouse() to drag the objects - if (m_picking_enabled) - m_mouse.scene_position = _mouse_to_3d(m_mouse.position.cast()); - // sidebar hints need to be rendered before the gizmos because the depth buffer // could be invalidated by the following gizmo render methods _render_selection_sidebar_hints(); @@ -4491,12 +4485,13 @@ void GLCanvas3D::on_mouse(wxMouseEvent& evt) BoundingBoxf3 volume_bbox = m_volumes.volumes[volume_idx]->transformed_bounding_box(); volume_bbox.offset(1.0); const bool is_cut_connector_selected = m_selection.is_any_connector(); - if ((!any_gizmo_active || !evt.CmdDown()) && volume_bbox.contains(m_mouse.scene_position) && !is_cut_connector_selected) { + const Vec3d scene_position = _mouse_to_3d(pos); + if ((!any_gizmo_active || !evt.CmdDown()) && volume_bbox.contains(scene_position) && !is_cut_connector_selected) { m_volumes.volumes[volume_idx]->hover = GLVolume::HS_None; // The dragging operation is initiated. m_mouse.drag.move_volume_idx = volume_idx; m_selection.setup_cache(); - m_mouse.drag.start_position_3D = m_mouse.scene_position; + m_mouse.drag.start_position_3D = scene_position; m_sequential_print_clearance_first_displacement = true; m_moving = true; diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index b1dd674d96..c2962c3858 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -337,7 +337,6 @@ class GLCanvas3D bool dragging{ false }; Vec2d position{ DBL_MAX, DBL_MAX }; - Vec3d scene_position{ DBL_MAX, DBL_MAX, DBL_MAX }; bool ignore_left_up{ false }; Drag drag; bool ignore_right_up; From efc9f253ee2d3e16cfb95331ea5234d2b237dca1 Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Mon, 14 Sep 2026 23:47:01 -0500 Subject: [PATCH 09/17] fix: resolve relative input paths given on the command line (#14803) Opening a model with a relative path, for example `orca-slicer ./some.3mf`, failed with "Loading of a model file failed." and "The file does not contain any geometry data.", while the same file opened by an absolute path or by drag and drop worked. GUI_App::init_app_config() changes the working directory to /log, and it runs from the GUI_App constructor because the app config is needed early for instance checking. The input files are opened much later, in post_init(), so a path still relative at that point resolved against the log directory instead of the directory OrcaSlicer was started from, and the 3MF reader failed to open it. Resolve the input paths in CLI::setup(), which runs before GUI_App is constructed and therefore before the working directory moves. Absolute paths are returned unchanged, so the forms that open today are unaffected, and custom open protocol URLs are passed through since post_init() hands those to the downloader rather than the file loader. The working directory change is left alone. It was added in #3248 so the TUTK logs land in the data directory instead of the working directory (#3209). --- src/OrcaSlicer.cpp | 7 ++++ src/libslic3r/Utils.hpp | 3 ++ src/libslic3r/utils.cpp | 13 +++++++ tests/libslic3r/test_utils.cpp | 64 ++++++++++++++++++++++++++++++++++ tests/test_utils.hpp | 18 ++++++++++ 5 files changed, 105 insertions(+) diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index b75c653eda..24f218caa5 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -7715,6 +7715,13 @@ bool CLI::setup(int argc, char **argv) this->print_help(); return false; } + + // Orca: resolve here, while the process is still in the directory the user invoked it from. + // GUI_App's constructor moves the working directory to /log, long before the GUI + // opens these files in post_init(), and a relative path would then resolve against that. + for (std::string &input_file : m_input_files) + input_file = resolve_cli_input_path(input_file); + // Parse actions and transform options. for (auto const &opt_key : opt_order) { if (cli_actions_config_def.has(opt_key)) diff --git a/src/libslic3r/Utils.hpp b/src/libslic3r/Utils.hpp index c364860531..b21da72fc8 100644 --- a/src/libslic3r/Utils.hpp +++ b/src/libslic3r/Utils.hpp @@ -314,6 +314,9 @@ extern unsigned get_current_pid(); std::string per_user_temp_id(); // Per-user temp root under `base`; an empty `user_id` returns `base` unchanged. std::string per_user_temp_dir(const std::string &base, const std::string &user_id); +// Completes a relative command line input path against the current working directory. Absolute +// paths and custom open protocol URLs are returned unchanged. +std::string resolve_cli_input_path(const std::string &path); // BBS: backup & restore std::string get_process_name(int pid); diff --git a/src/libslic3r/utils.cpp b/src/libslic3r/utils.cpp index 58323b29ce..9def5dad17 100644 --- a/src/libslic3r/utils.cpp +++ b/src/libslic3r/utils.cpp @@ -1339,6 +1339,19 @@ std::string per_user_temp_dir(const std::string &base, const std::string &user_i return base + "/orcaslicer_" + user_id; } +std::string resolve_cli_input_path(const std::string &path) +{ + const boost::filesystem::path input(path); + if (path.empty() || is_supported_open_protocol(path) || input.is_absolute()) + return path; + + boost::system::error_code ec; + const boost::filesystem::path resolved = boost::filesystem::system_complete(input, ec); + if (ec) + return path; + return resolved.lexically_normal().make_preferred().string(); +} + // BBS: backup & restore std::string get_process_name(int pid) { diff --git a/tests/libslic3r/test_utils.cpp b/tests/libslic3r/test_utils.cpp index 484438127c..7880b783f1 100644 --- a/tests/libslic3r/test_utils.cpp +++ b/tests/libslic3r/test_utils.cpp @@ -4,6 +4,8 @@ #include "test_utils.hpp" +#include + #include #include #include @@ -88,3 +90,65 @@ TEST_CASE("copy_file reports the OS error when the destination cannot be written REQUIRE(std::all_of(code.begin(), code.end(), [](unsigned char c) { return std::isdigit(c) != 0; })); #endif // _WIN32 } + +TEST_CASE("A resolved input path still names the same file after the working directory changes", "[utils]") { + ScopedTemporaryFile model(".3mf"); + { std::ofstream out(model.string()); out << "3mf"; } + const std::string name = model.path().filename().string(); + + // Resolve the bare name from the directory holding the file, then move away from it. The guard + // restores the directory the test started in, wherever this leaves it. + ScopedWorkingDirectory cwd(model.path().parent_path()); + const std::string resolved = resolve_cli_input_path(name); + boost::filesystem::current_path(boost::filesystem::path(TEST_DATA_DIR)); + + REQUIRE(boost::filesystem::exists(resolved)); + REQUIRE(boost::filesystem::equivalent(resolved, model.path())); + // Control: the bare name finds nothing from here, so resolving it this late would have failed. + REQUIRE_FALSE(boost::filesystem::exists(name)); +} + +TEST_CASE("resolve_cli_input_path completes a relative path against the working directory", "[utils]") { + ScopedWorkingDirectory cwd(boost::filesystem::temp_directory_path()); + // Read back rather than reusing temp_directory_path(): changing to it resolves any symlink. + const boost::filesystem::path here = boost::filesystem::current_path(); + + SECTION("a bare name") { + REQUIRE(resolve_cli_input_path("model.3mf") == (here / "model.3mf").make_preferred().string()); + } + SECTION("a ./ prefix is dropped") { + REQUIRE(resolve_cli_input_path("./model.3mf") == (here / "model.3mf").make_preferred().string()); + } + SECTION("a ../ traversal is collapsed") { + REQUIRE(resolve_cli_input_path("../model.3mf") == (here.parent_path() / "model.3mf").make_preferred().string()); + } +} + +TEST_CASE("resolve_cli_input_path leaves inputs that must not be completed unchanged", "[utils]") { + SECTION("an absolute path") { + const boost::filesystem::path absolute = (boost::filesystem::temp_directory_path() / "model.3mf").make_preferred(); + REQUIRE(resolve_cli_input_path(absolute.string()) == absolute.string()); + } +#ifdef _WIN32 + // Every absolute form Windows accepts opens today, so each must come back byte for byte: + // normalizing them would rewrite the forward slashes and rebuild the \\?\ and UNC prefixes. + SECTION("an absolute Windows path of any form") { + for (const std::string absolute : {R"(C:\models\model.3mf)", + R"(C:/models/model.3mf)", + R"(\\server\share\model.3mf)", + R"(\\?\C:\models\model.3mf)"}) + REQUIRE(resolve_cli_input_path(absolute) == absolute); + } +#endif + // These are downloaded rather than opened, and completing one would produce a path, not a URL. + SECTION("a custom open protocol URL") { + for (const std::string url : {"orcaslicer://open/?file=https://example.com/model.3mf", + "prusaslicer://open/?file=https://example.com/model.3mf", + "bambustudio://open/?file=https://example.com/model.3mf", + "cura://open/?file=https://example.com/model.3mf"}) + REQUIRE(resolve_cli_input_path(url) == url); + } + SECTION("an empty argument") { + REQUIRE(resolve_cli_input_path("").empty()); + } +} diff --git a/tests/test_utils.hpp b/tests/test_utils.hpp index e3fbbe8fab..0b04e6ad11 100644 --- a/tests/test_utils.hpp +++ b/tests/test_utils.hpp @@ -176,4 +176,22 @@ inline void write_debug_stream([[maybe_unused]] const std::string &name, [[maybe #endif } +// Changes the working directory and restores the previous one on scope exit, including when an +// assertion throws. It is process wide state shared with every other test. +class ScopedWorkingDirectory +{ +public: + explicit ScopedWorkingDirectory(const boost::filesystem::path &dir) + : m_previous(boost::filesystem::current_path()) + { + boost::filesystem::current_path(dir); + } + ~ScopedWorkingDirectory() { boost::system::error_code ec; boost::filesystem::current_path(m_previous, ec); } + ScopedWorkingDirectory(const ScopedWorkingDirectory &) = delete; + ScopedWorkingDirectory &operator=(const ScopedWorkingDirectory &) = delete; + +private: + boost::filesystem::path m_previous; +}; + #endif // SLIC3R_TEST_UTILS From d5cf1502c442b0b4860dedfa0b6d791d243f4299 Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Tue, 15 Sep 2026 13:31:30 +0800 Subject: [PATCH 10/17] Share One Library Load Between Vendors in the CLI Preset Resolver The manifest resolver loaded OrcaFilamentLibrary once per vendor it resolved through, so a run that mixes vendors parsed the library tree again for each of them. The library is now cached like any other vendor tree, keyed on its root and substitution rule, and doubles as the base every vendor under that root loads against. A vendor bundle only reads from its base while loading, so sharing the instance is safe. The cache key carries the substitution rule as its enum, and the lookup lambdas take a const bundle since they only read. --- src/libslic3r/PresetBundle.cpp | 48 ++++++++-------- src/libslic3r/PresetBundle.hpp | 18 +++--- .../libslic3r/test_preset_bundle_loading.cpp | 56 +++++++++++++++++++ 3 files changed, 87 insertions(+), 35 deletions(-) diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 9cef965490..54e5db27e4 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -484,7 +484,7 @@ bool PresetBundle::resolve_preset_config(DynamicPrintConfig &config, Preset::Typ else if (compatibility_rule == ForwardCompatibilitySubstitutionRule::EnableSilentDisableSystem) compatibility_rule = ForwardCompatibilitySubstitutionRule::Disable; - auto collection_for_type = [](PresetBundle &bundle, Preset::Type preset_type) -> PresetCollection * { + auto collection_for_type = [](const PresetBundle &bundle, Preset::Type preset_type) -> const PresetCollection * { switch (preset_type) { case Preset::TYPE_PRINT: return &bundle.prints; case Preset::TYPE_FILAMENT: return &bundle.filaments; @@ -493,15 +493,15 @@ bool PresetBundle::resolve_preset_config(DynamicPrintConfig &config, Preset::Typ } }; - PresetCollection *collection = collection_for_type(*this, type); + const PresetCollection *collection = collection_for_type(*this, type); if (collection == nullptr) { error = "Unsupported preset type"; return false; } const boost::filesystem::path source_path = boost::filesystem::absolute(source_file).lexically_normal(); - auto find_loaded = [&](PresetBundle &bundle) -> const Preset * { - PresetCollection *loaded_collection = collection_for_type(bundle, type); + auto find_loaded = [&](const PresetBundle &bundle) -> const Preset * { + const PresetCollection *loaded_collection = collection_for_type(bundle, type); const Preset *resolved = nullptr; for (const Preset &preset : loaded_collection->get_presets()) { if (preset.file.empty()) @@ -549,11 +549,11 @@ bool PresetBundle::resolve_preset_config(DynamicPrintConfig &config, Preset::Typ continue; try { - const SourceManifestBundles *loaded = load_source_manifest(root_dir, vendor_id, compatibility_rule, error); + const PresetBundle *loaded = load_source_vendor(root_dir, vendor_id, compatibility_rule, error); if (loaded == nullptr) return false; - const Preset *resolved = find_loaded(*loaded->vendor); + const Preset *resolved = find_loaded(*loaded); if (resolved == nullptr) { if (error.empty()) error = "Source file is not an instantiated preset in its vendor manifest"; @@ -572,37 +572,35 @@ bool PresetBundle::resolve_preset_config(DynamicPrintConfig &config, Preset::Typ return false; } -const PresetBundle::SourceManifestBundles *PresetBundle::load_source_manifest(const boost::filesystem::path &root_dir, - const std::string &vendor_id, - ForwardCompatibilitySubstitutionRule compatibility_rule, - std::string &error) +const PresetBundle *PresetBundle::load_source_vendor(const boost::filesystem::path &root_dir, + const std::string &vendor_id, + ForwardCompatibilitySubstitutionRule compatibility_rule, + std::string &error) { - auto key = std::make_tuple(root_dir.string(), vendor_id, static_cast(compatibility_rule)); - if (auto it = m_source_manifest_bundles.find(key); it != m_source_manifest_bundles.end()) - return &it->second; + auto key = std::make_tuple(root_dir.string(), vendor_id, compatibility_rule); + if (auto it = m_source_vendor_bundles.find(key); it != m_source_vendor_bundles.end()) + return it->second.get(); - SourceManifestBundles loaded; + // The library loads with no base of its own, so the tree a vendor inherits from + // is the same one that resolves the library's own presets. + const PresetBundle *library = nullptr; if (vendor_id != ORCA_FILAMENT_LIBRARY && boost::filesystem::is_regular_file(root_dir / (std::string(ORCA_FILAMENT_LIBRARY) + ".json"))) { - loaded.library = std::make_unique(); - loaded.library->m_preserve_vendor_source_paths = true; - loaded.library->load_vendor_configs_from_json(root_dir.string(), ORCA_FILAMENT_LIBRARY, LoadSystem, - compatibility_rule, nullptr, false); - if (loaded.library->error_count() != 0) { + library = load_source_vendor(root_dir, ORCA_FILAMENT_LIBRARY, compatibility_rule, error); + if (library == nullptr) { error = "OrcaFilamentLibrary contains invalid presets"; return nullptr; } } - loaded.vendor = std::make_unique(); - loaded.vendor->m_preserve_vendor_source_paths = true; - loaded.vendor->load_vendor_configs_from_json(root_dir.string(), vendor_id, LoadSystem, - compatibility_rule, loaded.library.get(), false); - if (loaded.vendor->error_count() != 0) { + auto bundle = std::make_unique(); + bundle->m_preserve_vendor_source_paths = true; + bundle->load_vendor_configs_from_json(root_dir.string(), vendor_id, LoadSystem, compatibility_rule, library, false); + if (bundle->error_count() != 0) { error = "Vendor bundle contains invalid presets"; return nullptr; } - return &m_source_manifest_bundles.emplace(std::move(key), std::move(loaded)).first->second; + return m_source_vendor_bundles.emplace(std::move(key), std::move(bundle)).first->second.get(); } bool PresetBundle::resolve_preset_config_type(DynamicPrintConfig &config, Preset::Type &type, diff --git a/src/libslic3r/PresetBundle.hpp b/src/libslic3r/PresetBundle.hpp index a0fceb332b..88455fabf3 100644 --- a/src/libslic3r/PresetBundle.hpp +++ b/src/libslic3r/PresetBundle.hpp @@ -654,17 +654,15 @@ private: bool m_preserve_vendor_source_paths { false }; // Vendor trees loaded by resolve_preset_config's manifest path, so every preset - // resolved through this bundle shares one load per source root and vendor. - struct SourceManifestBundles { - std::unique_ptr library; - std::unique_ptr vendor; - }; - std::map, SourceManifestBundles> m_source_manifest_bundles; + // resolved through this bundle shares one load per source root and vendor. The + // filament library is one such tree, shared by every vendor under its root. + std::map, std::unique_ptr> + m_source_vendor_bundles; - const SourceManifestBundles *load_source_manifest(const boost::filesystem::path &root_dir, - const std::string &vendor_id, - ForwardCompatibilitySubstitutionRule compatibility_rule, - std::string &error); + const PresetBundle *load_source_vendor(const boost::filesystem::path &root_dir, + const std::string &vendor_id, + ForwardCompatibilitySubstitutionRule compatibility_rule, + std::string &error); // Orca: validation only - flag any printer with two or more compatible // filament presets sharing one filament_id (ambiguous AMS subtype match). diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 29c38395ac..73d244cf42 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -1118,6 +1118,62 @@ TEST_CASE("Manifest-backed resolution reuses the library base for type-probed fi CHECK_THAT(density(second), Catch::Matchers::WithinAbs(1.27, 1e-6)); } +TEST_CASE("Manifest-backed resolution shares the library between vendors under one root", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path library_dir = dir.path() / PresetBundle::ORCA_FILAMENT_LIBRARY / "filament"; + + std::ofstream((dir.path() / (std::string(PresetBundle::ORCA_FILAMENT_LIBRARY) + ".json")).string()) + << R"({"version":"1.0.0","name":"OrcaFilamentLibrary","filament_list":[)" + << R"({"name":"fdm_filament_pet","sub_path":"filament/pet.json","filament_id":"GFL99"},)" + << R"({"name":"Generic PETG","sub_path":"filament/generic_petg.json","filament_id":"GFL98"}]})"; + fs::create_directories(library_dir); + auto write_library_pet = [&](double density) { + std::ofstream((library_dir / "pet.json").string()) + << R"({"type":"filament","name":"fdm_filament_pet","from":"system",)" + << R"("filament_id":"GFL99","instantiation":"false",)" + << R"("filament_type":["PETG"],"filament_density":[")" << density << R"("]})"; + }; + write_library_pet(1.27); + std::ofstream((library_dir / "generic_petg.json").string()) + << R"({"type":"filament","name":"Generic PETG","from":"system",)" + << R"("filament_id":"GFL98","instantiation":"true","inherits":"fdm_filament_pet"})"; + + auto write_vendor = [&](const std::string &vendor, const std::string &filament_id) { + const fs::path filament_dir = dir.path() / vendor / "filament"; + fs::create_directories(filament_dir); + std::ofstream((dir.path() / (vendor + ".json")).string()) + << R"({"version":"1.0.0","name":")" << vendor << R"(","filament_list":[)" + << R"({"name":")" << vendor << R"( PETG","sub_path":"filament/petg.json","filament_id":")" << filament_id << R"("}]})"; + std::ofstream((filament_dir / "petg.json").string()) + << R"({"type":"filament","name":")" << vendor << R"( PETG","from":"system",)" + << R"("filament_id":")" << filament_id << R"(","instantiation":"true","inherits":"fdm_filament_pet"})"; + return filament_dir / "petg.json"; + }; + const fs::path acme_petg = write_vendor("Acme", "GFA00"); + const fs::path beta_petg = write_vendor("Beta", "GFB00"); + + auto density = [&](PresetBundle &bundle, const fs::path &file) { + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_filament_pet"; + std::string error; + REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_FILAMENT, file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + return raw.option("filament_density")->values.front(); + }; + + PresetBundle bundle; + CHECK_THAT(density(bundle, acme_petg), Catch::Matchers::WithinAbs(1.27, 1e-6)); + + // Only a reload would see this change. + write_library_pet(1.5); + CHECK_THAT(density(bundle, beta_petg), Catch::Matchers::WithinAbs(1.27, 1e-6)); + CHECK_THAT(density(bundle, library_dir / "generic_petg.json"), Catch::Matchers::WithinAbs(1.27, 1e-6)); + + PresetBundle fresh; + CHECK_THAT(density(fresh, beta_petg), Catch::Matchers::WithinAbs(1.5, 1e-6)); +} + // Orca: a filament in the Orca Filament Library that names its compatible printers has to hide the generic // library filament sharing its alias, the same way a vendor owned filament does. Otherwise both are compatible // with that printer and the plater combo box lists the shared alias twice. From 2b6eb425e428df118326eba55f5b6f41fe84f2e5 Mon Sep 17 00:00:00 2001 From: Lam Wei Lun Date: Tue, 15 Sep 2026 18:04:06 +0800 Subject: [PATCH 11/17] Update YouTube URL for publish 3MF guide --- src/slic3r/GUI/PublishSettingsDialog.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/slic3r/GUI/PublishSettingsDialog.cpp b/src/slic3r/GUI/PublishSettingsDialog.cpp index e63cec7615..82de9fca68 100644 --- a/src/slic3r/GUI/PublishSettingsDialog.cpp +++ b/src/slic3r/GUI/PublishSettingsDialog.cpp @@ -665,7 +665,7 @@ PublishSettingsDialog::PublishSettingsDialog(wxWindow* parent, }; wxBoxSizer* links_sizer = new wxBoxSizer(wxVERTICAL); links_sizer->Add(make_link(_L("Publish 3MF Wiki"), "https://www.orcaslicer.com/wiki/publishing_3mf/publish_3mf.html"), 0, wxALIGN_LEFT); - links_sizer->Add(make_link(_L("Publish 3MF Video Guide"), "https://www.youtube.com/@OfficialOrcaSlicer/videos"), 0, + links_sizer->Add(make_link(_L("Publish 3MF Video Guide"), "https://www.youtube.com/watch?v=-xt1N29UIOg"), 0, wxTOP | wxALIGN_LEFT, FromDIP(4)); wxBoxSizer* footer = new wxBoxSizer(wxHORIZONTAL); From bd1304443cb417d39c7be7d4182d8d8c1f737908 Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Tue, 15 Sep 2026 07:03:20 -0500 Subject: [PATCH 12/17] fix: guard per-filament array reads against short config arrays (#14789) * fix: guard H2C per-filament array reads against short config arrays The H2C tool-ordering, wipe-tower, and g-code export paths index per-filament config arrays by filament/tool id. A config with fewer entries than the filament count (partial or legacy projects, minimal test configs) makes these reads run past the end of the vector: silent under a normal STL, but UB that aborts under the flatpak build's bounds-checked STL (_GLIBCXX_ASSERTIONS). Route the reads through the existing clamping accessors (get_at, get_filament_category, is_in_same_extruder) and add a small clamp helper for filament_change_length. The guards are no-ops when the arrays are sized to the filament count, so correctly specified configs are unaffected. * fix: size the grouping context's filament_info to the filament count build_filament_group_context built model_info.filament_info by walking filament_type, so a config whose filament_type is shorter than the filament count produced a short vector. FilamentGroup indexes filament_info by filament id, so clamping the individual reads only moved the out-of-bounds access downstream. Loop to filament_nums and read all three fields through get_at, and drop filament_ids entries past the filament count, since the grouping code pairs filament_ids and filament_info by position. Adds a regression test with four filaments and one-entry filament_type / filament_is_support. Without the fix it throws bad_alloc from copying a garbage std::string read past the end. * fix: guard the carousel nozzle-change length reads too The carousel branch added in b90ac13d86/b0dddb4648 reads m_filaments_change_length by tool id without a bounds check, the same pattern this branch already routed through filament_change_length_at a few lines above in both plan_toolchange and plan_tower_new. * fix: guard WipeTower per-filament array reads against short config arrays The BambuStudio WipeTower sync reintroduced raw per-filament array indexing that reads out of bounds when a config leaves an array shorter than the filament count: m_physical_extruder_map in format_line_M104/M109 (indexed even when empty), and m_filament_categories in get_wall_skip_points and get_wall_filament_for_all_layer. Silent on a normal STL, a hard abort under the bounds-checked STL the Flatpak build uses. Bounds-check the physical extruder map before indexing (omitting the T token, as the existing -1 path already does), and route the two raw m_filament_categories reads through the clamping get_filament_category() accessor the surrounding code already uses. No change for correctly-sized configs. --- src/libslic3r/GCode.cpp | 4 +- src/libslic3r/GCode/ToolOrdering.cpp | 19 ++++---- src/libslic3r/GCode/WipeTower.cpp | 8 ++-- .../test_toolordering_nozzle_group.cpp | 44 +++++++++++++++++++ 4 files changed, 61 insertions(+), 14 deletions(-) diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 4aa45a60ed..902786bf7d 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -3555,7 +3555,7 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato auto used_filaments = print.get_slice_used_filaments(false); this->placeholder_parser().set("is_all_bbl_filament", std::all_of(used_filaments.begin(), used_filaments.end(), [&](auto idx) { - return m_config.filament_vendor.values[idx] == "Bambu Lab"; + return m_config.filament_vendor.get_at(idx) == "Bambu Lab"; })); //add during_print_exhaust_fan_speed @@ -3572,7 +3572,7 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato this->placeholder_parser().set("outer_wall_volumetric_speed", new ConfigOptionFloat(outer_wall_volumetric_speed)); auto first_layer_filaments = print.get_slice_used_filaments(true); - bool has_tpu_in_first_layer = std::any_of(first_layer_filaments.begin(), first_layer_filaments.end(), [&](unsigned int idx) { return m_config.filament_type.values[idx] == "TPU"; }); + bool has_tpu_in_first_layer = std::any_of(first_layer_filaments.begin(), first_layer_filaments.end(), [&](unsigned int idx) { return m_config.filament_type.get_at(idx) == "TPU"; }); this->placeholder_parser().set("has_tpu_in_first_layer", new ConfigOptionBool(has_tpu_in_first_layer)); if (print.calib_params().mode == CalibMode::Calib_PA_Line) { diff --git a/src/libslic3r/GCode/ToolOrdering.cpp b/src/libslic3r/GCode/ToolOrdering.cpp index c6517c6e65..e9be0171e4 100644 --- a/src/libslic3r/GCode/ToolOrdering.cpp +++ b/src/libslic3r/GCode/ToolOrdering.cpp @@ -1488,10 +1488,10 @@ static FilamentGroupContext build_filament_group_context( auto machine_filament_info = build_machine_filaments(print->get_extruder_filament_info(), extruder_ams_counts, ignore_ext_filament); - std::vector filament_types = print_config.filament_type.values; - std::vector filament_colours = print_config.filament_colour.values; - std::vector filament_is_support = print_config.filament_is_support.values; - std::vector filament_ids = print_config.filament_ids.values; + // The grouping code walks filament_ids and indexes filament_info by the same position. + std::vector filament_ids = print_config.filament_ids.values; + if (filament_ids.size() > filament_nums) + filament_ids.resize(filament_nums); FGMode fg_mode = mode == FilamentMapMode::fmmAutoForMatch ? FGMode::MatchMode : FGMode::FlushMode; context.model_info.flush_matrix = std::move(nozzle_flush_mtx); @@ -1500,11 +1500,14 @@ static FilamentGroupContext build_filament_group_context( context.model_info.filament_ids = filament_ids; context.model_info.unprintable_volumes = unprintable_volumes; - for (size_t idx = 0; idx < filament_types.size(); ++idx) { + // Consumers index filament_info by filament id, so it must span the filament count: a partial + // or legacy config can leave any of these arrays short, and get_at clamps. + context.model_info.filament_info.reserve(filament_nums); + for (size_t idx = 0; idx < filament_nums; ++idx) { FilamentGroupUtils::FilamentInfo info; - info.color = filament_colours[idx]; - info.type = filament_types[idx]; - info.is_support = filament_is_support[idx]; + info.color = print_config.filament_colour.get_at(idx); + info.type = print_config.filament_type.get_at(idx); + info.is_support = print_config.filament_is_support.get_at(idx); context.model_info.filament_info.emplace_back(std::move(info)); } diff --git a/src/libslic3r/GCode/WipeTower.cpp b/src/libslic3r/GCode/WipeTower.cpp index 589ac14bad..e80433f4ae 100644 --- a/src/libslic3r/GCode/WipeTower.cpp +++ b/src/libslic3r/GCode/WipeTower.cpp @@ -1349,7 +1349,7 @@ public: // flavor it reaches understands, not the zero dwell the other flavors flush with. buffer += "M400\n"; buffer += "M104"; - if (target_extruder != -1) + if (target_extruder != -1 && target_extruder < int(m_physical_extruder_map.size())) buffer += (" T" + std::to_string(m_physical_extruder_map[target_extruder])); buffer += " S" + std::to_string(target_temp) + " N0"; // N0 means the gcode is generated by slicer if (!comment.empty()) buffer += " ;" + comment; @@ -1361,7 +1361,7 @@ public: WipeTowerWriter &format_line_M109(int target_temp, int target_extruder, const std::string &comment = std::string()) { std::string buffer = "M109"; - if (target_extruder != -1) + if (target_extruder != -1 && target_extruder < int(m_physical_extruder_map.size())) buffer += (" T" + std::to_string(m_physical_extruder_map[target_extruder])); buffer += " S" + std::to_string(target_temp) + " N0"; // N0 means the gcode is generated by slicer if (!comment.empty()) buffer += " ;" + comment; @@ -3309,7 +3309,7 @@ void WipeTower::get_wall_skip_points(const WipeTowerInfo &layer, int layer_id) if (!cur_block_depth.count(m_filpar[new_filament].category)) cur_block_depth[m_filpar[new_filament].category] = block->start_depth; process_depth = cur_block_depth[m_filpar[new_filament].category]; if (is_need_ramming(new_filament, old_filament, layer_id)) { - if (m_filament_categories[new_filament] == m_filament_categories[old_filament]) + if (get_filament_category(new_filament) == get_filament_category(old_filament)) process_depth += nozzle_change_depth; else { if (!cur_block_depth.count(m_filpar[old_filament].category)) { @@ -4783,7 +4783,7 @@ int WipeTower::get_wall_filament_for_all_layer() int filament_id = -1; int filament_count = 0; for (auto iter = filament_counts.begin(); iter != filament_counts.end(); ++iter) { - if (m_filament_categories[iter->first] == selected_category && iter->second > filament_count) { + if (get_filament_category(iter->first) == selected_category && iter->second > filament_count) { filament_id = iter->first; filament_count = iter->second; } diff --git a/tests/libslic3r/test_toolordering_nozzle_group.cpp b/tests/libslic3r/test_toolordering_nozzle_group.cpp index 26e36c0dbf..d01ccf5856 100644 --- a/tests/libslic3r/test_toolordering_nozzle_group.cpp +++ b/tests/libslic3r/test_toolordering_nozzle_group.cpp @@ -163,6 +163,50 @@ TEST_CASE("H2C multi-nozzle: filaments get distinct nozzles on the 6-nozzle extr } } +TEST_CASE("Grouping context spans the filament count with mis-sized config arrays", "[ToolOrdering][H2C]") +{ + // FilamentGroup indexes the grouping context's filament_info by filament id, so a short + // per-filament array must not shorten it: the reads run off the end. + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + // Single 6-nozzle extruder: opens the grouping engine without needing a BBL multi-extruder. + config.option("nozzle_diameter", true)->values = {0.4}; + config.option("extruder_max_nozzle_count", true)->values = {6}; + config.option("extruder_nozzle_stats", true)->values = {"Standard#6"}; + + // Four filaments, with filament_type / filament_is_support left short on purpose. + config.option("filament_colour", true)->values = {"#FF0000", "#00FF00", "#0000FF", "#FFFF00"}; + config.option("filament_type", true)->values = {"PLA"}; + config.option("filament_is_support", true)->values = {0}; + config.option("filament_diameter", true)->values = {1.75, 1.75, 1.75, 1.75}; + config.option("filament_map", true)->values = {1, 1, 1, 1}; + config.option("flush_volumes_matrix", true)->values = std::vector(16, 140.); + config.option("flush_multiplier", true)->values = {1.}; + + Model model; + model.add_object("cube", "", make_cube(20, 20, 20))->add_instance(); + + Print print; + print.apply(model, config); + // apply() does not pad the per-filament arrays, so the mis-sizing survives into the engine. + REQUIRE(print.config().filament_type.values.size() < print.config().filament_colour.values.size()); + + std::vector> layer_filaments = {{0, 1}, {1, 2}, {2, 3}}; + + SECTION("short per-filament arrays still yield one entry per filament") { + auto result = ToolOrdering::get_recommended_filament_maps(layer_filaments, &print, FilamentMapMode::fmmAutoForFlush, {}, {}); + REQUIRE(result.get_extruder_map(false).size() == 4); + for (int f = 0; f < 4; ++f) + REQUIRE(result.get_extruder_id(f) == 0); + } + + SECTION("filament_ids longer than the filament count is truncated, not paired past the end") { + config.option("filament_ids", true)->values = {"a", "b", "c", "d", "e", "f"}; + print.apply(model, config); + auto result = ToolOrdering::get_recommended_filament_maps(layer_filaments, &print, FilamentMapMode::fmmAutoForFlush, {}, {}); + REQUIRE(result.get_extruder_map(false).size() == 4); + } +} + TEST_CASE("H2C dynamic selector: per-layer nozzle ids reach the g-code surface", "[ToolOrdering][H2C][Dynamic]") { // The per-layer regroup engine From ac3997c0d1920dc37ebb0a093e7e4ba423a4e7ea Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Tue, 15 Sep 2026 07:46:31 -0500 Subject: [PATCH 13/17] fix: bounds-check the toolchange flush-volume and HRC per-filament lookups (#15289) * fix: bounds-check the toolchange flush-volume and HRC per-filament lookups GCode::set_extruder's toolchange flush-volume lookup and GCodeProcessor::update_slice_warnings's HRC check index per-filament and per-extruder arrays (flush_volumes_matrix, the filament map, the nozzle list) by filament/extruder id. When a config leaves one of those arrays shorter than the filament count (partial or legacy multi-extruder projects, minimal configs), the reads run off the end: silent on a normal STL, a hard abort under _GLIBCXX_ASSERTIONS. Route both reads through bounds checks: the flush lookup falls back to no flush, matching the existing unknown-old-filament branch beside it, and the HRC check skips an unmapped filament, mirroring the required_nozzle_HRC guard on the line above. When the arrays are sized to the filament count the values are unchanged, so correctly-specified configs are unaffected. * ci: retrigger checks --- src/libslic3r/GCode.cpp | 6 ++++-- src/libslic3r/GCode/GCodeProcessor.cpp | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 902786bf7d..12a3a73e7c 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -9474,12 +9474,14 @@ std::string GCode::set_extruder(unsigned int new_filament_id, double print_z, bo if (old_filament_id_in_new_extruder == -1) wipe_volume = 0; else { - wipe_volume = flush_matrix[old_filament_id_in_new_extruder * number_of_extruders + new_filament_id]; + size_t flush_idx = size_t(old_filament_id_in_new_extruder) * number_of_extruders + new_filament_id; + wipe_volume = flush_idx < flush_matrix.size() ? flush_matrix[flush_idx] : 0.f; wipe_volume *= m_config.flush_multiplier.get_at(new_extruder_id); } } else { - wipe_volume = flush_matrix[old_filament_id * number_of_extruders + new_filament_id]; + size_t flush_idx = size_t(old_filament_id) * number_of_extruders + new_filament_id; + wipe_volume = flush_idx < flush_matrix.size() ? flush_matrix[flush_idx] : 0.f; wipe_volume *= m_config.flush_multiplier.get_at(new_extruder_id); // if is multi_extruder only use the fist extruder matrix } wipe_volume = std::max(0.f, wipe_volume-grab_purge_volume); diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp index e4da19cd73..6fc7717386 100644 --- a/src/libslic3r/GCode/GCodeProcessor.cpp +++ b/src/libslic3r/GCode/GCodeProcessor.cpp @@ -7596,8 +7596,8 @@ void GCodeProcessor::update_slice_warnings() if (used_filaments[idx] < m_result.required_nozzle_HRC.size()) filament_hrc = m_result.required_nozzle_HRC[used_filaments[idx]]; - int filament_extruder_id = m_filament_maps[used_filaments[idx]]; - int extruder_hrc = nozzle_hrc_lists[filament_extruder_id]; + int filament_extruder_id = used_filaments[idx] < m_filament_maps.size() ? m_filament_maps[used_filaments[idx]] : -1; + int extruder_hrc = (filament_extruder_id >= 0 && (size_t) filament_extruder_id < nozzle_hrc_lists.size()) ? nozzle_hrc_lists[filament_extruder_id] : 0; BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": Check HRC: filament:%1%, hrc=%2%, extruder:%3%, hrc:%4%") % used_filaments[idx] % filament_hrc % filament_extruder_id % extruder_hrc; From 7e545651bb6256e008517a26ca93e66450b97624 Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Tue, 15 Sep 2026 09:55:11 -0500 Subject: [PATCH 14/17] deps: compile unicodectype.c unoptimised in the Windows arm64 Python (#15719) VS 2026's ARM64 code generator needs about 27 GB for _PyUnicode_ToNumeric, a switch with 1951 cases in Objects/unicodetype_db.h; the same file takes under 1 GB on x64. The 16 GB CI runner has an 18.9 GB commit limit and only gets through when Windows grows the pagefile on the temp disk in time, so cold arm64 dependency builds fail at random with C1002 "compiler is out of heap space". build_release_vs.bat returns 0 on failure, so the job still reports success and the incomplete dependencies are cached. A property sheet compiles that one file with optimisation off on arm64; the rest stays whole-program optimised and x64 is unchanged. MSBuild reads it from PCbuild/msbuild.rsp, which is now written at configure time and copied in, so a checkout path with spaces works too. --- deps/python3/arm64-unicodectype.props | 12 ++++++++++++ deps/python3/python3.cmake | 12 +++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 deps/python3/arm64-unicodectype.props diff --git a/deps/python3/arm64-unicodectype.props b/deps/python3/arm64-unicodectype.props new file mode 100644 index 0000000000..5f5727c842 --- /dev/null +++ b/deps/python3/arm64-unicodectype.props @@ -0,0 +1,12 @@ + + + + + + Disabled + false + + + diff --git a/deps/python3/python3.cmake b/deps/python3/python3.cmake index 2eae315d0b..3e063fac4e 100644 --- a/deps/python3/python3.cmake +++ b/deps/python3/python3.cmake @@ -88,8 +88,18 @@ if(WIN32) list(APPEND _python_env_args "PreferredToolArchitecture=${_python_tool_arch}") endif() + # MSBuild reads extra switches from PCbuild/msbuild.rsp. + set(_python_rsp "/p:PlatformToolset=${_python_platform_toolset}\n") + # VS 2026's ARM64 code generator needs about 27 GB for one function in + # Objects/unicodectype.c (python/cpython#153668); the property sheet compiles + # that file without optimisation. + if(_python_pcbuild_platform STREQUAL "ARM64") + file(TO_NATIVE_PATH "${CMAKE_CURRENT_LIST_DIR}/arm64-unicodectype.props" _python_arm64_props) + string(APPEND _python_rsp "/p:ForceImportAfterCppTargets=\"${_python_arm64_props}\"\n") + endif() + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/python3-msbuild.rsp" "${_python_rsp}") set(_conf_cmd - cmd /c "echo /p:PlatformToolset=${_python_platform_toolset}>PCbuild\\msbuild.rsp" + ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_BINARY_DIR}/python3-msbuild.rsp" /PCbuild/msbuild.rsp ) set(_build_cmd ${CMAKE_COMMAND} -E env ${_python_env_args} From 9409598c2a9c68ec571720799187bf7f02367487 Mon Sep 17 00:00:00 2001 From: Kris Austin Date: Tue, 15 Sep 2026 10:41:46 -0500 Subject: [PATCH 15/17] ci: run the unit-test suite under the flatpak build's bounds-checked STL (#14709) * ci(flatpak): run the unit suite in a separate job, mirroring the other arches Alternative to the in-job step: split build and test like the Linux/Windows/ macOS legs. The flatpak build now builds the test binaries in-sandbox (the action's run-tests fires the module's build-only test-commands), prunes the kept build tree to the test binaries + CTest metadata + data, and uploads it with /app as a test asset (size reported to the run summary). A new unit_tests_flatpak matrix job downloads that asset on a native runner, restores the module-build symlink, and runs the suite via flatpak-builder --run (which bind-mounts /run/build so TEST_DATA_DIR resolves) against the GNOME SDK's bounds-checked STL. Results feed publish_test_results. Costs a per-arch asset upload/download + a runtime install on the test runner; the trade-off vs the in-job step is a genuine separate graph box. * ci(flatpak): run tests via `flatpak build` to avoid rofiles-fuse `flatpak-builder --run` sets up a rofiles-fuse overlay that this CI container rejects (Failure spawning rofiles-fuse, exit_status: 256), even in a fresh job with a machine-id and the runtime installed, and --disable-rofiles-fuse is not accepted in --run mode. `flatpak build` enters the sandbox via bwrap directly, so it sidesteps rofiles-fuse; bind-mounting the build tree at /run/build gives the same path the compiled-in TEST_DATA_DIR expects. * ci(flatpak): slim the test asset (strip binaries, drop source tree) The first cut shipped ~1 GB: the test exes carried debug info (the SDK builds with -g and only the app gets stripped) and the packaged module dir included the whole copied source tree the tests never read at runtime. Strip the test binaries and keep only build_flatpak/tests, tests/ (TEST_DATA_DIR) and scripts/. The irreducible remainder is /app, which the exes link against. * ci(flatpak): extract the test run into a reusable unit_tests_flatpak workflow Move the flatpak test job out of build_all.yml into a reusable unit_tests_flatpak.yml, called once per arch (Flatpak x86_64 / aarch64) the same way the other arches call unit_tests.yml. build_all.yml keeps only the build + asset packaging; the reusable workflow downloads the asset, runs the suite via `flatpak build`, and uploads results as test-results- for publish_test_results. Drops the now-unused manifest checkout (flatpak build does not need it). * ci(flatpak): trim comments to the non-obvious No behavior change. * ci(flatpak): drop redundant caller comment * ci(flatpak): drop redundant trim comment * ci(flatpak): drop size-report scaffolding and redundant if-guards * ci(flatpak): force the app module to rebuild so the test asset always exists flatpak-builder caches modules by content hash and skips a hit, producing no build tree and no test asset, so a re-run of the same commit would leave the separate test job with nothing to download. Inject a per-run cache-buster into the OrcaSlicer module's build-options (part of its cache key) so it always rebuilds, mirroring how the other arches cache only deps and always rebuild the app and tests. The deps modules stay cached. * ci(flatpak): trim cache-buster comment, fix stale step name * fix: guard H2C per-filament array reads against short config arrays The H2C tool-ordering, wipe-tower, and g-code export paths index per-filament config arrays by filament/tool id. A config with fewer entries than the filament count (partial or legacy projects, minimal test configs) makes these reads run past the end of the vector: silent under a normal STL, but UB that aborts under the flatpak build's bounds-checked STL (_GLIBCXX_ASSERTIONS). Route the reads through the existing clamping accessors (get_at, get_filament_category, is_in_same_extruder) and add a small clamp helper for filament_change_length. The guards are no-ops when the arrays are sized to the filament count, so correctly specified configs are unaffected. * ci(flatpak): build filament_group_tests too The suite landed on main after this branch was cut and arrived via a later merge, so it was missing from the target list and ctest failed the leg with filament_group_tests_NOT_BUILT. Not tests/all, which build_linux.sh uses: that is a Ninja subdirectory target and this build configures with the default Makefile generator, where it does not exist. * ci(flatpak): give the embedded-interpreter tests a valid Python home python_test_support.hpp sets PyConfig.home to /python when that path resolves. WIN32/APPLE populate it with a copied bundled runtime; the flatpak leg had no such branch, so home resolved to a directory with no stdlib and all 21 embedded plugin tests failed at "failed to get the Python codec of the filesystem encoding". Symlink /python to the bundled /app/libpython that already ships in the flatpak (the test exe links libpython3.12.so from there via rpath), so the interpreter initializes without duplicating the runtime. * ci(flatpak): sync the ToolOrdering guard mirror with #14789 Match #14709's build_filament_group_context guard to the version on #14789 (size filament_info to filament_nums, truncate filament_ids) so the folded guard is a byte-identical mirror that drops cleanly when #14789 merges, instead of leaving a stale hunk that conflicts on rebase. * fix: guard WipeTower per-filament array reads against short config arrays The BambuStudio WipeTower sync reintroduced raw per-filament array indexing that reads out of bounds when a config leaves an array shorter than the filament count: m_physical_extruder_map in format_line_M104/M109 (indexed even when empty), and m_filament_categories in get_wall_skip_points and get_wall_filament_for_all_layer. Silent on a normal STL, a hard abort under the bounds-checked STL the Flatpak build uses. Bounds-check the physical extruder map before indexing (omitting the T token, as the existing -1 path already does), and route the two raw m_filament_categories reads through the clamping get_filament_category() accessor the surrounding code already uses. No change for correctly-sized configs. * fix: default-initialize WallToolPathsParams fields min_length_factor and is_top_or_bottom_layer had no default initializers, and the FillConcentric/FillConcentricInternal callers never set them, so WallToolPaths::removeSmallLines() thresholded on stack garbage. Which short extrusion lines it dropped then depended on memory layout, so concentric solid-infill output was nondeterministic between runs and across machines. Give every member a default, matching the adjacent FillParams. The perimeter path was already fine because it builds the struct via make_paths_params(). * fix: bounds-check the toolchange flush-volume and HRC per-filament lookups GCode::set_extruder's toolchange flush-volume lookup and GCodeProcessor::update_slice_warnings's HRC check index per-filament and per-extruder arrays (flush_volumes_matrix, the filament map, the nozzle list) by filament/extruder id. When a config leaves one of those arrays shorter than the filament count (partial or legacy multi-extruder projects, minimal configs), the reads run off the end: silent on a normal STL, a hard abort under _GLIBCXX_ASSERTIONS. Route both reads through bounds checks: the flush lookup falls back to no flush, matching the existing unknown-old-filament branch beside it, and the HRC check skips an unmapped filament, mirroring the required_nozzle_HRC guard on the line above. When the arrays are sized to the filament count the values are unchanged, so correctly-specified configs are unaffected. * ci: retrigger checks * ci: name the flatpak rebuild token after the cache it defeats Since #15650 the Flatpak job also has a compiler cache, so a bare "cache-buster" no longer says which cache is meant. Call it flatpak_builder_cache_buster, and name the build-dir trim step after the flatpak-builder cache save it keeps lean. * ci: ship resources/profiles and resources/printers in the flatpak test asset Two slic3rutils tests added in 4aa0e1d60b read resources/printers/bambu_filament_ids.json through PROFILES_DIR/.., and the asset dropped resources/ entirely, so both failed parsing an empty stream on each Flatpak leg. Keep the two subtrees the tests reach; test_gcodewriter's shipped-profile case stops skipping on this leg too. * ci: restore the CRLF line endings of build_all.yml The last merge from upstream/main rewrote the file with LF endings, which turns the 60-line change into a whole-file diff on GitHub. Upstream has had this file as CRLF since it was created, so put it back. * ci: trigger Build all on changes to the unit-test workflows The path filters only matched build_*.yml, so an edit to unit_tests.yml or unit_tests_flatpak.yml could merge without ever running. * ci: put a timeout on the flatpak unit-test step Matches the 20 minutes of the regular unit-test workflow; without it a hung test holds the runner for the six-hour job default. --- .github/workflows/build_all.yml | 62 ++++++++++++++++- .github/workflows/unit_tests_flatpak.yml | 67 +++++++++++++++++++ scripts/flatpak/com.orcaslicer.OrcaSlicer.yml | 18 +++++ tests/slic3rutils/CMakeLists.txt | 12 ++++ 4 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/unit_tests_flatpak.yml diff --git a/.github/workflows/build_all.yml b/.github/workflows/build_all.yml index 10edec5aaa..f8d6bb8235 100644 --- a/.github/workflows/build_all.yml +++ b/.github/workflows/build_all.yml @@ -14,6 +14,7 @@ on: - 'localization/**' - 'resources/**' - ".github/workflows/build_*.yml" + - ".github/workflows/unit_tests*.yml" - 'scripts/build_preset_cache.*' - 'scripts/flatpak/**' - 'scripts/msix/**' @@ -30,6 +31,7 @@ on: - '**/CMakeLists.txt' - 'version.inc' - ".github/workflows/build_*.yml" + - ".github/workflows/unit_tests*.yml" - 'build_linux.sh' - 'build_release_vs.bat' - 'build_release_vs2022.bat' @@ -207,7 +209,7 @@ jobs: ./validator-bin/OrcaSlicer_profile_validator -p "${{ github.workspace }}/resources/profiles" -s -l 2 publish_test_results: name: Publish Test Results - needs: [unit_tests_linux_x86_64, unit_tests_linux_aarch64, unit_tests_windows_x64, unit_tests_windows_arm64, unit_tests_macos_arm64] + needs: [unit_tests_linux_x86_64, unit_tests_linux_aarch64, unit_tests_windows_x64, unit_tests_windows_arm64, unit_tests_macos_arm64, unit_tests_flatpak_x86_64, unit_tests_flatpak_aarch64] if: ${{ !cancelled() }} runs-on: ubuntu-latest steps: @@ -324,9 +326,16 @@ jobs: sed -i '/^build-options:/a\ no-debuginfo: true\n strip: true' \ scripts/flatpak/com.orcaslicer.OrcaSlicer.yml shell: bash - - name: Inject git commit hash into Flatpak manifest + # flatpak-builder reuses a module from its cache when the definition and + # sources are unchanged, so a re-run of the same commit would skip the + # OrcaSlicer module and ship no test asset. A per-run value in that module's + # env keeps it rebuilding; orca_deps stays cached, and the compiler cache + # still serves the rebuild. + - name: Inject commit hash and flatpak-builder cache buster into Flatpak manifest + env: + flatpak_builder_cache_buster: ${{ github.run_id }}-${{ github.run_attempt }} run: | - sed -i "/name: OrcaSlicer/{n;s|buildsystem: simple|buildsystem: simple\n build-options:\n env:\n git_commit_hash: \"$git_commit_hash\"|}" \ + sed -i "/name: OrcaSlicer/{n;s|buildsystem: simple|buildsystem: simple\n build-options:\n env:\n flatpak_builder_cache_buster: \"$flatpak_builder_cache_buster\"\n git_commit_hash: \"$git_commit_hash\"|}" \ scripts/flatpak/com.orcaslicer.OrcaSlicer.yml shell: bash # flatpak-builder's --ccache only wraps cc and gcc, and the manifest builds @@ -372,6 +381,10 @@ jobs: save-cache: false arch: ${{ matrix.variant.arch }} upload-artifact: false + # run-tests fires the module's build-only test-commands; keep-build-dirs + # retains the binaries for the packaging step below. + run-tests: true + keep-build-dirs: true # The build has just touched everything it can use, so an object untouched # for a week is dead, usually orphaned by a flag change. - name: Compiler cache statistics @@ -425,3 +438,46 @@ jobs: asset_name: OrcaSlicer-Linux-flatpak_nightly${{ env.nightly_suffix }}_${{ matrix.variant.arch }}.flatpak asset_content_type: application/octet-stream max_releases: 1 # optional, if there are more releases than this matching the asset_name, the oldest ones are going to be deleted + # The asset is /app (the exes link it at runtime) plus the build tree + # slimmed to what ctest needs. + - name: Package flatpak test asset + shell: bash + run: | + d=$(ls -d .flatpak-builder/build/OrcaSlicer-* | tail -1) + find "$d/build_flatpak" -mindepth 1 -maxdepth 1 ! -name tests -exec rm -rf {} + + # Strip debug info (the SDK builds with -g, only the app gets stripped); + # the bounds checks are compiled in, so a stripped exe still catches them. + find "$d/build_flatpak/tests" -type f -perm -u+x -exec strip --strip-unneeded {} + 2>/dev/null || true + # At runtime the tests read tests/ (TEST_DATA_DIR), scripts/, and under + # resources/ the shipped profiles (PROFILES_DIR) and the printers/ maps. + find "$d" -mindepth 1 -maxdepth 1 -type d \ + ! -name tests ! -name build_flatpak ! -name scripts ! -name resources -exec rm -rf {} + + find "$d/resources" -mindepth 1 -maxdepth 1 ! -name profiles ! -name printers -exec rm -rf {} + + tar -cf flatpak-test-asset.tar flatpak_app "$d" + - name: Upload flatpak test asset + uses: actions/upload-artifact@v7 + with: + name: ${{ github.sha }}-flatpak-tests-${{ matrix.variant.arch }} + path: flatpak-test-asset.tar + retention-days: 1 + # keep-build-dirs would otherwise land in the flatpak-builder cache saved post-job. + - name: Drop the kept build dirs before the flatpak-builder cache saves + if: always() + shell: bash + run: rm -rf .flatpak-builder/build + unit_tests_flatpak_x86_64: + name: Flatpak x86_64 + needs: flatpak + if: ${{ !cancelled() && success() }} + uses: ./.github/workflows/unit_tests_flatpak.yml + with: + os: ubuntu-24.04 + artifact: ${{ github.sha }}-flatpak-tests-x86_64 + unit_tests_flatpak_aarch64: + name: Flatpak aarch64 + needs: flatpak + if: ${{ !cancelled() && success() }} + uses: ./.github/workflows/unit_tests_flatpak.yml + with: + os: ubuntu-24.04-arm + artifact: ${{ github.sha }}-flatpak-tests-aarch64 diff --git a/.github/workflows/unit_tests_flatpak.yml b/.github/workflows/unit_tests_flatpak.yml new file mode 100644 index 0000000000..ce261c210c --- /dev/null +++ b/.github/workflows/unit_tests_flatpak.yml @@ -0,0 +1,67 @@ +name: Flatpak Unit Tests + +# Run the flatpak build's test asset inside the sandbox, once per arch. The +# GNOME SDK's _GLIBCXX_ASSERTIONS gives a bounds-checked STL that catches +# out-of-bounds reads no other test leg does. +on: + workflow_call: + inputs: + os: + required: true + type: string + artifact: + description: Test asset uploaded by the flatpak build leg + required: true + type: string + +jobs: + unit_tests_flatpak: + name: Flatpak Unit Tests + runs-on: ${{ inputs.os }} + container: + image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-50 + options: --privileged + steps: + - name: Restore test asset + uses: actions/download-artifact@v8 + with: + name: ${{ inputs.artifact }} + - name: Run unit tests (bounds-checked sandbox) + timeout-minutes: 20 + shell: bash + run: | + tar -xf flatpak-test-asset.tar + # Recreate the stable module symlink so /run/build/OrcaSlicer resolves. + d=$(ls -d .flatpak-builder/build/OrcaSlicer-* | tail -1) + ln -sfn "$(basename "$d")" .flatpak-builder/build/OrcaSlicer + # The runtime + SDK + the llvm extension the app metadata references, + # which `flatpak build` mounts; best-effort, the image may have them. + flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo + flatpak install --user -y --noninteractive flathub \ + org.gnome.Platform//50 org.gnome.Sdk//50 org.freedesktop.Sdk.Extension.llvm21//25.08 || true + # `flatpak build` uses bwrap (no rofiles-fuse, which this container + # rejects); bind-mount the build tree so the baked TEST_DATA_DIR resolves. + flatpak build --die-with-parent --share=network \ + --bind-mount=/run/build="$PWD/.flatpak-builder/build" \ + flatpak_app \ + bash -c 'cd /run/build/OrcaSlicer && scripts/run_unit_tests.sh build_flatpak/tests' + - name: Collect test results + if: always() + shell: bash + run: | + d=$(ls -d .flatpak-builder/build/OrcaSlicer-* 2>/dev/null | tail -1 || true) + [ -n "$d" ] && [ -f "$d/ctest_results.xml" ] && cp "$d/ctest_results.xml" ctest_results.xml || true + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results-${{ inputs.artifact }} + path: ctest_results.xml + retention-days: 5 + if-no-files-found: warn + - name: Delete Test Asset + if: success() + uses: geekyeggo/delete-artifact@v6 + with: + name: ${{ inputs.artifact }} + failOnError: false diff --git a/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml b/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml index 00aa430f84..668f51334b 100644 --- a/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml +++ b/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml @@ -378,6 +378,17 @@ modules: - cmake --build build_flatpak --target generate_system_cache -j$FLATPAK_BUILDER_N_JOBS - ./scripts/build_preset_cache.sh -n -b build_flatpak /app/share/OrcaSlicer/profiles + # Built (not run) here via the action's run-tests, then shipped to a separate + # test job. Only the test sources compile; nothing installs to /app. + test-commands: + - cmake . -B build_flatpak -DBUILD_TESTS=ON + # A suite missing from this list fails the leg loudly, since ctest registers a + # _NOT_BUILT test for it. (tests/all is a Ninja subdirectory target and + # this build uses the default Makefile generator, so it is not available here.) + - cmake --build build_flatpak -j"${FLATPAK_BUILDER_N_JOBS:-$(nproc)}" --target + libslic3r_tests fff_print_tests sla_print_tests libnest2d_tests slic3rutils_tests + filament_group_tests + cleanup: - /include @@ -414,6 +425,10 @@ modules: - type: dir path: ../../localization dest: localization + # For the post-build unit-test step (BUILD_TESTS=ON); not built by the app. + - type: dir + path: ../../tests + dest: tests - type: file path: ../../CMakeLists.txt @@ -427,6 +442,9 @@ modules: - type: file path: ../build_preset_cache.sh dest: scripts + - type: file + path: ../run_unit_tests.sh + dest: scripts # AppData metainfo for GNOME Software & Co. - type: file diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 2ab78f56de..3ddacc5a1b 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -55,6 +55,18 @@ elseif (APPLE) COMMENT "Copying Python runtime for macOS plugin host API tests" VERBATIM ) +elseif (FLATPAK) + # Same /python home as WIN32/APPLE; symlink since /app/libpython + # already ships in the flatpak (the test exe links libpython3.12.so from it). + add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD + COMMAND ${CMAKE_COMMAND} -E rm -rf + "$/python" + COMMAND ${CMAKE_COMMAND} -E create_symlink + "${CMAKE_PREFIX_PATH}/libpython" + "$/python" + COMMENT "Linking Python runtime for flatpak plugin host API tests" + VERBATIM + ) endif() orcaslicer_discover_tests(${_TEST_NAME}_tests) From a0ada1aa882b1aa0ceb77d0a5e0684dcfd174100 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Wed, 16 Sep 2026 00:43:50 +0800 Subject: [PATCH 16/17] fix wiki links --- AGENTS.md | 6 +++--- resources/data/hints.ini | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4be195b40d..01402af3eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,16 +64,16 @@ ctest --test-dir ./tests/fff_print - Keep code concise and clear. Manually simplify AI generated bloated codes before review. - Include targeted tests or documented verification for behavior changes, especially in slicing logic, profiles, formats, and GUI defaults. - For profile changes (`resources/profiles//**`), check that `version` in the sibling `resources/profiles/.json` was bumped. -- For translation changes (`localization/i18n/**/*.po`), check that recurring terms match the [Localization glossary](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/guides/localization_glossary.md) for that language. +- For translation changes (`localization/i18n/**/*.po`), check that recurring terms match the [Localization glossary](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/developer_reference/localization_glossary.md) for that language. ## Localization & translations Catalogs live in `localization/i18n//OrcaSlicer_.po`; the template is `OrcaSlicer.pot`. -See the [Localization guide](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/guides/localization_guide.md) for the human-facing version of these principles. +See the [Localization guide](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/developer_reference/localization_guide.md) for the human-facing version of these principles. ### Terminology -- Use the [Localization glossary](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/guides/localization_glossary.md) as the source of truth for recurring terms, so the same English term is always rendered the same way within a language, and terms that must stay in English (brand/product names, acronyms, materials, file formats, G-code tokens, macros/variables/identifiers) are not translated. +- Use the [Localization glossary](https://github.com/OrcaSlicer/OrcaSlicer_WIKI/blob/main/developer_reference/localization_glossary.md) as the source of truth for recurring terms, so the same English term is always rendered the same way within a language, and terms that must stay in English (brand/product names, acronyms, materials, file formats, G-code tokens, macros/variables/identifiers) are not translated. - If a term's established translation changes, update both the affected `.po` files and the glossary (`localization_glossary.tsv`, then regenerate) so they stay in sync. - Translate the *meaning*, not the words. Check what the string actually controls before translating it — English reuses one word for different things. `Flow ratio` (multiplier), `Flow Rate` (throughput) and `Flow Dynamics` (pressure compensation) are three different terms; `extruder` may mean the toolhead, the feeder motor, or the nozzle depending on the string. - Reuse one template per recurring message shape (`Failed to connect to …`, `Are you sure you want to …?`), even where the English wording varies. diff --git a/resources/data/hints.ini b/resources/data/hints.ini index 15d2758551..a71fb868f9 100644 --- a/resources/data/hints.ini +++ b/resources/data/hints.ini @@ -75,7 +75,7 @@ documentation_link = https://www.orcaslicer.com/wiki/material_temperatures#print [hint:Calibration] text = Calibration\nDid you know that calibrating your printer can do wonders? Check out our beloved calibration solution in OrcaSlicer. -documentation_link = https://www.orcaslicer.com/wiki/calibration +documentation_link = https://www.orcaslicer.com/wiki/calibration_guide [hint:Auxiliary fan] text = Auxiliary fan\nDid you know that OrcaSlicer supports Auxiliary part cooling fan? From 3e1daccd7c567a0a3d2b5721841d30845f21307e Mon Sep 17 00:00:00 2001 From: Valerii Bokhan <80919135+valerii-bokhan@users.noreply.github.com> Date: Wed, 16 Sep 2026 01:01:39 +0200 Subject: [PATCH 17/17] Feature: Add inward wipe for external perimeters (#15407) --- docs/HLSD/wipe-inward.md | 170 ++++ src/libslic3r/CMakeLists.txt | 2 + src/libslic3r/GCode.cpp | 198 +++-- src/libslic3r/GCode.hpp | 13 +- src/libslic3r/GCode/WipePathHelpers.cpp | 920 ++++++++++++++++++++ src/libslic3r/GCode/WipePathHelpers.hpp | 96 +++ src/libslic3r/Preset.cpp | 2 + src/libslic3r/Print.cpp | 2 + src/libslic3r/PrintConfig.cpp | 29 + src/libslic3r/PrintConfig.hpp | 2 + src/libslic3r/PrintObject.cpp | 2 + src/slic3r/GUI/ConfigManipulation.cpp | 3 + src/slic3r/GUI/Plater.cpp | 2 + src/slic3r/GUI/Tab.cpp | 2 + src/slic3r/Utils/CalibUtils.cpp | 2 + tests/fff_print/CMakeLists.txt | 1 + tests/fff_print/test_wipe.cpp | 653 +++++++++++++++ tests/libslic3r/CMakeLists.txt | 1 + tests/libslic3r/test_wipe_path.cpp | 1024 +++++++++++++++++++++++ 19 files changed, 3041 insertions(+), 83 deletions(-) create mode 100644 docs/HLSD/wipe-inward.md create mode 100644 src/libslic3r/GCode/WipePathHelpers.cpp create mode 100644 src/libslic3r/GCode/WipePathHelpers.hpp create mode 100644 tests/fff_print/test_wipe.cpp create mode 100644 tests/libslic3r/test_wipe_path.cpp diff --git a/docs/HLSD/wipe-inward.md b/docs/HLSD/wipe-inward.md new file mode 100644 index 0000000000..9f1c617cc3 --- /dev/null +++ b/docs/HLSD/wipe-inward.md @@ -0,0 +1,170 @@ +# Wipe inward — High Level Design + +## Purpose and scope + +Wipe inward reduces reheating of fresh plastic and visible seam artifacts by +moving the hot nozzle toward adjacent printed material during the external-wall +wipe. Wipe marks are especially visible at layer heights below 0.1 mm. +The option applies only to wipes after external walls, including walls around +holes. It does not offset wipes after inner walls, infill or supports. For an +outer contour the move is inward; for a hole it is away from the hole, toward +the surrounding material. The path must remain supported by material that is +already present when the wipe executes. + +The operation belongs to G-code generation. It uses extrusion paths, their actual +widths and their print order. Changing its settings invalidates G-code export +while preserving the sliced geometry. + +## Settings and eligibility + +`wipe_inward` defaults to disabled and requires Wipe while retracting to be +enabled for the active filament. `wipe_inward_distance` defaults to 50% of the +actual external-wall extrusion width; it also accepts an absolute distance in +millimeters. Using the path width makes Auto width and Arachne's variable widths +meaningful. The effective offset is limited by that width and the spacing to the +adjacent wall. A zero distance disables the offset. + +Only external perimeters with a suitable, previously printed inner perimeter +are eligible. A configured wall count alone cannot establish eligibility: +the local geometry may contain fewer walls, and walls scheduled later do not +provide support. Outer/Inner wall order therefore normally retains the regular +wipe path. + +Retraction and pressure advance calibrations disable inward wiping so it cannot +mask the behavior being measured. The calibration settings turn it off, and +G-code generation enforces this even if a profile or object override enables it. + +## Path selection and support + +The planner identifies an adjacent inner perimeter on the material side of the +outgoing wall. Contour winding and the distinction between outer contours and +holes establish a preferred direction; local printed geometry resolves ambiguous +or self-touching contours. + +Candidate paths offset or translate the portion needed for the configured wipe +distance. A wide seam gap can prevent a supported forward path; following the +incoming printed wall backwards is also a candidate. If translating that wall +cannot provide a complete wipe around a curve, the planner tries an offset of +the reversed wall. Direction checks allow coordinate-rounding error at a +perpendicular entry, while rejecting actual backtracking. The planner checks the +complete executable path, including its connector from the nozzle position, +against the current and earlier printed perimeters. Nearby endpoints alone do +not establish support across a gap. + +Each region accumulates its printed perimeter prefix once, in extrusion order. +Every entity contributes its geometry only after it is printed, and the prefix +is discarded when the region ends. This collection is skipped when inward wiping +is disabled or its configured distance is zero. A mixed inner-wall loop remains +an eligible target even when its first path is an overhang: ordinary inner-wall +paths elsewhere in the loop identify it. Likewise, an external loop with an +overhanging start remains eligible when other segments identify the external +wall. It is available for support checks but is not an inner-wall target. +Candidate-specific support filtering and AABB trees are built only for eligible +external loops, then reused across their candidate paths. + +Material-side validation applies with or without a seam gap. Along each +candidate, local wall normals point toward the adjacent printed inner wall; +samples on the opposite side are rejected even when they remain close enough +to the external wall to pass the support check. This uses the open wall geometry +without treating it as a closed polygon. Full paths at a zero-gap seam also +retain clearance from the external wall after their initial connector. At a +clipped corner, another branch can be closer than the requested offset, so +material-side and support checks apply without that additional clearance rule. + +An accepted candidate replaces the stored wipe path as a whole. A short direct +inward move is also eligible when longer candidates fail validation. It may +waive full wall clearance, but must pass the material-side check. Its initial +direction is checked from the actual nozzle position after any loop pre-move; +the original wall endpoint is retained separately for intersection checks. It takes +priority over the alternate offset when the preferred and translated paths +are unusable. A longer reversed path may replace the selected candidate only +when its distance to the target inner wall is no worse within tolerance. + +## Fallback to the regular wipe + +The original wipe path is retained when: + +- No suitable adjacent inner wall has already been printed near the seam. This + includes single-wall areas, locally missing inner walls and normally Outer/Inner + wall order. A distant wall or a wall on the air side does not qualify. +- The requested or available offset, or the configured wipe distance, is zero + or too small at the geometry's coordinate precision. +- Degenerate geometry prevents construction of a usable candidate, or all + candidates fail the checks for printed support, direction, wall clearance or + the connector from the actual nozzle position. This can occur at tight corners, + narrow features or seam gaps. + +Corners and seam gaps do not automatically trigger fallback: an offset, +translated, reversed or short direct inward path may still be valid. The regular +wipe is retained only when no candidate is accepted. + +Fallback uses the path and retraction rules for `wipe_inward` disabled. +Wipe while retracting must still be enabled for a wipe to occur; `wipe_on_loops` +remains controlled by its own setting. + +## Interaction with Wipe on loop + +`wipe_on_loops` is an independent option that makes a short move before leaving +an external loop. It can operate with `wipe_inward` disabled. When both options +are enabled, its destination is the starting position for the inward wipe. + +The loop move samples the outgoing and incoming paths by distance across path +boundaries. The sampling distance is bounded by the nozzle diameter and one +quarter of the total path length. It samples the outgoing path at up to 20% of +the nozzle diameter and rotates that point around the seam through one third +of the material-side corner angle. For a closed square outer contour, this +produces a move of 20% of the nozzle diameter at 30 degrees into the corner. +Coincident samples or degenerate angles suppress the move. + +The nozzle position stored by G-code generation must match the emitted loop +move. Both travel planning and wipe execution depend on this position, including +when Wipe inward is disabled. + +With a seam gap, a loop move may advance past the inward offset's original entry. +If that alone makes the connector backtrack, the entry advances to the nozzle's +projection on the offset. The planner extends the source as needed to preserve +the configured wipe length and validates the new connector and complete path. +Joins that already backtrack across the seam gap are not adjusted this way. + +## Execution and retraction + +The stored wipe path uses a sentinel first point. Execution starts from the +actual nozzle position and proceeds to the second stored point. Path selection, +support validation and wipe-length calculation must all use this same executable +geometry, especially after a Wipe on loop move. + +An accepted inward path executes at the end of the external loop, after any +Wipe on loop move, without retracting filament. It consumes the stored path and +updates the nozzle position before travel planning. A short travel to the next +wall cannot discard this wipe or force a retraction or Z-hop. Subsequent travel +uses the normal minimum-travel threshold and retraction/lift settings from the +new position. The regular wipe, including fallback, remains deferred until a +normal retraction uses it. + +Retraction is divided into portions before, during and after wiping. The amount +that can be retracted during the wipe depends on its executable length, wipe +speed and the active filament's retraction speed. Fractional retraction speeds +are retained in this calculation. For a 2 mm wipe at 100 mm/s and a retraction +speed of 25.5 mm/s, the wipe can retract 0.51 mm. With a total retraction of 0.8 mm +and both before/after percentages set to zero, the remaining 0.29 mm is retracted +before wiping. This split applies to regular deferred wipes, including fallback; +an accepted inward wipe executes separately without retraction. + +## Implementation and verification + +- [GCode.cpp](../../src/libslic3r/GCode.cpp) integrates path selection, nozzle + position and retraction; [Print.cpp](../../src/libslic3r/Print.cpp) controls + invalidation, and [PrintConfig.cpp](../../src/libslic3r/PrintConfig.cpp) defines + the settings. +- [WipePathHelpers](../../src/libslic3r/GCode/WipePathHelpers.hpp) implements path + sampling, offset selection and support checks. +- [Geometry tests](../../tests/libslic3r/test_wipe_path.cpp) cover support, + degenerate paths, contour and hole orientations, and exact loop-move geometry + across path subdivisions. +- [FFF tests](../../tests/fff_print/test_wipe.cpp) cover emitted trajectories, + fallback, minimum-travel retraction and Z-hop rules, and export invalidation. + With Wipe inward disabled, they check the loop move's direction and magnitude + for Classic and Arachne, the subsequent wipe's start and length, and fractional + retraction splitting in absolute and relative E modes. + Loop-move checks use reserved role/wipe markers and extrusion state, and run + with human-readable G-code comments both enabled and disabled. diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index ffc6b5cee6..202c317e7f 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -260,6 +260,8 @@ set(lisbslic3r_sources GCode/SmallAreaInfillFlowCompensator.hpp GCode/SpiralVase.cpp GCode/SpiralVase.hpp + GCode/WipePathHelpers.cpp + GCode/WipePathHelpers.hpp GCode/ThumbnailData.cpp GCode/ThumbnailData.hpp GCode/Thumbnails.cpp diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 12a3a73e7c..501b5d0264 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -1,5 +1,6 @@ #include "BoundingBox.hpp" #include "Config.hpp" +#include "GCode/WipePathHelpers.hpp" #include "GCodeWriter.hpp" #include "Polygon.hpp" #include "PrintConfig.hpp" @@ -438,7 +439,6 @@ static std::vector get_path_of_change_filament(const Print& print) auto& writer = gcodegen.writer(); auto& config = gcodegen.config(); auto extruder = writer.filament(); - auto extruder_id = extruder->extruder_id(); auto last_pos = gcodegen.last_pos(); // Declare & initialize retraction lengths @@ -475,13 +475,13 @@ static std::vector get_path_of_change_filament(const Print& print) wipe_speed = std::max(wipe_speed, 10.0); // Process wipe path & calculate wipe path length - double wipe_dist = scale_(config.wipe_distance.get_at(extruder_id)); + double wipe_dist = scale_(config.wipe_distance.get_at(extruder->config_index())); Polyline wipe_path = {last_pos}; wipe_path.append(this->path.points.begin() + 1, this->path.points.end()); double wipe_path_length = std::min(wipe_path.length(), wipe_dist); // Calculate the maximum retraction amount during wipe - retraction_length_during_wipe = config.retraction_speed.get_at(extruder_id) * + retraction_length_during_wipe = config.retraction_speed.get_at(extruder->config_index()) * unscale_(wipe_path_length) / wipe_speed; // If the maximum retraction amount during wipe is too small, @@ -564,6 +564,16 @@ static std::vector get_path_of_change_filament(const Print& print) return default_value; } + // Orca: rebuild the stored wipe path while preserving Polyline's boundary deduplication. + void Wipe::update_path(const ExtrusionPaths &paths, bool reverse) + { + reset_path(); + for (const ExtrusionPath& extrusion_path : paths) + path.append(extrusion_path.polyline.to_polyline()); + if (reverse) + path.reverse(); + } + std::string Wipe::wipe(GCode& gcodegen,double length, bool toolchange, bool is_last) { std::string gcode; @@ -616,14 +626,11 @@ static std::vector get_path_of_change_filament(const Print& print) if (gcodegen.enable_cooling_markers() && !is_last) cooling_mark = /*gcodegen.config().role_based_wipe_speed ? ";_EXTERNAL_PERIMETER" : */";_WIPE"; + // Orca: set speed once because wipe_speed is constant for all segments. gcode += gcodegen.writer().set_speed(_wipe_speed * 60, "", cooling_mark); for (const Line& line : wipe_path.lines()) { double segment_length = line.length(); double dE = length * (segment_length / wipe_dist); - //BBS: fix this FIXME - //FIXME one shall not generate the unnecessary G1 Fxxx commands, here wipe_speed is a constant inside this cycle. - // Is it here for the cooling markers? Or should it be outside of the cycle? - //gcode += gcodegen.writer().set_speed(wipe_speed * 60, "", gcodegen.enable_cooling_markers() ? ";_WIPE" : ""); gcode += gcodegen.writer().extrude_to_xy( gcodegen.point_to_gcode(line.b), -dE, @@ -2901,6 +2908,19 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato const bool skip_config_block = print.config().gcode_skip_config_block; const WipeTowerType wipe_tower_type = print.wipe_tower_type(); m_calib_config.clear(); + // Orca: Calibration overrides are reapplied after object/region settings in _extrude(). + // Keep inward wiping from masking retraction and pressure advance artifacts. + switch (print.calib_mode()) { + case CalibMode::Calib_PA_Line: + case CalibMode::Calib_PA_Pattern: + case CalibMode::Calib_PA_Tower: + case CalibMode::Calib_Auto_PA_Line: + case CalibMode::Calib_Retraction_tower: + m_calib_config.set_key_value("wipe_inward", new ConfigOptionBool(false)); + break; + default: + break; + } // resets analyzer's tracking data m_last_height = 0.f; m_last_layer_z = 0.f; @@ -7204,7 +7224,8 @@ std::string GCode::extrude_loop(const ExtrusionLoop& loop_ref, const std::string& description, double speed, const ExtrusionEntitiesPtr& region_perimeters, - const Point* start_point) + const Point* start_point, + const WipeInwardSupport* wipe_support) { // get a copy; don't modify the orientation of the original loop object otherwise // next copies (if any) would not detect the correct orientation @@ -7434,63 +7455,80 @@ std::string GCode::extrude_loop(const ExtrusionLoop& loop_ref, m_processor.result().print_statistics.total_seam_scarf_distance += static_cast(seam_scarf_distance_mm); } - // BBS + // Orca: share the post-extrusion nozzle position between wipe_inward and wipe_on_loops. + const bool is_ccw = loop.is_counter_clockwise(); + + std::optional wipe_on_loops_dest; + if (m_config.wipe_on_loops.value && paths.back().role() == erExternalPerimeter && + m_layer != nullptr && m_config.wall_loops.value > 1 && paths.front().size() >= 2 && + paths.back().polyline.points.size() >= 2) + wipe_on_loops_dest = wipe_on_loops_destination(paths, scale_(nozzle_diameter), is_ccw, is_hole); + + bool wipe_inward_applied = false; + // Orca: store loop paths in print order because inward offsets use this orientation. if (m_wipe.enable && FILAMENT_CONFIG(wipe)) { - m_wipe.path = Polyline(); - for (ExtrusionPath &path : paths) { - //BBS: Don't need to save duplicated point into wipe path - if (!m_wipe.path.empty() && !path.empty() && - m_wipe.path.last_point() == Point(path.first_point().x(), path.first_point().y())) { - // Convert Points3 to Points - for (auto it = path.polyline.points.begin() + 1; it != path.polyline.points.end(); ++it) - m_wipe.path.append(Point(it->x(), it->y())); - } else - m_wipe.path.append(path.polyline.to_polyline()); // TODO: don't limit wipe to last path + m_wipe.update_path(paths); + + // Orca: loop wipe paths retain print direction. Their material side is + // therefore left for CCW contours and right for CW contours, with the + // result inverted for holes. Only external perimeters are eligible. + // Calibration overrides are applied during extrusion, after the region + // context was created. Check the effective setting again at execution. + if (m_config.wipe_inward && m_config.wipe_inward_distance.value > 0. && + wipe_support != nullptr && !wipe_support->inner_lines.empty() && + // A loop's role is its first path's role. An overhanging start must + // not hide ordinary external-wall segments elsewhere in the loop. + std::any_of(paths.begin(), paths.end(), + [](const ExtrusionPath &path) { return is_external_perimeter(path.role()); }) && + m_wipe.path.points.size() >= 2) { + // Orca: use the actual extrusion width from the path, not the config + // value — outer_wall_line_width=0 (Auto) would make get_abs_value + // return 0 and silently disable the feature, and Arachne may produce + // a different width than the config default. + const double outer_wall_line_width = paths.front().width; + const double requested_offset = m_config.wipe_inward_distance.get_abs_value(outer_wall_line_width); + const double offset_dist = scale_(std::min(requested_offset, outer_wall_line_width)); + if (offset_dist > SCALED_EPSILON) { + const Point seam_start = paths.front().first_point(); + const Point seam_end = paths.back().last_point(); + const Point wipe_start = wipe_on_loops_dest.value_or(seam_end); + const double max_wipe_length = scale_(FILAMENT_CONFIG(wipe_distance)); + // Orca: Wipe::wipe() replaces points[0] with last_pos and executes + // from points[1]. The helper preserves that sentinel and atomically + // replaces the remaining points, or leaves the path untouched. + // Orca: a configured wall count does not guarantee that Arachne + // generated an adjacent wall for this particular loop. Only + // earlier entities are considered because later walls have + // not been printed yet (for example with Outer/Inner order). + // Inner walls determine the material side; every earlier wall + // remains available to validate the executable wipe path. + const double support_distance = scale_(std::max(nozzle_diameter, outer_wall_line_width)); + Polyline inward_path = m_wipe.path; + if (offset_wipe_path_toward_support( + inward_path, seam_start, seam_end, wipe_start, + wipe_offset_direction(is_ccw, is_hole), offset_dist, max_wipe_length, + wipe_support->inner_lines, wipe_support->printed_lines, + m_wipe.path.lines(), support_distance)) { + m_wipe.path = std::move(inward_path); + wipe_inward_applied = true; + } + } } } - // make a little move inwards before leaving loop - if (m_config.wipe_on_loops.value && paths.back().role() == erExternalPerimeter && m_layer != NULL && m_config.wall_loops.value > 1 && paths.front().size() >= 2 && paths.back().polyline.points.size() >= 3) { - // detect angle between last and first segment - // the side depends on the original winding order of the polygon (inwards for contours, outwards for holes) - //FIXME improve the algorithm in case the loop is tiny. - //FIXME improve the algorithm in case the loop is split into segments with a low number of points (see the Point b query). - const Point3 &a3 = paths.front().polyline.points[1]; // second point - Point a = Point(a3.x(), a3.y()); - const Point3 &b3 = *(paths.back().polyline.points.end()-3); // second to last point - Point b = Point(b3.x(), b3.y()); - if (is_hole == loop.is_counter_clockwise()) { - // swap points - Point c = a; a = b; b = c; - } - - double angle = paths.front().first_point().ccw_angle(a, b) / 3; - - // turn inwards if contour, turn outwards if hole - if (is_hole == loop.is_counter_clockwise()) angle *= -1; - - // create the destination point along the first segment and rotate it - // we make sure we don't exceed the segment length because we don't know - // the rotation of the second segment so we might cross the object boundary - Vec2d p1 = paths.front().polyline.points.front().cast().head<2>(); - Vec2d p2 = paths.front().polyline.points[1].cast().head<2>(); - Vec2d v = p2 - p1; - double nd = scale_(EXTRUDER_CONFIG(nozzle_diameter)); - double l2 = v.squaredNorm(); - // Shift by no more than a nozzle diameter. - //FIXME Hiding the seams will not work nicely for very densely discretized contours! - //BBS. shorten the travel distant before the wipe path - double threshold = 0.2; - Point pt = (p1 + v * threshold).cast(); - if (nd * nd < l2) - pt = (p1 + threshold * v * (nd / sqrt(l2))).cast(); - //Point pt = ((nd * nd >= l2) ? (p1+v*0.4): (p1 + 0.2 * v * (nd / sqrt(l2)))).cast(); - const Point3 ¢er3 = paths.front().polyline.points.front(); - pt.rotate(angle, Point(center3.x(), center3.y())); - // generate the travel move - gcode += m_writer.extrude_to_xy(this->point_to_gcode(pt), 0, "move inwards before travel", true); + // Orca: make the configured inward move before leaving the loop. + if (wipe_on_loops_dest) { + gcode += m_writer.extrude_to_xy( + this->point_to_gcode(*wipe_on_loops_dest), 0, "move inwards before travel", true); + this->set_last_pos(*wipe_on_loops_dest); } + // Execute the accepted path before another extrusion replaces it. Wiping + // must not force retraction or Z-hop across a short travel to the next wall. + // Ordinary travel planning decides whether to retract from the new position. + if (wipe_inward_applied) + gcode += m_wipe.wipe(*this, 0.); + return gcode; } @@ -7524,21 +7562,9 @@ std::string GCode::extrude_multi_path(const ExtrusionMultiPath& multipath, const m_multi_flow_segment_path_pa_set = true; } - // BBS - if (m_wipe.enable && FILAMENT_CONFIG(wipe)) { - m_wipe.path = Polyline(); - for (const ExtrusionPath &path : multipath.paths) { - //BBS: Don't need to save duplicated point into wipe path - if (!m_wipe.path.empty() && !path.empty() && - m_wipe.path.last_point() == Point(path.first_point().x(), path.first_point().y())) { - // Convert Points3 to Points - for (auto it = path.polyline.points.begin() + 1; it != path.polyline.points.end(); ++it) - m_wipe.path.append(Point(it->x(), it->y())); - } else - m_wipe.path.append(path.polyline.to_polyline()); // TODO: don't limit wipe to last path - } - m_wipe.path.reverse(); - } + // Orca: multipath wipes retrace the extrusion in reverse order. + if (m_wipe.enable && FILAMENT_CONFIG(wipe)) + m_wipe.update_path(multipath.paths, true); return gcode; } @@ -7546,14 +7572,15 @@ std::string GCode::extrude_multi_path(const ExtrusionMultiPath& multipath, const std::string GCode::extrude_entity(const ExtrusionEntity& entity, const std::string& description, double speed, - const ExtrusionEntitiesPtr& region_perimeters) + const ExtrusionEntitiesPtr& region_perimeters, + const WipeInwardSupport* wipe_support) { if (const ExtrusionPath* path = dynamic_cast(&entity)) return this->extrude_path(*path, description, speed); else if (const ExtrusionMultiPath* multipath = dynamic_cast(&entity)) return this->extrude_multi_path(*multipath, description, speed); else if (const ExtrusionLoop* loop = dynamic_cast(&entity)) - return this->extrude_loop(*loop, description, speed, region_perimeters); + return this->extrude_loop(*loop, description, speed, region_perimeters, nullptr, wipe_support); else throw Slic3r::InvalidArgument("Invalid argument supplied to extrude()"); return ""; @@ -7567,6 +7594,7 @@ std::string GCode::extrude_path(const ExtrusionPath& path, const std::string& de // description += ExtrusionEntity::role_to_string(path.role()); std::string gcode = this->_extrude(path, description, speed); if (m_wipe.enable && FILAMENT_CONFIG(wipe)) { + m_wipe.reset_path(); m_wipe.path = path.polyline.to_polyline(); if (is_tree(this->config().support_type) && is_support(path.role())) { if ((m_wipe.path.first_point() - m_wipe.path.last_point()).cast().norm() > scale_(0.2)) { @@ -7599,8 +7627,19 @@ std::string GCode::extrude_perimeters(const Print &print, const std::vectorextrude_entity(*ee, "perimeter", -1., region.perimeters); + // Build the printed prefix once in emission order, scoped to this + // region. Disabled or zero-length wipes need no support geometry. + std::optional wipe_support; + if (m_wipe.enable && FILAMENT_CONFIG(wipe) && m_config.wipe_inward && + m_config.wipe_inward_distance.value > 0. && + scale_(FILAMENT_CONFIG(wipe_distance)) > SCALED_EPSILON) + wipe_support.emplace(); + for (const ExtrusionEntity* ee : region.perimeters) { + gcode += this->extrude_entity(*ee, "perimeter", -1., region.perimeters, + wipe_support ? &*wipe_support : nullptr); + if (wipe_support) + wipe_support->append(*ee); + } } return gcode; } @@ -7841,7 +7880,8 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, // path is 2D. But in slope lift case, lift z is done in travel_to function. // Add m_need_change_layer_lift_z when change_layer in case of no lift if m_last_pos is equal to path.first_point() by chance Point first_point = path.first_point(); - if (!m_last_pos_defined || m_last_pos.to_point() != first_point || m_need_change_layer_lift_z || slope_need_z_travel) { + if (!m_last_pos_defined || m_last_pos.to_point() != first_point || m_need_change_layer_lift_z || + slope_need_z_travel) { const bool _last_pos_undefined = !m_last_pos_defined; double z = DBL_MAX; diff --git a/src/libslic3r/GCode.hpp b/src/libslic3r/GCode.hpp index 990bf0fee7..29e4638a94 100644 --- a/src/libslic3r/GCode.hpp +++ b/src/libslic3r/GCode.hpp @@ -39,6 +39,7 @@ namespace Slic3r { // Forward declarations. class GCode; +struct WipeInwardSupport; namespace CustomGCode{ struct Item; } struct PrintInstance; @@ -61,7 +62,7 @@ public: bool enable; Polyline path; - // Orca: + // Orca: retraction portions emitted before, during, and after the wipe move. struct RetractionValues{ double retraction_length_before_wipe = 0.; double retraction_length_during_wipe = 0.; @@ -73,8 +74,10 @@ public: void reset_path() { this->path = Polyline(); } std::string wipe(GCode &gcodegen, double length, bool toolchange = false, bool is_last = false); - // Orca: + // Orca: calculate the retraction portions that can be emitted at wipe speed. RetractionValues calculateWipeRetractionLengths(GCode& gcodegen, bool toolchange); + // Orca: rebuild the stored path while deduplicating shared path boundaries. + void update_path(const ExtrusionPaths &paths, bool reverse = false); }; class WipeTowerIntegration { @@ -430,14 +433,16 @@ private: std::string extrude_entity(const ExtrusionEntity& entity, const std::string& description = "", double speed = -1., - const ExtrusionEntitiesPtr& region_perimeters = ExtrusionEntitiesPtr()); + const ExtrusionEntitiesPtr& region_perimeters = ExtrusionEntitiesPtr(), + const WipeInwardSupport* wipe_support = nullptr); // Orca: pass the complete collection of region perimeters to the extrude loop to check whether the wipe before external loop // should be executed std::string extrude_loop(const ExtrusionLoop& loop, const std::string& description, double speed = -1., const ExtrusionEntitiesPtr& region_perimeters = ExtrusionEntitiesPtr(), - const Point* start_point = nullptr); + const Point* start_point = nullptr, + const WipeInwardSupport* wipe_support = nullptr); std::string extrude_multi_path(const ExtrusionMultiPath& multipath, const std::string& description = "", double speed = -1.); std::string extrude_path(const ExtrusionPath& path, const std::string& description = "", double speed = -1.); diff --git a/src/libslic3r/GCode/WipePathHelpers.cpp b/src/libslic3r/GCode/WipePathHelpers.cpp new file mode 100644 index 0000000000..d785a16a8c --- /dev/null +++ b/src/libslic3r/GCode/WipePathHelpers.cpp @@ -0,0 +1,920 @@ +#include "WipePathHelpers.hpp" + +#include "../AABBTreeLines.hpp" + +#include +#include +#include +#include +#include + +namespace Slic3r { + +void WipeInwardSupport::append(const ExtrusionEntity &entity) +{ + const ExtrusionPaths *paths = nullptr; + if (const auto *loop = dynamic_cast(&entity)) + paths = &loop->paths; + else if (const auto *multipath = dynamic_cast(&entity)) + paths = &multipath->paths; + + // A loop's role is its first path's role. An overhanging start must not + // hide the ordinary inner-wall segments elsewhere in the same loop. + const bool is_inner = paths ? std::any_of(paths->begin(), paths->end(), + [](const ExtrusionPath &path) { return is_internal_perimeter(path.role()); }) : + is_internal_perimeter(entity.role()); + const Lines lines = entity.as_polyline().lines(); + printed_lines.insert(printed_lines.end(), lines.begin(), lines.end()); + if (is_inner) + inner_lines.insert(inner_lines.end(), lines.begin(), lines.end()); +} + +// Orca: miter limit ratio. Matches DefaultMiterLimit from ClipperUtils.hpp. +// When the miter join extends more than miter_limit * offset_dist from the +// original vertex, the miter is replaced by a bevel join. +static constexpr double miter_limit = 3.0; + +// Orca: threshold for detecting near-reversal (backtracking spike). +// Normalized dot product below this means the segments point in nearly +// opposite directions (angle > ~172°). Offsetting such a path is unsafe. +static constexpr double reversal_dot_threshold = -0.99; + +// Orca: candidates pointing more than 60 degrees away from the selected inner +// wall are too tangent to distinguish the material side reliably at a cusp. +static constexpr double min_support_alignment = 0.5; + +// Keep a scaled-coordinate rounding floor while allowing the tolerance to +// follow the relevant offset or path length. Clearance allows a larger fraction. +static double wipe_tolerance(double distance, double relative_tolerance = 0.1) +{ + return std::max(4. * SCALED_EPSILON, relative_tolerance * distance); +} + +Point sample_path_at_distance(const ExtrusionPaths &paths, bool forward, double target) +{ + assert(!paths.empty()); + if (paths.empty()) + return Point(0, 0); + + double remaining = target; + Point result = forward ? paths.front().first_point() : paths.back().last_point(); + for (int pi = forward ? 0 : (int)paths.size() - 1; + pi >= 0 && pi < (int)paths.size() && remaining > 0.; + pi += forward ? 1 : -1) { + const Points3 &pts = paths[pi].polyline.points; + for (int i = forward ? 0 : (int)pts.size() - 1; + remaining > 0. && (forward ? i + 1 < (int)pts.size() : i > 0); + i += forward ? 1 : -1) { + const int j = forward ? i + 1 : i - 1; + const Point cur(pts[i].x(), pts[i].y()); + const Point next(pts[j].x(), pts[j].y()); + const double segment_length = (next - cur).cast().norm(); + if (segment_length < SCALED_EPSILON) + continue; + if (remaining <= segment_length) { + const double ratio = remaining / segment_length; + return Point(coord_t(cur.x() + ratio * (next.x() - cur.x())), + coord_t(cur.y() + ratio * (next.y() - cur.y()))); + } + remaining -= segment_length; + result = next; + } + } + return result; +} + +// Orca: consecutive duplicates carry no path length and can be removed safely. +// A reversal, however, is real travelled distance: removing its vertex would +// replace a long backtracking wipe with a short, unrelated shortcut. +static bool prepare_source(Points &pts) +{ + pts.erase(std::unique(pts.begin(), pts.end()), pts.end()); + + if (pts.size() < 2) + return false; + + for (size_t i = 1; i + 1 < pts.size(); ++i) { + const Vec2d v_prev = (pts[i] - pts[i - 1]).cast(); + const Vec2d v_next = (pts[i + 1] - pts[i]).cast(); + const double dot = v_prev.dot(v_next) / (v_prev.norm() * v_next.norm()); + if (dot < reversal_dot_threshold) + return false; + } + return true; +} + +static bool build_offset_polyline(const Points &original, int dir, double offset_dist, + Points &result, size_t &first_join_index) +{ + if (original.size() < 2) + return false; + + // Orca: collapse all consecutive duplicates first, then reject any + // backtracking in the cleaned path instead of replacing travelled distance + // with a shortcut. + Points source = original; + if (! prepare_source(source)) + return false; + + const size_t n = source.size(); + + // Orca: compute the perpendicular offset for segment i->i+1 as an infinite Line. + auto offset_segment = [dir, offset_dist](const Point &a, const Point &b) -> Line { + Vec2d v = (b - a).cast(); + double len = v.norm(); + Vec2d perp(0, 0); + if (len > SCALED_EPSILON) + perp = Vec2d(-v.y(), v.x()) * (dir * offset_dist / len); + return Line(Point(coord_t(a.x() + perp.x()), coord_t(a.y() + perp.y())), + Point(coord_t(b.x() + perp.x()), coord_t(b.y() + perp.y()))); + }; + + result.clear(); + result.reserve(n); + first_join_index = 0; + + // Orca: the first point is perpendicular to the first segment. + Line l_prev = offset_segment(source[0], source[1]); + result.push_back(l_prev.a); + + // Orca: use the analytic intersection of adjacent offset segments for a + // miter join. Intersecting the already rounded Line endpoints amplifies + // coordinate quantization when the source segments are nearly parallel. + for (size_t i = 1; i + 1 < n; ++i) { + Line l_next = offset_segment(source[i], source[i + 1]); + const Vec2d previous = (source[i] - source[i - 1]).cast().normalized(); + const Vec2d next = (source[i + 1] - source[i]).cast().normalized(); + const double denominator = 1. + previous.dot(next); + + bool need_bevel = denominator <= EPSILON; + Point pt; + if (! need_bevel) { + const Vec2d previous_normal(-previous.y(), previous.x()); + const Vec2d next_normal(-next.y(), next.x()); + const Vec2d miter = (previous_normal + next_normal) * (dir * offset_dist / denominator); + if (miter.norm() > miter_limit * offset_dist) { + need_bevel = true; + } else { + pt = Point(coord_t(source[i].x() + miter.x()), + coord_t(source[i].y() + miter.y())); + } + } + + if (need_bevel) { + result.push_back(l_prev.b); + if (l_next.a != result.back()) + result.push_back(l_next.a); + } else { + result.push_back(pt); + } + if (i == 1) + first_join_index = result.size() - 1; + l_prev = l_next; + } + + // Orca: the last point is perpendicular to the last segment. + result.push_back(l_prev.b); + + return true; +} + +int wipe_offset_direction(bool is_ccw, bool is_hole) +{ + const int loop_inside = is_ccw ? +1 : -1; + return is_hole ? -loop_inside : loop_inside; +} + +static bool starts_by_backtracking(const Polyline &path, Point actual_start) +{ + if (path.points.size() < 3) + return false; + // Orca: points[0] is only a storage sentinel; use the nozzle position for + // the executable connector, particularly after a wipe_on_loops pre-move. + const Vec2d connector = (path.points[1] - actual_start).cast(); + const Vec2d outgoing = (path.points[2] - path.points[1]).cast(); + // An inward connector may be perpendicular to the outgoing offset edge. + // Rounded joins must not turn that right angle into a false backtrack. + return connector.dot(outgoing) < -4. * SCALED_EPSILON * outgoing.norm(); +} + +// Orca: sample the outgoing perimeter without copying or clipping its full loop. +static Point sample_polyline_at_distance(const Polyline &polyline, double target) +{ + assert(! polyline.points.empty()); + Point result = polyline.first_point(); + for (size_t i = 1; i < polyline.points.size() && target > 0.; ++i) { + const Vec2d segment = (polyline.points[i] - result).cast(); + const double length = segment.norm(); + if (length <= SCALED_EPSILON) + continue; + if (target <= length) + return (result.cast() + segment * (target / length)).cast(); + target -= length; + result = polyline.points[i]; + } + return result; +} + +// Orca: convert an executable path into Wipe::wipe()'s stored representation. +// The first point is a dummy replaced by the actual nozzle position, while the +// remaining points are clipped to the configured wipe distance. +static bool store_wipe_path(Polyline &destination, Point seam_start, + Polyline actual_path, double max_wipe_length) +{ + if (actual_path.points.size() < 2 || max_wipe_length <= SCALED_EPSILON) + return false; + + const double actual_length = actual_path.length(); + if (actual_length <= SCALED_EPSILON) + return false; + if (actual_length - max_wipe_length > SCALED_EPSILON) + actual_path.clip_end(actual_length - max_wipe_length); + if (actual_path.points.size() < 2) + return false; + for (size_t i = 1; i < actual_path.points.size(); ++i) + if (actual_path.points[i - 1] == actual_path.points[i]) + return false; + + Polyline stored_path; + stored_path.points.reserve(actual_path.points.size()); + stored_path.points.push_back(seam_start); + stored_path.points.insert(stored_path.points.end(), actual_path.points.begin() + 1, actual_path.points.end()); + stored_path.reset_to_linear_move(); + destination = std::move(stored_path); + return true; +} + +bool offset_wipe_path(Polyline &polyline, Point seam_start, Point seam_end, Point wipe_start, + int dir, double offset_dist, double max_wipe_length) +{ + assert(dir == +1 || dir == -1); + assert(offset_dist > 0); + if (polyline.points.empty() || polyline.first_point() != seam_start || + max_wipe_length <= SCALED_EPSILON) + return false; + + const Polyline original = polyline; + const double original_length = original.length(); + if (original_length <= SCALED_EPSILON) + return false; + + double source_length = std::min(original_length, max_wipe_length); + for (;;) { + Polyline source = original; + const double clip_distance = original_length - source_length; + if (clip_distance > SCALED_EPSILON) + source.clip_end(clip_distance); + + Points wrapped_source; + wrapped_source.reserve(source.points.size() + 1); + if (seam_start == seam_end) { + // Orca: the stored loop is open at seam_start even when the seam gap is + // zero. Prepend the closing edge so build_offset_polyline() creates + // the proper join between that edge and the first outgoing edge, + // instead of leaving the first offset point on the closing wall. + size_t closing_index = original.points.size(); + while (closing_index > 0 && original.points[closing_index - 1] == seam_start) + --closing_index; + if (closing_index == 0) + return false; // Orca: the entire path is a single point. + wrapped_source.push_back(original.points[closing_index - 1]); + } else { + // Orca: use the unextruded seam-gap edge to determine the incoming + // direction at the seam. Its offset is construction geometry only; + // wiping along it would create a Z-shaped detour before the outgoing + // perimeter offset. + wrapped_source.push_back(seam_end); + } + wrapped_source.insert(wrapped_source.end(), source.points.begin(), source.points.end()); + + Points offset_points; + size_t first_join_index = 0; + if (! build_offset_polyline(wrapped_source, dir, offset_dist, offset_points, first_join_index) || + first_join_index == 0 || first_join_index >= offset_points.size()) + return false; + // Orca: discard the offset of the prepended edge and, for a bevel, its + // incoming endpoint. The executable wipe starts at the seam join and + // then follows only the already printed outgoing perimeter. + offset_points.erase(offset_points.begin(), offset_points.begin() + first_join_index); + + Polyline actual_path; + actual_path.points.reserve(offset_points.size() + 1); + actual_path.points.push_back(wipe_start); + actual_path.points.insert(actual_path.points.end(), offset_points.begin(), offset_points.end()); + + // A loop pre-move may advance past an otherwise valid offset join. + // Enter at the nozzle's projection instead of returning to the join. + // Do not repair a join that already backtracks across the seam gap; + // the caller must still validate wall crossings, material side and support. + if (seam_start != seam_end && wipe_start != seam_start && wipe_start != seam_end && + starts_by_backtracking(actual_path, wipe_start) && ! starts_by_backtracking(actual_path, seam_end)) { + size_t entry = 1; + while (entry + 1 < actual_path.points.size()) { + const Vec2d edge = (actual_path.points[entry + 1] - actual_path.points[entry]).cast(); + const double projection = (wipe_start - actual_path.points[entry]).cast().dot(edge); + if (projection <= 0.) + break; + if (projection < edge.squaredNorm()) { + actual_path.points[entry] = (actual_path.points[entry].cast() + + edge * (projection / edge.squaredNorm())).cast(); + break; + } + ++entry; + } + actual_path.points.erase(actual_path.points.begin() + 1, actual_path.points.begin() + entry); + } + + if (seam_start != seam_end && wipe_start == seam_end && + starts_by_backtracking(actual_path, wipe_start)) { + // Orca: a wide seam gap or a sharp cusp may put the first miter + // behind its outgoing edge. Reject this offset candidate so the + // caller can try the opposite side or the translated fallback. + return false; + } + + const double actual_length = actual_path.length(); + const bool source_exhausted = original_length - source_length <= SCALED_EPSILON; + if (actual_length + SCALED_EPSILON < max_wipe_length && ! source_exhausted) { + // Orca: offset joins may shorten the path at every corner. Grow the + // source until the executable offset path, not a heuristic source + // margin, reaches the configured wipe distance. + const double deficit = max_wipe_length - actual_length; + const double next_length = std::min(original_length, + source_length + std::max(deficit, 2. * SCALED_EPSILON)); + if (next_length - source_length <= SCALED_EPSILON) + return false; + source_length = next_length; + continue; + } + + // Orca: unlike an extruded offset, a wipe may safely cross or retrace the + // just-printed perimeter. The caller validates the complete executable + // path against current and earlier printed perimeter geometry. + return store_wipe_path(polyline, seam_start, std::move(actual_path), max_wipe_length); + } +} + +static bool translated_wipe_path(Polyline &polyline, Point seam_start, Point seam_end, Point wipe_start, + const Vec2d &translation, double max_wipe_length) +{ + if (translation.norm() <= SCALED_EPSILON || max_wipe_length <= SCALED_EPSILON) + return false; + + const Polyline original = polyline; + Polyline actual_path; + actual_path.points.reserve(original.points.size() + 2); + actual_path.points.push_back(wipe_start); + + const auto append_translated = [&actual_path, &translation](const Point &point) { + const Point translated = (point.cast() + translation).cast(); + if (translated != actual_path.points.back()) + actual_path.points.push_back(translated); + }; + + // Orca: translate the seam join directly. Translating seam_end and then + // following the unextruded gap back to seam_start makes the wipe double + // back whenever a gap ends near a sharp corner. + append_translated(seam_start); + for (const Point &point : original.points) + append_translated(point); + + if (seam_start != seam_end && wipe_start == seam_end && + starts_by_backtracking(actual_path, wipe_start)) { + // Orca: at a wide gap next to a cusp, the translated seam join may + // lie behind the outgoing edge. Prefer a shorter local inward move + // at the actual extrusion end over a longer lightning-shaped wipe. + actual_path.points.resize(1); + append_translated(seam_end); + } + + return store_wipe_path(polyline, seam_start, std::move(actual_path), max_wipe_length); +} + +// A segment whose endpoints lie within one line's distance capsule is fully +// supported, since that capsule is convex. Subdivide only when support changes +// between lines; fixed-distance sampling can miss an unsupported gap. +static bool segment_is_supported(Point start, Point end, + const AABBTreeLines::LinesDistancer &distancer, + double max_distance) +{ + const Point midpoint = ((start.cast() + end.cast()) * 0.5).cast(); + const auto [distance, line_index, nearest] = distancer.distance_from_lines_extra(midpoint); + if (distance > max_distance) + return false; + + const Line &line = distancer.get_line(line_index); + if (line.distance_to(start) <= max_distance && line.distance_to(end) <= max_distance) + return true; + if (distancer.distance_from_lines(start) > max_distance || + distancer.distance_from_lines(end) > max_distance) + return false; + + // Conservatively reject an unresolved transition at coordinate precision. + if ((end - start).cast().norm() <= SCALED_EPSILON) + return false; + return segment_is_supported(start, midpoint, distancer, max_distance) && + segment_is_supported(midpoint, end, distancer, max_distance); +} + +std::optional wipe_path_support_score( + const Polyline &polyline, Point wipe_start, + const AABBTreeLines::LinesDistancer &target_distancer, + const AABBTreeLines::LinesDistancer &all_support_distancer, + double max_distance) +{ + if (polyline.points.size() < 2 || target_distancer.get_lines().empty() || max_distance <= 0) + return std::nullopt; + + // Orca: require a local neighbour, not merely an earlier perimeter elsewhere in + // the region. At a convex corner, an inner wall's miter is farther from the + // external seam than its normal wall spacing, so allow the same bounded miter + // reach as the offset construction without accepting a remote island. + if (target_distancer.distance_from_lines(wipe_start) > + miter_limit * max_distance + 4. * SCALED_EPSILON) + return std::nullopt; + + Point previous = wipe_start; + for (size_t i = 1; i < polyline.points.size(); ++i) { + // Orca: a tightly curved inward path may cross back over the current wall. + // This is safe for a non-extruding wipe as long as the complete path + // remains over current or earlier printed perimeter geometry. + // Allow the same coordinate-rounding tolerance at every point, including + // the actual start substituted for the stored sentinel. + if (! segment_is_supported(previous, polyline.points[i], all_support_distancer, + max_distance + 4. * SCALED_EPSILON)) + return std::nullopt; + previous = polyline.points[i]; + } + + // Orca: decide direction at the seam. Scoring the complete path may select + // the wrong initial side when two contours converge and the later prefix + // happens to run closer to unrelated support. + return target_distancer.distance_from_lines(polyline.points[1]); +} + +static bool initial_connector_is_clear( + const Polyline &polyline, Point wipe_start, Point seam_start, + AABBTreeLines::LinesDistancer ¤t_perimeter_distancer, + double contact_tolerance) +{ + if (polyline.points.size() < 2 || polyline.points[1] == wipe_start) + return false; + + // Orca: without a seam gap, the connector necessarily starts at the wall + // and a self-touching cusp may share that same endpoint on several edges. + if (seam_start == wipe_start) + return true; + + const Line connector(wipe_start, polyline.points[1]); + const auto intersections = current_perimeter_distancer.intersections_with_line(connector); + for (const auto &intersection : intersections) { + if ((intersection.first - wipe_start).cast().norm() > contact_tolerance) + return false; + } + + Point closest; + // Orca: integer offset joins may miss the exact seam-start coordinate by + // a few microns. Treat a close pass through that point as retracing the + // external wall, but keep the unavoidable contact at the actual start. + if (connector.distance_to_squared(seam_start, &closest) <= contact_tolerance * contact_tolerance && + (closest - wipe_start).cast().norm() > contact_tolerance) + return false; + + return true; +} + +static std::optional support_offset_at_start( + const Polyline &source, Point local_origin, bool disambiguate_branch, + AABBTreeLines::LinesDistancer &support_distancer, + double max_support_distance) +{ + if (source.points.size() < 2) + return std::nullopt; + + // Orca: a nonzero gap may put the seam beside the wrong branch of a cusp. + // Sample farther along the path to identify its actual neighbouring wall. + const Point support_query = disambiguate_branch ? + sample_polyline_at_distance(source, 2. * max_support_distance) : source.first_point(); + const auto nearest_result = support_distancer.distance_from_lines_extra(support_query); + const Line &nearest_line = support_distancer.get_line(std::get<1>(nearest_result)); + Vec2d sampled_offset = std::get<2>(nearest_result) - support_query.cast(); + + if (disambiguate_branch) { + // Orca: an endpoint projection also contains distance along the support + // segment. Remove that tangent component before comparing wall sides. + const Vec2d support_edge = (nearest_line.b - nearest_line.a).cast(); + if (support_edge.norm() > SCALED_EPSILON) { + const Vec2d support_tangent = support_edge.normalized(); + sampled_offset -= support_tangent * sampled_offset.dot(support_tangent); + } + } + if (sampled_offset.norm() <= SCALED_EPSILON) + return std::nullopt; + + if (! disambiguate_branch) + return sampled_offset; + + // Orca: find the local point on the same material-side branch. Using the + // sampled point itself would add the distance already travelled along the + // perimeter and turn a normal transition into a long diagonal move. + const Vec2d sampled_direction = sampled_offset.normalized(); + Vec2d local_offset = sampled_offset; + double best_local_score = std::numeric_limits::infinity(); + for (size_t line_index : support_distancer.all_lines_in_radius( + local_origin, 2. * max_support_distance + 4. * SCALED_EPSILON)) { + Point local_support; + const Line &line = support_distancer.get_line(line_index); + const double distance_squared = line.distance_to_squared(local_origin, &local_support); + const Vec2d candidate_offset = local_support.cast() - local_origin.cast(); + const double candidate_distance = std::sqrt(distance_squared); + if (candidate_distance <= SCALED_EPSILON) + continue; + const double alignment = candidate_offset.normalized().dot(sampled_direction); + if (alignment < min_support_alignment) + continue; + const double score = candidate_distance / alignment; + if (score < best_local_score) { + best_local_score = score; + local_offset = candidate_offset; + } + } + return local_offset; +} + +static double executable_path_length(const Polyline &stored_path, Point wipe_start) +{ + if (stored_path.points.size() < 2) + return 0.; + + // Orca: points[0] is the storage sentinel, so measure the first segment + // from the actual nozzle position and the remaining stored segments normally. + double length = (stored_path.points[1] - wipe_start).cast().norm(); + for (size_t index = 2; index < stored_path.points.size(); ++index) + length += (stored_path.points[index] - stored_path.points[index - 1]).cast().norm(); + return length; +} + +static Lines material_side_support_lines(const Polyline &path, Point seam, int preferred_dir, + const Lines &support_lines) +{ + if (path.points.size() < 4 || path.first_point() != path.last_point()) + return {}; + + // Orca: the bisector of the incoming and outgoing material-side normals is + // a local side test that remains valid for globally self-touching Arachne + // contours. Ignore repeated seam points when obtaining both tangents. + const auto outgoing_it = std::find_if( + path.points.begin() + 1, path.points.end(), [seam](const Point &point) { return point != seam; }); + const auto incoming_it = std::find_if( + path.points.rbegin() + 1, path.points.rend(), [seam](const Point &point) { return point != seam; }); + if (outgoing_it == path.points.end() || incoming_it == path.points.rend()) + return {}; + + const Vec2d outgoing = (*outgoing_it - seam).cast().normalized(); + const Vec2d incoming = (seam - *incoming_it).cast().normalized(); + const Vec2d material_direction = + (Vec2d(-outgoing.y(), outgoing.x()) + Vec2d(-incoming.y(), incoming.x())) * preferred_dir; + if (material_direction.norm() <= EPSILON) + return {}; + + Lines result; + result.reserve(support_lines.size()); + for (const Line &line : support_lines) { + Point closest; + line.distance_to_squared(seam, &closest); + if ((closest - seam).cast().dot(material_direction) > SCALED_EPSILON) + result.push_back(line); + } + return result; +} + +bool wipe_path_stays_on_material_side( + const Polyline &path, Point path_start, const Vec2d &support_direction, + const AABBTreeLines::LinesDistancer &target_perimeter_distancer, + const AABBTreeLines::LinesDistancer ¤t_perimeter_distancer, + double effective_offset, bool require_clearance) +{ + if (path.points.size() < 2 || support_direction.norm() <= EPSILON || + target_perimeter_distancer.get_lines().empty() || current_perimeter_distancer.get_lines().empty() || + effective_offset <= SCALED_EPSILON) + return false; + + const Vec2d initial_offset = (path.points[1] - path_start).cast(); + if (initial_offset.norm() <= SCALED_EPSILON || + initial_offset.normalized().dot(support_direction.normalized()) < min_support_alignment) + return false; + // Orca: after the connector has left the extrusion endpoint, an inward + // offset must retain most of its requested clearance from the current + // external wall. Otherwise a tight turn may send an initially correct path + // back onto that wall, or make the opposite-side candidate look supported. + const double clearance_tolerance = wipe_tolerance(effective_offset, 0.25); + const double minimum_clearance = effective_offset - clearance_tolerance; + const Lines &lines = current_perimeter_distancer.get_lines(); + const auto left_normal = [](const Line &line) -> Vec2d { + const Vec2d edge = (line.b - line.a).cast(); + if (edge.norm() <= SCALED_EPSILON) + return Vec2d::Zero(); + return Vec2d(-edge.y(), edge.x()).normalized(); + }; + const auto on_material_side = [&](const Point &point, bool check_clearance) { + const auto [distance, line_index, nearest] = + current_perimeter_distancer.distance_from_lines_extra(point); + if (line_index >= lines.size()) + return false; + const Line &line = lines[line_index]; + Vec2d normal = left_normal(line); + // At a shared vertex use both incident edges, so the result does not + // depend on which equally close edge the AABB query happens to return. + const Line &previous = lines[(line_index + lines.size() - 1) % lines.size()]; + const Line &next = lines[(line_index + 1) % lines.size()]; + if ((nearest - line.a.cast()).norm() <= SCALED_EPSILON && previous.b == line.a) + normal += left_normal(previous); + if ((nearest - line.b.cast()).norm() <= SCALED_EPSILON && next.a == line.b) + normal += left_normal(next); + if (normal.norm() <= EPSILON) + return false; + + // An open or self-touching wall has no reliable polygon-wide sign. + // Orient its local normal toward the neighbouring printed inner wall, + // then test the candidate on that side at every sample. + normal.normalize(); + const Point wall_point = nearest.cast(); + const Vec2d support_point = std::get<2>( + target_perimeter_distancer.distance_from_lines_extra(wall_point)); + const double support_side = (support_point - nearest).dot(normal); + if (std::abs(support_side) <= 4. * SCALED_EPSILON) + return false; + const double side = (point.cast() - nearest).dot(normal) * (support_side > 0. ? 1. : -1.); + return side >= -4. * SCALED_EPSILON && + (! check_clearance || distance + 4. * SCALED_EPSILON >= minimum_clearance); + }; + + Point previous = path.points[1]; + if (! on_material_side(previous, require_clearance)) + return false; + for (size_t index = 2; index < path.points.size(); ++index) { + const Vec2d segment = (path.points[index] - previous).cast(); + const size_t samples = std::max(1, size_t(std::ceil(segment.norm() / effective_offset))); + for (size_t sample = 1; sample <= samples; ++sample) { + const Point point = (previous.cast() + + segment * (double(sample) / double(samples))).cast(); + if (! on_material_side(point, require_clearance)) + return false; + } + previous = path.points[index]; + } + return true; +} + +bool offset_wipe_path_toward_support(Polyline &polyline, Point seam_start, Point seam_end, Point wipe_start, + int preferred_dir, double offset_dist, double max_wipe_length, + const Lines &target_perimeter_lines, const Lines &printed_perimeter_lines, + const Lines ¤t_perimeter_lines, + double max_support_distance) +{ + assert(preferred_dir == +1 || preferred_dir == -1); + if (polyline.points.size() < 2 || target_perimeter_lines.empty() || current_perimeter_lines.empty() || + offset_dist <= SCALED_EPSILON || + max_wipe_length <= SCALED_EPSILON || max_support_distance <= SCALED_EPSILON) + return false; + + Lines material_support_lines; + const Lines *candidate_support_lines = &target_perimeter_lines; + if (seam_start == seam_end) { + // Orca: another contour may have a geometrically closer inner wall on + // this loop's air side. Restrict zero-gap support using the local seam + // normals before choosing the nearest wall. + material_support_lines = material_side_support_lines( + polyline, seam_start, preferred_dir, target_perimeter_lines); + if (material_support_lines.empty()) + return false; + candidate_support_lines = &material_support_lines; + } + + AABBTreeLines::LinesDistancer support_distancer(*candidate_support_lines); + const std::optional support_offset = support_offset_at_start( + polyline, seam_end, seam_start != seam_end, + support_distancer, max_support_distance); + if (! support_offset) + return false; + const Vec2d toward_support = *support_offset; + const double local_support_distance = toward_support.norm(); + const double effective_offset = std::min(offset_dist, local_support_distance); + if (effective_offset <= SCALED_EPSILON) + return false; + const Vec2d support_direction = toward_support / local_support_distance; + + // Orca: every candidate is validated against the same generated geometry. + // Build these AABB trees once per loop instead of rebuilding them for each + // preferred, alternate, translated, direct, or reversed candidate. + Lines all_support_lines = printed_perimeter_lines; + all_support_lines.insert(all_support_lines.end(), current_perimeter_lines.begin(), current_perimeter_lines.end()); + AABBTreeLines::LinesDistancer all_support_distancer(std::move(all_support_lines)); + AABBTreeLines::LinesDistancer current_perimeter_distancer(current_perimeter_lines); + + // Orca: allow only the contact needed to leave the extrusion endpoint. A + // connector that meets the current wall again is a seam-gap retrace, even + // if the rest of the non-extruding wipe remains over printed material. + const double contact_tolerance = wipe_tolerance(effective_offset); + + struct Candidate { + Polyline path; + // Orca: support score chooses the material-side path; length is used + // only to replace a corner-truncated path with the reverse fallback. + double support_score; + double path_length; + }; + + // Direction and wall contact have different origins after a loop pre-move. + // Keep the construction's wall endpoint for intersection checks even when + // the candidate's direction must be checked from the current nozzle position. + const auto validate_candidate = [&](Polyline path, Point path_start, Point direction_start, + double path_contact_tolerance, + const Vec2d &candidate_support_direction, + double candidate_offset, + bool require_clearance = true) -> std::optional { + // Orca: backtracking indicates a wrong join only across a nonzero gap. + // A closed zero-gap offset may initially turn back at its miter while + // still remaining on the supported material side of the perimeter. + const bool backtracks_across_gap = seam_start != seam_end && starts_by_backtracking(path, wipe_start); + // At a clipped corner another branch of the current wall may be closer + // than the requested offset. Preserve the zero-gap clearance rule, but + // check direction and local material side independently for every gap. + const bool material_side = wipe_path_stays_on_material_side( + path, direction_start, candidate_support_direction, + support_distancer, current_perimeter_distancer, candidate_offset, + require_clearance && seam_start == seam_end); + const bool connector_clear = initial_connector_is_clear( + path, wipe_start, path_start, current_perimeter_distancer, path_contact_tolerance); + if (backtracks_across_gap || ! material_side || ! connector_clear) + return std::nullopt; + const std::optional score = wipe_path_support_score( + path, wipe_start, support_distancer, all_support_distancer, max_support_distance); + if (! score) + return std::nullopt; + const double path_length = executable_path_length(path, wipe_start); + return Candidate{std::move(path), *score, path_length}; + }; + + const auto offset_candidate = [&](int dir) -> std::optional { + Polyline path = polyline; + if (! offset_wipe_path(path, seam_start, seam_end, wipe_start, dir, + effective_offset, max_wipe_length)) + return std::nullopt; + return validate_candidate(std::move(path), seam_start, seam_start, + contact_tolerance, support_direction, effective_offset); + }; + + std::optional preferred = offset_candidate(preferred_dir); + std::optional alternate = offset_candidate(-preferred_dir); + + // Orca: forward and reverse fallbacks share the same clamping, translation, + // connector tolerance, and complete-path validation. + const auto translated_candidate = [&](Polyline source, Point source_start, Point source_end, + const Vec2d &candidate_support_offset) -> std::optional { + const double support_distance = candidate_support_offset.norm(); + const double candidate_offset = std::min(offset_dist, support_distance); + if (candidate_offset <= SCALED_EPSILON) + return std::nullopt; + + const Vec2d candidate_translation = candidate_support_offset * (candidate_offset / support_distance); + if (! translated_wipe_path(source, source_start, source_end, wipe_start, + candidate_translation, max_wipe_length)) + return std::nullopt; + const double candidate_tolerance = wipe_tolerance(candidate_offset); + return validate_candidate(std::move(source), source_start, source_start, candidate_tolerance, + candidate_support_offset / support_distance, candidate_offset); + }; + + std::optional translated = translated_candidate(polyline, seam_start, seam_end, toward_support); + + // Orca: if every full-length construction folds back onto the external + // wall, retain a short direct inward move instead of accepting an outward + // candidate or falling back to the standard wipe along the outer wall. + const auto direct_candidate = [&](Point origin, const Vec2d &candidate_support_offset) -> std::optional { + const double support_distance = candidate_support_offset.norm(); + const double candidate_offset = std::min(offset_dist, support_distance); + if (candidate_offset <= SCALED_EPSILON) + return std::nullopt; + const Vec2d direction = candidate_support_offset / support_distance; + const Point destination = (origin.cast() + direction * candidate_offset).cast(); + if (destination == wipe_start) + return std::nullopt; + + Polyline path; + if (! store_wipe_path(path, seam_start, Polyline{wipe_start, destination}, max_wipe_length)) + return std::nullopt; + const double candidate_tolerance = wipe_tolerance(candidate_offset); + // Check the executed direction from the nozzle after any loop pre-move, + // but retain the wall origin for the connector's intersection checks. + return validate_candidate(std::move(path), origin, wipe_start, + candidate_tolerance, direction, candidate_offset, false); + }; + std::optional direct = direct_candidate(seam_end, toward_support); + + const double length_margin = wipe_tolerance(max_wipe_length); + std::optional reversed; + if (seam_start != seam_end && polyline.last_point() == seam_end) { + // Orca: when a large gap straddles a sharp corner, connecting the + // extrusion end to the forward offset may either reverse or leave only + // a short local move. The already printed incoming wall is equally safe: + // follow it backwards and determine its own material-side support. + Polyline reversed_source = polyline; + reversed_source.reverse(); + const std::optional reversed_support_offset = support_offset_at_start( + reversed_source, seam_end, true, support_distancer, max_support_distance); + if (reversed_support_offset) { + reversed = translated_candidate(reversed_source, seam_end, seam_end, *reversed_support_offset); + // A translated reverse path can backtrack or leave the material on + // a curved wall. Offset the incoming wall itself when translation + // cannot supply a complete wipe, retaining all candidate checks. + if (! reversed || reversed->path_length + length_margin < max_wipe_length) { + const double reverse_offset = std::min(offset_dist, reversed_support_offset->norm()); + if (reverse_offset > SCALED_EPSILON && + offset_wipe_path(reversed_source, seam_end, seam_start, wipe_start, + -preferred_dir, reverse_offset, max_wipe_length)) { + reversed_source.points.front() = seam_start; + auto candidate = validate_candidate(std::move(reversed_source), seam_end, seam_end, + wipe_tolerance(reverse_offset), reversed_support_offset->normalized(), reverse_offset); + if (candidate && (! reversed || + (candidate->path_length > reversed->path_length + length_margin && + candidate->support_score <= reversed->support_score + wipe_tolerance(reverse_offset)))) + reversed = std::move(candidate); + } + } + } + } + + // Orca: conventional offsets at a narrow cusp may form a bevel across the + // cusp. Candidates pointing away from the actual inner wall are rejected + // during validation; among the remaining paths, prefer the one whose first + // point is materially closer to that wall. + const double direction_change_margin = wipe_tolerance(effective_offset); + std::optional selected = std::move(preferred); + if (translated) { + if (! selected || translated->support_score + direction_change_margin < selected->support_score) + selected = std::move(translated); + } + if (! selected) + selected = std::move(direct); + // Prefer a direct inward move when the normal offset cannot be used. + // An alternate offset is eligible only after the same material-side checks. + if (! selected) + selected = std::move(alternate); + + // Orca: prefer a complete reverse wipe over a forward fallback that had to + // stop at the corner. Equal-length paths keep the normal forward behavior. + if (reversed && (! selected || + (reversed->path_length > selected->path_length + length_margin && + reversed->support_score <= selected->support_score + direction_change_margin))) + selected = std::move(reversed); + if (! selected) + return false; + + polyline = std::move(selected->path); + return true; +} + +std::optional wipe_on_loops_destination(const ExtrusionPaths &paths, double nozzle_diam_scaled, + bool is_ccw, bool is_hole) +{ + assert(!paths.empty()); + assert(nozzle_diam_scaled > 0); + if (paths.empty() || nozzle_diam_scaled <= 0) + return std::nullopt; + + // Orca: clamp sample distance to L/4 so forward/backward samples cannot meet. + double total_length = 0.; + for (const ExtrusionPath &path : paths) + total_length += path.length(); + const double sample_distance = std::min(nozzle_diam_scaled, total_length * 0.25); + + Point a = sample_path_at_distance(paths, true, sample_distance); + Point b = sample_path_at_distance(paths, false, sample_distance); + + const Point seam_start = paths.front().first_point(); + + // Orca: skip the inward move for degenerate geometry. + if (a == b || a == seam_start || b == seam_start) + return std::nullopt; + + const bool reverse_turn = is_hole == is_ccw; + if (reverse_turn) + std::swap(a, b); + + double angle = seam_start.ccw_angle(a, b) / 3; + + // Orca: reject degenerate angles near 0 or 2π. + static constexpr double angle_epsilon = 0.01; + if (angle < angle_epsilon || angle > 2 * PI / 3 - angle_epsilon) + return std::nullopt; + + if (reverse_turn) + angle *= -1; + + Point pt = sample_path_at_distance(paths, true, std::min(0.2 * nozzle_diam_scaled, sample_distance)); + pt.rotate(angle, seam_start); + return pt; +} + +} // namespace Slic3r diff --git a/src/libslic3r/GCode/WipePathHelpers.hpp b/src/libslic3r/GCode/WipePathHelpers.hpp new file mode 100644 index 0000000000..616e5dc88b --- /dev/null +++ b/src/libslic3r/GCode/WipePathHelpers.hpp @@ -0,0 +1,96 @@ +#pragma once + +#include + +#include "../ExtrusionEntity.hpp" +#include "../Polyline.hpp" +#include "../Line.hpp" + +namespace Slic3r { + +// Printed prefix of one region's perimeter sequence. Append each entity only +// after extrusion; later walls and other regions cannot support an inward wipe. +struct WipeInwardSupport { + Lines printed_lines; + Lines inner_lines; + void append(const ExtrusionEntity &entity); +}; + +namespace AABBTreeLines { +template class LinesDistancer; +} + +// Orca: sample a point at a given distance along ExtrusionPaths, walking +// across segment boundaries. forward=true walks from paths.front, false from +// paths.back. For tiny loops the walk stops early and returns the last +// reachable point. Returns the start point if target is zero. +// Precondition: paths must be non-empty. +Point sample_path_at_distance(const ExtrusionPaths &paths, bool forward, double target); + +// Orca: return the side of the printed path on which the material lies. +// dir +1 is left and -1 is right, matching the offset-builder convention. +int wipe_offset_direction(bool is_ccw, bool is_hole); + +// Orca: atomically offset a stored wipe path. The seam-gap or closing edge +// determines the join with the first outgoing perimeter edge, but its offset +// is not part of the executable wipe. Only the prefix needed by Wipe::wipe() +// is offset. Returns false and leaves polyline unchanged if that path cannot +// be constructed without degenerate segments. This only constructs a candidate; +// offset_wipe_path_toward_support() validates its support, material side and +// connector before accepting it. The first stored point +// remains a dummy preserving Wipe::wipe()'s convention of skipping points[0]. +// Precondition: polyline starts at seam_start, dir is +1 or -1, and +// offset_dist > 0. A non-positive max_wipe_length returns false. +bool offset_wipe_path(Polyline &polyline, Point seam_start, Point seam_end, Point wipe_start, + int dir, double offset_dist, double max_wipe_length); + +// Orca: score a candidate's first destination by distance to the target inner +// walls. Return nullopt if no target wall is near wipe_start or any executable +// segment lacks support. target_distancer contains eligible earlier walls; +// all_support_distancer includes the current wall and all earlier walls. +// The stored first point is a dummy: the first segment starts at wipe_start. +// This checks support only; material-side and connector checks belong to +// offset_wipe_path_toward_support(). Trees are reused across its candidates. +std::optional wipe_path_support_score( + const Polyline &polyline, Point wipe_start, + const AABBTreeLines::LinesDistancer &target_distancer, + const AABBTreeLines::LinesDistancer &all_support_distancer, + double max_distance); + +// Validate the initial inward direction and the local material side along the +// executable path, using the inner wall to orient the open current wall's +// normals. Clearance is optional for clipped corners and short direct fallbacks; +// the material-side check is mandatory. The straight connector is checked by +// its initial direction and separately by support and intersection validation. +// path_start is the construction origin; points[0] is only a storage sentinel. +bool wipe_path_stays_on_material_side( + const Polyline &path, Point path_start, const Vec2d &support_direction, + const AABBTreeLines::LinesDistancer &target_perimeter_distancer, + const AABBTreeLines::LinesDistancer ¤t_perimeter_distancer, + double effective_offset, bool require_clearance); + +// Orca: identify the adjacent inner perimeter from the outgoing wall, excluding +// support on the air side of a closed zero-gap loop. Clamp the requested offset +// to the distance from the seam end to that support, then select the safest +// supported offset or translated path. If a wide seam gap at a corner truncates +// every forward candidate, the incoming printed wall may be followed backwards +// instead. All earlier printed perimeters still participate in the complete-path +// safety check. This handles converging, locally ambiguous, or self-touching +// contours whose global winding alone does not identify the material side. +// Returns false and leaves polyline unchanged when no candidate is supported. +// Precondition: preferred_dir is +1 or -1. Distances must be positive. +bool offset_wipe_path_toward_support(Polyline &polyline, Point seam_start, Point seam_end, Point wipe_start, + int preferred_dir, double offset_dist, double max_wipe_length, + const Lines &target_perimeter_lines, const Lines &printed_perimeter_lines, + const Lines ¤t_perimeter_lines, + double max_support_distance); + +// Orca: compute the inward destination point for wipe_on_loops, or +// std::nullopt when the geometry is degenerate (tiny loop, coincident samples, +// angle near 0 or 2π). Returns the rotated destination or nullopt to skip the +// inward move entirely. +// Precondition: paths non-empty, nozzle_diam_scaled > 0. +std::optional wipe_on_loops_destination(const ExtrusionPaths &paths, double nozzle_diam_scaled, + bool is_ccw, bool is_hole); + +} // namespace Slic3r diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index e974ffd7f8..d4209abe1f 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1282,6 +1282,8 @@ static std::vector s_Preset_print_options{ "accel_to_decel_enable", "accel_to_decel_factor", "wipe_on_loops", + "wipe_inward", + "wipe_inward_distance", "wipe_before_external_loop", "bridge_density", "internal_bridge_density", diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 60f747ce04..9c4edabfdb 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -233,6 +233,8 @@ bool Print::invalidate_state_by_config_options(const ConfigOptionResolver & /* n "accel_to_decel_enable", "accel_to_decel_factor", "wipe_on_loops", + "wipe_inward", + "wipe_inward_distance", "gcode_comments", "gcode_label_objects", "exclude_object", diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 0b0fe71dc0..7c34fb9542 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -6267,6 +6267,35 @@ void PrintConfigDef::init_fff_params() def->mode = comAdvanced; def->set_default_value(new ConfigOptionBool(false)); + def = this->add("wipe_inward", coBool); + def->label = L("Wipe inward"); + def->category = L("Quality"); + def->tooltip = L("Applies only to external walls, including hole boundaries. Moves the hot nozzle toward printed " + "inner walls during wiping to reduce reheating of freshly printed plastic and seam marks.\n\n" + "Especially useful at layer heights below 0.1 mm, where wipe marks are more visible.\n\n" + "Uses the regular wipe if no adjacent inner wall is already printed (single-wall areas or " + "Outer/Inner wall order), or if no supported inward path can be found, for example at tight " + "corners or seam gaps."); + def->mode = comExpert; + def->set_default_value(new ConfigOptionBool(false)); + + def = this->add("wipe_inward_distance", coFloatOrPercent); + def->label = L("Wipe inward distance"); + def->category = L("Quality"); + def->tooltip = L("The distance the wipe path is shifted away from the external perimeter, specified in millimeters " + "or as a percentage of the actual outer-wall extrusion width.\n\n" + "For example, 50% shifts the path by half of the outer-wall width. The effective offset is limited " + "by both the actual outer-wall width and the available spacing to the adjacent wall, so values " + "above 100% or an equivalent absolute distance have no additional effect. " + "Set to 0 to disable the offset."); + def->sidetext = L("mm or %"); + def->ratio_over = "outer_wall_line_width"; + def->min = 0; + def->max = 100; + def->max_literal = 2; // Orca: G-code generation also clamps literal values to the actual outer-wall width. + def->mode = comExpert; + def->set_default_value(new ConfigOptionFloatOrPercent(50, true)); + def = this->add("wipe_before_external_loop", coBool); def->label = L("Wipe before external loop"); def->category = L("Quality"); diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 18e66adb34..6beaeed104 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -1391,6 +1391,8 @@ PRINT_CONFIG_CLASS_DEFINE( ((ConfigOptionBool, role_based_wipe_speed)) ((ConfigOptionFloatOrPercent, wipe_speed)) ((ConfigOptionBool, wipe_on_loops)) + ((ConfigOptionBool, wipe_inward)) + ((ConfigOptionFloatOrPercent, wipe_inward_distance)) ((ConfigOptionBool, wipe_before_external_loop)) ((ConfigOptionEnum, wall_infill_order)) ((ConfigOptionBool, precise_outer_wall)) diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index 54378b3b16..bb2a355daa 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -1574,6 +1574,8 @@ bool PrintObject::invalidate_state_by_config_options( || opt_key == "brim_flow_ratio" || opt_key == "filament_flow_ratio" || opt_key == "scarf_joint_flow_ratio" + || opt_key == "wipe_inward" + || opt_key == "wipe_inward_distance" || opt_key == "spiral_starting_flow_ratio" || opt_key == "spiral_finishing_flow_ratio") { invalidated |= m_print->invalidate_step(psGCodeExport); diff --git a/src/slic3r/GUI/ConfigManipulation.cpp b/src/slic3r/GUI/ConfigManipulation.cpp index 5bd74e107d..ba91ffb7c2 100644 --- a/src/slic3r/GUI/ConfigManipulation.cpp +++ b/src/slic3r/GUI/ConfigManipulation.cpp @@ -1104,6 +1104,9 @@ void ConfigManipulation::toggle_print_fff_options(DynamicPrintConfig *config, in auto is_role_based_wipe_speed = config->opt_bool("role_based_wipe_speed"); toggle_field("wipe_speed",!is_role_based_wipe_speed); + const bool have_wipe_inward = config->opt_bool("wipe_inward"); + toggle_line("wipe_inward_distance", have_wipe_inward); + for (auto el : {"accel_to_decel_enable", "accel_to_decel_factor"}) toggle_line(el, gcf_is_klipper); if(gcf_is_klipper) diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 0fdb9dcd94..06d953aa25 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -15755,6 +15755,7 @@ void Plater::calib_pa(const Calib_Params& params) auto printer_config = &wxGetApp().preset_bundle->printers.get_edited_preset().config; print_config->set_key_value("overhang_reverse", new ConfigOptionBool(false)); print_config->set_key_value("precise_z_height", new ConfigOptionBool(false)); + print_config->set_key_value("wipe_inward", new ConfigOptionBool(false)); printer_config->set_key_value("resonance_avoidance", new ConfigOptionBool{false}); switch (params.mode) { case CalibMode::Calib_PA_Line: @@ -16440,6 +16441,7 @@ void Plater::calib_retraction(const Calib_Params& params) auto obj = model().objects[0]; print_config->set_key_value("enable_wrapping_detection", new ConfigOptionBool(false)); + print_config->set_key_value("wipe_inward", new ConfigOptionBool(false)); float nozzle_diameter = printer_config->option("nozzle_diameter")->get_at(0); float layer_height; diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index 914e4cc7bb..c0f78ee9c5 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -2669,6 +2669,8 @@ void TabPrint::build() optgroup->append_single_option_line("role_based_wipe_speed","quality_settings_seam#role-based-wipe-speed"); optgroup->append_single_option_line("wipe_speed", "quality_settings_seam#wipe-speed"); optgroup->append_single_option_line("wipe_on_loops","quality_settings_seam#wipe-on-loop-inward-movement"); + optgroup->append_single_option_line("wipe_inward", "quality_settings_seam#wipe-inward"); + optgroup->append_single_option_line("wipe_inward_distance", "quality_settings_seam#wipe-inward"); optgroup->append_single_option_line("wipe_before_external_loop","quality_settings_seam#wipe-before-external"); diff --git a/src/slic3r/Utils/CalibUtils.cpp b/src/slic3r/Utils/CalibUtils.cpp index 25aad85d2f..e432ccc152 100644 --- a/src/slic3r/Utils/CalibUtils.cpp +++ b/src/slic3r/Utils/CalibUtils.cpp @@ -1096,6 +1096,7 @@ bool CalibUtils::calib_generic_PA(const CalibInfo &calib_info, wxString &error_m calib_pa_pattern(calib_info, model); DynamicPrintConfig print_config = calib_info.print_prest->config; + print_config.set_key_value("wipe_inward", new ConfigOptionBool(false)); DynamicPrintConfig filament_config = calib_info.filament_prest->config; DynamicPrintConfig printer_config = calib_info.printer_prest->config; @@ -1357,6 +1358,7 @@ void CalibUtils::calib_retraction(const CalibInfo &calib_info, wxString &error_m read_model_from_file(input_file, model); DynamicPrintConfig print_config = calib_info.print_prest->config; + print_config.set_key_value("wipe_inward", new ConfigOptionBool(false)); DynamicPrintConfig filament_config = calib_info.filament_prest->config; DynamicPrintConfig printer_config = calib_info.printer_prest->config; diff --git a/tests/fff_print/CMakeLists.txt b/tests/fff_print/CMakeLists.txt index 3247bfda66..60e1721817 100644 --- a/tests/fff_print/CMakeLists.txt +++ b/tests/fff_print/CMakeLists.txt @@ -21,6 +21,7 @@ add_executable(${_TEST_NAME}_tests test_support_material.cpp test_tree_support.cpp test_trianglemesh.cpp + test_wipe.cpp test_wipe_tower.cpp ) target_link_libraries(${_TEST_NAME}_tests test_common libslic3r Catch2::Catch2WithMain) diff --git a/tests/fff_print/test_wipe.cpp b/tests/fff_print/test_wipe.cpp new file mode 100644 index 0000000000..46ee0d6441 --- /dev/null +++ b/tests/fff_print/test_wipe.cpp @@ -0,0 +1,653 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "libslic3r/GCode/GCodeProcessor.hpp" +#include "libslic3r/GCodeReader.hpp" +#include "libslic3r/PrintConfig.hpp" +#include "libslic3r/Layer.hpp" + +#include "test_helpers.hpp" + +using namespace Slic3r; +using namespace Slic3r::Test; + +namespace { + +DynamicPrintConfig wipe_config(const char *wall_generator, bool wipe_inward, + const char *wipe_inward_distance = "50%", + const char *seam_gap = "10%", bool wipe_on_loops = false, + const char *wall_loops = "2", + const char *wall_sequence = "inner wall/outer wall", + bool alternate_extra_wall = false, + const char *sparse_infill_density = "0%", + const char *seam_position = "aligned") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ + { "nozzle_diameter", "0.4" }, + { "layer_height", "0.2" }, + { "initial_layer_print_height", "0.2" }, + { "line_width", "0.45" }, + { "outer_wall_line_width", "0" }, // Orca: Auto must use the actual path width. + { "wall_loops", wall_loops }, + { "wall_generator", wall_generator }, + { "wall_sequence", wall_sequence }, + { "top_shell_layers", "0" }, + { "bottom_shell_layers", "0" }, + { "sparse_infill_density", sparse_infill_density }, + { "seam_position", seam_position }, + { "seam_gap", seam_gap }, + { "wipe", "1" }, + { "wipe_distance", "2" }, + { "retraction_length", "0.8" }, + { "retract_when_changing_layer", "1" }, + { "wipe_inward", wipe_inward ? "1" : "0" }, + { "wipe_inward_distance", wipe_inward_distance }, + { "wipe_on_loops", wipe_on_loops ? "1" : "0" }, + { "alternate_extra_wall", alternate_extra_wall ? "1" : "0" }, + { "gcode_comments", "1" }, + { "machine_start_gcode", "" }, + { "machine_end_gcode", "" }, + }); + return config; +} + +struct WipeTrajectory { + Vec2d start; + double z; + std::vector destinations; +}; + +std::vector wipe_trajectories(const std::string &gcode) +{ + const std::string &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start); + const std::string &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_End); + std::vector trajectories; + bool in_wipe = false; + + GCodeReader parser; + parser.parse_buffer(gcode, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { + const std::string_view comment = line.comment(); + if (comment.find(start_tag) != std::string_view::npos) { + in_wipe = true; + trajectories.push_back({Vec2d(self.x(), self.y()), self.z(), {}}); + return; + } + if (comment.find(end_tag) != std::string_view::npos) { + in_wipe = false; + return; + } + if (in_wipe && line.dist_XY(self) > EPSILON) + trajectories.back().destinations.emplace_back(line.new_X(self), line.new_Y(self)); + }); + return trajectories; +} + +std::vector wipe_destinations(const std::string &gcode) +{ + std::vector destinations; + for (const WipeTrajectory &trajectory : wipe_trajectories(gcode)) + destinations.insert(destinations.end(), trajectory.destinations.begin(), trajectory.destinations.end()); + return destinations; +} + +bool trajectories_differ(const std::vector &lhs, const std::vector &rhs) +{ + if (lhs.size() != rhs.size()) + return true; + for (size_t i = 0; i < lhs.size(); ++i) + if ((lhs[i] - rhs[i]).norm() > 0.01) + return true; + return false; +} + +double trajectory_length(const WipeTrajectory &trajectory) +{ + double length = 0.; + Vec2d previous = trajectory.start; + for (const Vec2d &destination : trajectory.destinations) { + length += (destination - previous).norm(); + previous = destination; + } + return length; +} + +} // namespace + +TEST_CASE("Wipe retraction preserves fractional speed with inward wipe disabled", "[Wipe][Regression]") +{ + const char *retraction_speed = GENERATE("25.25", "25.5", "25.75"); + const char *relative_e = GENERATE("0", "1"); + INFO("retraction speed: " << retraction_speed); + INFO("relative E: " << relative_e); + DynamicPrintConfig config = wipe_config("classic", false); + config.set_deserialize_strict({ + {"gcode_flavor", "marlin2"}, + {"use_relative_e_distances", relative_e}, + {"retraction_speed", retraction_speed}, + {"retraction_length", "0.8"}, + {"retract_before_wipe", "0%"}, + {"retract_after_wipe", "0%"}, + {"role_based_wipe_speed", "0"}, + {"wipe_speed", "100"}, + {"wipe_distance", "2"}, + }); + const std::string output = slice({make_cube(10., 10., 1.)}, config); + const auto &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start); + const auto &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_End); + double before_wipe = 0.; + double during_wipe = 0.; + bool in_wipe = false; + bool complete = false; + GCodeReader parser; + parser.apply_config(config); + parser.parse_buffer(output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { + if (complete) + return; + if (line.comment().find(start_tag) != std::string_view::npos) { + in_wipe = true; + } else if (in_wipe && line.comment().find(end_tag) != std::string_view::npos) { + complete = true; + } else if (line.retracting(self)) { + (in_wipe ? during_wipe : before_wipe) -= line.dist_E(self); + } else if (line.extruding(self)) { + before_wipe = 0.; + } + }); + + REQUIRE(complete); + // At 100 mm/s, the 2 mm wipe lasts 0.02 seconds. The remaining part of + // the configured 0.8 mm retraction must be emitted before that wipe. + const double expected_during = std::stod(retraction_speed) * 2. / 100.; + CHECK_THAT(during_wipe, Catch::Matchers::WithinAbs(expected_during, 0.00005)); + CHECK_THAT(before_wipe, Catch::Matchers::WithinAbs(0.8 - expected_during, 0.00005)); +} + +TEST_CASE("Inward wipe respects the minimum travel for retraction and Z hop", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + const char *relative_e = GENERATE("0", "1"); + const char *reduce_crossing_wall = GENERATE("0", "1"); + const char *minimum_travel = GENERATE("5", "0"); + CAPTURE(wall_generator, relative_e, reduce_crossing_wall, minimum_travel); + DynamicPrintConfig config = wipe_config( + wall_generator, true, "50%", "10%", false, "3", "inner-outer-inner wall"); + config.set_deserialize_strict({ + {"gcode_flavor", "marlin2"}, + {"use_relative_e_distances", relative_e}, + {"reduce_crossing_wall", reduce_crossing_wall}, + {"retraction_minimum_travel", minimum_travel}, + {"retract_when_changing_layer", "0"}, + {"use_firmware_retraction", "0"}, + {"retract_before_wipe", "0%"}, + {"retract_after_wipe", "0%"}, + {"retraction_speed", "25.5"}, + {"role_based_wipe_speed", "0"}, + {"wipe_speed", "100"}, + {"z_hop", "0.4"}, + {"retract_lift_above", "0"}, + {"retract_lift_below", "0"}, + }); + config.set_key_value("z_hop_types", new ConfigOptionEnumsGeneric{zhtNormal}); + config.set_key_value("retract_lift_enforce", new ConfigOptionEnumsGeneric{rletAllSurfaces}); + const std::string output = slice({make_cube(10., 10., 1.)}, config); + const auto &role_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Role); + const auto &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start); + const auto &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_End); + ExtrusionRole role = erNone; + bool after_outer_wall = false; + bool in_wipe = false; + size_t transitions = 0; + size_t same_layer_transitions = 0; + size_t inward_wipes = 0; + double retraction = 0.; + double lift = 0.; + double outer_z = 0.; + GCodeReader parser; + parser.apply_config(config); + parser.parse_buffer(output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { + if (line.comment().find(role_tag) == 0) + role = ExtrusionEntity::string_to_role(line.comment().substr(role_tag.size())); + if (line.comment().find(start_tag) == 0) { + in_wipe = true; + if (after_outer_wall) + ++inward_wipes; + } else if (line.comment().find(end_tag) == 0) { + in_wipe = false; + } + if (line.extruding(self) && line.dist_XY(self) > EPSILON) { + if (role == erExternalPerimeter) { + after_outer_wall = true; + retraction = lift = 0.; + outer_z = line.new_Z(self); + } else if (after_outer_wall) { + REQUIRE(role == erPerimeter); + ++transitions; + const double layer_rise = std::max(0., double(self.z()) - outer_z); + if (layer_rise < EPSILON) + ++same_layer_transitions; + // A 5 mm threshold suppresses retraction across a few wall widths. + // A zero threshold still permits the ordinary retract and lift. + const bool retract = std::stod(minimum_travel) == 0.; + CHECK_THAT(retraction, Catch::Matchers::WithinAbs(retract ? 0.8 : 0., 0.00005)); + // Exclude an ordinary layer change from the accumulated upward motion. + CHECK_THAT(lift - layer_rise, Catch::Matchers::WithinAbs(retract ? 0.4 : 0., 0.001)); + after_outer_wall = false; + } + } else if (after_outer_wall) { + if (line.retracting(self)) + retraction -= line.dist_E(self); + lift += std::max(0., double(line.dist_Z(self))); + if (in_wipe) + CHECK_THAT(line.dist_E(self), Catch::Matchers::WithinAbs(0., 0.00005)); + } + }); + // The 1 mm cube has five 0.2 mm layers: every outer wall must still wipe. + REQUIRE(transitions == 5); + REQUIRE(same_layer_transitions >= 4); + REQUIRE(inward_wipes == transitions); +} + +TEST_CASE("Changing inward wipe settings preserves the sliced geometry", "[Wipe][Regression]") +{ + const char *key = GENERATE("wipe_inward", "wipe_inward_distance"); + DynamicPrintConfig config = wipe_config("classic", false); + Print print; + Model model; + init_print({make_cube(10., 10., 1.)}, print, model, config); + gcode(print); + const PrintObject &object = *print.objects().front(); + REQUIRE(object.is_step_done(posPerimeters)); + REQUIRE(object.is_step_done(posInfill)); + REQUIRE(print.is_step_done(psWipeTower)); + REQUIRE(print.is_step_done(psGCodeExport)); + + DynamicPrintConfig changed = config; + changed.set_deserialize_strict({{key, std::string(key) == "wipe_inward" ? "1" : "75%"}}); + print.apply(model, changed); + + CHECK(print.objects().front()->is_step_done(posPerimeters)); + CHECK(print.objects().front()->is_step_done(posInfill)); + CHECK(print.is_step_done(psWipeTower)); + CHECK_FALSE(print.is_step_done(psGCodeExport)); +} + +TEST_CASE("Retraction and pressure advance calibration suppress inward wipe overrides", "[Wipe][Regression]") +{ + const auto mode = GENERATE(CalibMode::Calib_None, CalibMode::Calib_PA_Tower, + CalibMode::Calib_Auto_PA_Line, CalibMode::Calib_Retraction_tower, + CalibMode::Calib_Flow_Rate); + const char *wall_generator = GENERATE("classic", "arachne"); + const bool per_object = GENERATE(false, true); + INFO("calibration mode: " << int(mode) << ", wall generator: " << wall_generator + << ", per-object override: " << per_object); + + const auto trajectories = [&](bool inward) { + DynamicPrintConfig config = wipe_config(wall_generator, inward && !per_object); + const std::vector> overrides{ + {{"wipe_inward", inward ? "1" : "0"}} + }; + Print print; + Model model; + init_print({make_cube(10., 10., 1.)}, print, model, config, per_object ? &overrides : nullptr); + Calib_Params params; + params.mode = mode; + params.start = 0.2; + params.end = 0.4; + params.step = 0.1; + print.set_calib_params(params); + return wipe_destinations(gcode(print)); + }; + + const auto regular = trajectories(false); + const auto inward = trajectories(true); + REQUIRE_FALSE(regular.empty()); + REQUIRE_FALSE(inward.empty()); + // Other calibration modes and ordinary prints must still honor the option. + const bool should_differ = mode == CalibMode::Calib_None || mode == CalibMode::Calib_Flow_Rate; + CHECK(trajectories_differ(regular, inward) == should_differ); +} + +TEST_CASE("Inactive inward wipe settings preserve the exported trajectory", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + const bool disable_wiping = GENERATE(false, true); + DynamicPrintConfig regular = wipe_config(wall_generator, false); + DynamicPrintConfig inward = wipe_config(wall_generator, true, disable_wiping ? "50%" : "0"); + if (disable_wiping) { + regular.set_deserialize_strict({{"wipe", "0"}}); + inward.set_deserialize_strict({{"wipe", "0"}}); + } + const auto regular_paths = wipe_destinations(slice({make_cube(10., 10., 1.)}, regular)); + const auto inward_paths = wipe_destinations(slice({make_cube(10., 10., 1.)}, inward)); + if (!disable_wiping) + REQUIRE_FALSE(regular_paths.empty()); + CHECK_FALSE(trajectories_differ(regular_paths, inward_paths)); +} + +TEST_CASE("Inward wipe changes the exported trajectory when outer wall width is Auto", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + INFO("wall generator: " << wall_generator); + + const std::vector regular = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false))); + const std::vector inward = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true))); + + REQUIRE_FALSE(regular.empty()); + REQUIRE_FALSE(inward.empty()); + REQUIRE(trajectories_differ(regular, inward)); +} + +TEST_CASE("Inward wipe recognizes an external wall starting on an overhang", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + const bool inward = GENERATE(false, true); + CAPTURE(wall_generator, inward); + const auto config = wipe_config(wall_generator, inward, "50%", "0%", false, + "3", "inner-outer-inner wall", false, "0%", "back"); + Print print; + Model model; + init_print({make_cube(10., 10., 1.)}, print, model, config); + print.process(); + size_t mixed_loops = 0; + const auto mark_overhangs = [&](auto &&self, ExtrusionEntity *entity) -> void { + if (auto *collection = dynamic_cast(entity)) { + for (ExtrusionEntity *child : collection->entities) + self(self, child); + } else if (auto *loop = dynamic_cast(entity); loop && is_external_perimeter(loop->role())) { + // Keep the printed geometry intact and give the back seam overhang + // roles. The front edge remains an ordinary external-wall segment. + ExtrusionPaths paths; + bool has_overhang = false; + bool has_external = false; + for (const ExtrusionPath &source : loop->paths) { + for (size_t i = 1; i < source.polyline.points.size(); ++i) { + ExtrusionPath path = source; + path.polyline.points = {source.polyline.points[i - 1], source.polyline.points[i]}; + const bool overhang = path.polyline.points.front().y() > 0 || path.polyline.points.back().y() > 0; + path.set_extrusion_role(overhang ? erOverhangPerimeter : erExternalPerimeter); + has_overhang |= overhang; + has_external |= !overhang; + paths.push_back(std::move(path)); + } + } + REQUIRE(has_overhang); + REQUIRE(has_external); + loop->paths = std::move(paths); + ++mixed_loops; + } + }; + for (const PrintObject *object : print.objects()) + for (Layer *layer : object->layers()) + for (LayerRegion *region : layer->regions()) + mark_overhangs(mark_overhangs, ®ion->perimeters); + REQUIRE(mixed_loops > 0); + + bool has_inward_wipe = false; + for (const WipeTrajectory &trajectory : wipe_trajectories(gcode(print))) { + if (trajectory.destinations.empty()) + continue; + const Vec2d move = trajectory.destinations.front() - trajectory.start; + if (trajectory.start.x() > 4. && trajectory.start.y() > 4. && move.x() < -0.05 && move.y() < -0.05) + has_inward_wipe = true; + } + CHECK(has_inward_wipe == inward); +} + +TEST_CASE("Inward wipe keeps its offset when seam gap is zero", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + INFO("wall generator: " << wall_generator); + + const std::vector regular = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false, "50%", "0%"))); + const std::vector inward = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "50%", "0%"))); + + REQUIRE_FALSE(regular.empty()); + REQUIRE_FALSE(inward.empty()); + REQUIRE(trajectories_differ(regular, inward)); +} + +TEST_CASE("Inward wipe is retained across layers with a back seam", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + INFO("wall generator: " << wall_generator); + + const DynamicPrintConfig inward_config = wipe_config( + wall_generator, true, "50%", "0%", false, "3", "inner-outer-inner wall", false, "0%", "back"); + const std::vector inward = wipe_trajectories(slice({make_cube(27., 27., 1.)}, inward_config)); + + REQUIRE_FALSE(inward.empty()); + std::map inward_wipe_by_layer; + for (const WipeTrajectory &trajectory : inward) { + bool &has_inward_wipe = inward_wipe_by_layer[trajectory.z]; + if (trajectory.destinations.empty()) + continue; + const Vec2d first_move = trajectory.destinations.front() - trajectory.start; + // Orca: a back seam lands on the cube's positive-X/positive-Y corner. + // Its inward wipe must move diagonally away from both external faces. + has_inward_wipe = has_inward_wipe || + (trajectory.start.x() > 13. && trajectory.start.y() > 13. && + first_move.x() < -0.05 && first_move.y() < -0.05); + } + REQUIRE(inward_wipe_by_layer.size() == 5); + for (const auto &[z, has_inward_wipe] : inward_wipe_by_layer) { + INFO("layer Z: " << z); + REQUIRE(has_inward_wipe); + } +} + +TEST_CASE("Literal inward wipe distance is clamped to the outer wall width", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + INFO("wall generator: " << wall_generator); + + const std::vector regular = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false))); + const std::vector full_width = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "100%"))); + const std::vector oversized = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "2"))); + + REQUIRE_FALSE(full_width.empty()); + REQUIRE(trajectories_differ(regular, full_width)); + REQUIRE(oversized.size() == full_width.size()); + for (size_t i = 0; i < full_width.size(); ++i) + REQUIRE_THAT((oversized[i] - full_width[i]).norm(), Catch::Matchers::WithinAbs(0., 0.01)); +} + +TEST_CASE("Inward wipe is not applied without an adjacent wall", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + INFO("wall generator: " << wall_generator); + + const std::vector regular = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, false, "50%", "10%", false, "1"))); + const std::vector inward = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config(wall_generator, true, "50%", "10%", false, "1"))); + + REQUIRE_FALSE(regular.empty()); + REQUIRE_FALSE(trajectories_differ(regular, inward)); +} + +TEST_CASE("Inward wipe uses an alternate extra wall when the configured wall count is one", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + INFO("wall generator: " << wall_generator); + + const DynamicPrintConfig regular_config = wipe_config( + wall_generator, false, "50%", "10%", false, "1", "inner wall/outer wall", true, "15%"); + const DynamicPrintConfig inward_config = wipe_config( + wall_generator, true, "50%", "10%", false, "1", "inner wall/outer wall", true, "15%"); + const std::vector regular = wipe_destinations( + slice({make_cube(10., 10., 1.)}, regular_config)); + const std::vector inward = wipe_destinations( + slice({make_cube(10., 10., 1.)}, inward_config)); + + REQUIRE_FALSE(regular.empty()); + REQUIRE_FALSE(inward.empty()); + REQUIRE(trajectories_differ(regular, inward)); +} + +TEST_CASE("Inward wipe is not applied before the adjacent wall is printed", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + INFO("wall generator: " << wall_generator); + + const std::vector regular = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config( + wall_generator, false, "50%", "10%", false, "2", "outer wall/inner wall"))); + const std::vector inward = wipe_destinations( + slice({make_cube(10., 10., 1.)}, wipe_config( + wall_generator, true, "50%", "10%", false, "2", "outer wall/inner wall"))); + + REQUIRE_FALSE(regular.empty()); + REQUIRE_FALSE(trajectories_differ(regular, inward)); +} + +TEST_CASE("Wipe on loops preserves the corner move with inward wipe disabled", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + const char *nozzle_diameter = GENERATE("0.4", "0.8"); + const char *comments = GENERATE("0", "1"); + CAPTURE(comments); + INFO("wall generator: " << wall_generator << ", nozzle diameter: " << nozzle_diameter); + // A closed square gives a 90-degree material-side corner at the seam. + DynamicPrintConfig config = wipe_config(wall_generator, false, "50%", "0", true); + config.set_deserialize_strict({{"nozzle_diameter", nozzle_diameter}, {"seam_position", "nearest"}, + {"gcode_comments", comments}}); + const std::string output = slice({make_cube(10., 10., 1.)}, config); + const auto &role_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Role); + const auto &wipe_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start); + ExtrusionRole role = erNone; + std::vector loop; + bool after_extrusion = false; + size_t moves = 0; + GCodeReader parser; + parser.apply_config(config); + parser.parse_buffer(output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { + if (line.comment().find(role_tag) == 0) { + role = ExtrusionEntity::string_to_role(line.comment().substr(role_tag.size())); + loop.clear(); + after_extrusion = false; + } + if (line.comment().find(wipe_tag) == 0) + after_extrusion = false; + if (role != erExternalPerimeter) + return; + if (line.extruding(self) && line.dist_XY(self) > EPSILON) { + if (loop.empty()) + loop.emplace_back(self.x(), self.y()); + loop.emplace_back(line.new_X(self), line.new_Y(self)); + after_extrusion = true; + return; + } + // The loop move is the first non-extruding XY move after the external + // wall and before the reserved wipe marker, regardless of comment text. + if (!after_extrusion || line.dist_XY(self) <= EPSILON) + return; + after_extrusion = false; + + ++moves; + INFO("layer Z: " << self.z()); + REQUIRE(loop.size() >= 4); + const Vec2d seam = loop.front(); + REQUIRE_THAT((loop.back() - seam).norm(), Catch::Matchers::WithinAbs(0., 0.003)); + const Vec2d outgoing = (loop[1] - seam).normalized(); + const Vec2d into_corner = (loop[loop.size() - 2] - seam).normalized(); + REQUIRE_THAT(outgoing.dot(into_corner), Catch::Matchers::WithinAbs(0., 0.01)); + const Vec2d move = Vec2d(line.new_X(self), line.new_Y(self)) - seam; + // The legacy corner move is 20% of the nozzle diameter, turned 30 degrees + // from the outgoing edge into the square. Check both components independently. + const double distance = 0.2 * std::stod(nozzle_diameter); + CHECK_THAT(move.dot(outgoing), Catch::Matchers::WithinAbs(distance * std::sqrt(3.) / 2., 0.003)); + CHECK_THAT(move.dot(into_corner), Catch::Matchers::WithinAbs(distance / 2., 0.003)); + }); + REQUIRE(moves == 5); +} + +TEST_CASE("Inward wipe remains valid after wipe on loops moves the nozzle", "[Wipe][Regression]") +{ + const char *wall_generator = GENERATE("classic", "arachne"); + const char *comments = GENERATE("0", "1"); + CAPTURE(comments); + INFO("wall generator: " << wall_generator); + + DynamicPrintConfig config = wipe_config(wall_generator, false, "50%", "10%", true); + config.set_deserialize_strict({{"gcode_comments", comments}}); + const std::string loop_move = slice({make_cube(10., 10., 1.)}, config); + config.set_deserialize_strict({{"wipe_inward", "1"}}); + const std::string combined = slice({make_cube(10., 10., 1.)}, config); + config.set_deserialize_strict({{"wipe_on_loops", "0"}}); + const std::string inward_only = slice({make_cube(10., 10., 1.)}, config); + + for (const std::string *output : {&loop_move, &combined}) { + INFO("wipe_inward: " << (output == &combined)); + std::map> loop_moves_by_layer; + const auto &role_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Role); + const auto &wipe_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Start); + ExtrusionRole role = erNone; + bool after_extrusion = false; + GCodeReader parser; + parser.apply_config(config); + parser.parse_buffer(*output, [&](GCodeReader &self, const GCodeReader::GCodeLine &line) { + if (line.comment().find(role_tag) == 0) { + role = ExtrusionEntity::string_to_role(line.comment().substr(role_tag.size())); + after_extrusion = false; + } + if (line.comment().find(wipe_tag) == 0) + after_extrusion = false; + if (role != erExternalPerimeter || line.dist_XY(self) <= EPSILON) + return; + if (line.extruding(self)) { + after_extrusion = true; + } else if (after_extrusion) { + loop_moves_by_layer[line.new_Z(self)].emplace_back(line.new_X(self), line.new_Y(self)); + after_extrusion = false; + } + }); + + // The 1 mm cube at 0.2 mm layer height has one external loop on each of five layers. + const auto trajectories = wipe_trajectories(*output); + REQUIRE(loop_moves_by_layer.size() == 5); + for (size_t layer = 1; layer <= 5; ++layer) { + const double z = layer * 0.2; + const auto moves = std::find_if(loop_moves_by_layer.begin(), loop_moves_by_layer.end(), + [z](const auto &entry) { return std::abs(entry.first - z) < 0.001; }); + REQUIRE(moves != loop_moves_by_layer.end()); + REQUIRE(moves->second.size() == 1); + const auto wipe = std::find_if(trajectories.begin(), trajectories.end(), [&](const WipeTrajectory &trajectory) { + return std::abs(trajectory.z - z) < 0.001 && + (trajectory.start - moves->second.front()).norm() < 0.001; + }); + REQUIRE(wipe != trajectories.end()); + // The configured 2 mm wipe must be measured from the inward move's + // endpoint, including when wipe_inward is off (set_last_pos regression). + CHECK_THAT(trajectory_length(*wipe), Catch::Matchers::WithinAbs(2., 0.003)); + } + } + + const std::vector combined_trajectories = wipe_trajectories(combined); + const std::vector inward_trajectories = wipe_trajectories(inward_only); + REQUIRE_FALSE(combined_trajectories.empty()); + REQUIRE(combined_trajectories.size() == inward_trajectories.size()); + REQUIRE(trajectories_differ(wipe_destinations(combined), wipe_destinations(loop_move))); + + bool start_changed = false; + for (size_t i = 0; i < combined_trajectories.size(); ++i) { + start_changed = start_changed || + (combined_trajectories[i].start - inward_trajectories[i].start).norm() > 0.01; + REQUIRE_THAT(trajectory_length(combined_trajectories[i]), + Catch::Matchers::WithinAbs(trajectory_length(inward_trajectories[i]), 0.01)); + } + REQUIRE(start_changed); +} diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 2f859f46fe..bc5a0a1e80 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -43,6 +43,7 @@ add_executable(${_TEST_NAME}_tests test_voronoi.cpp test_wipe_tower_estimate.cpp test_wipe_tower.cpp + test_wipe_path.cpp test_optimizers.cpp test_ordering_strategies.cpp # test_png_io.cpp diff --git a/tests/libslic3r/test_wipe_path.cpp b/tests/libslic3r/test_wipe_path.cpp new file mode 100644 index 0000000000..b637ad2b87 --- /dev/null +++ b/tests/libslic3r/test_wipe_path.cpp @@ -0,0 +1,1024 @@ +#include + +#include "libslic3r/GCode/WipePathHelpers.hpp" +#include "libslic3r/AABBTreeLines.hpp" +#include "libslic3r/Polyline.hpp" +#include "libslic3r/Point.hpp" +#include "libslic3r/Line.hpp" +#include "libslic3r/libslic3r.h" + +#include +#include +#include + +using namespace Slic3r; +using Slic3r::AABBTreeLines::LinesDistancer; + +TEST_CASE("Stored wipe path retains its length around a curved wall after a seam gap", "[WipePath][Regression]") +{ + const int mirror = GENERATE(1, -1); + const double wipe_length = GENERATE(0.8, 1.0); + CAPTURE(mirror, wipe_length); + // A 0.02 mm seam gap on a curved 0.24 mm wall leaves a short outgoing + // segment whose inward offset backtracks. Coordinates use internal scaling + // from the affected loop; the adjacent inner wall is already printed. + const auto point = [mirror](coord_t x, coord_t y) { return Point(mirror * x, y); }; + const Polyline original{ + point(671861, 7772276), point(688586, 7765098), point(781082, 7687014), + point(852059, 7608861), point(889773, 7556912), point(958919, 7382963), + point(977048, 7259018), point(977325, 7173839), point(944250, 7039370), + point(911230, 6952087), point(880323, 6894944), point(760243, 6763860), + point(598587, 6641719), point(492626, 6593610), point(362533, 6543938), + point(170173, 6513482), point(114917, 6509380), point(18418, 6513666), + point(-145550, 6537251), point(-259087, 6580797), point(-413987, 6690495), + point(-485220, 6767022), point(-561189, 6893573), point(-576897, 6965717), + point(-595201, 7089303), point(-597977, 7164172), point(-590614, 7239553), + point(-574031, 7305533), point(-539668, 7383293), point(-442552, 7550465), + point(-332173, 7659644), point(-257819, 7717153), point(-209522, 7749563), + point(-121695, 7793438), point(399, 7844160), point(228137, 7880068), + point(363721, 7881585), point(431909, 7865985), point(569648, 7816149), + point(653482, 7780164), + }; + const Polyline inner{ + point(739251, 7241332), point(739377, 7202281), point(716708, 7110113), + point(694416, 7051190), point(685088, 7033943), point(599527, 6940541), + point(476243, 6847393), point(400952, 6813209), point(300851, 6774988), + point(142724, 6749952), point(111379, 6747625), point(40681, 6750765), + point(-85281, 6768883), point(-146010, 6792175), point(-256555, 6870462), + point(-295251, 6912034), point(-336173, 6978125), point(-357996, 7111200), + point(-359693, 7156985), point(-355612, 7198777), point(-348288, 7227916), + point(-327412, 7275156), point(-252784, 7403618), point(-175201, 7480358), + point(-89616, 7543582), point(-22802, 7576960), point(65459, 7613627), + point(248096, 7642423), point(338175, 7643431), point(364673, 7637369), + point(482204, 7594844), point(562221, 7560499), point(615604, 7515433), + point(679764, 7441323), point(727601, 7320981), point(739251, 7241332), + }; + const Point seam_start = original.first_point(); + const Point seam_end = original.last_point(); + const double offset = scale_(0.239999); + Polyline forward = original; + REQUIRE_FALSE(offset_wipe_path(forward, seam_start, seam_end, seam_end, + -mirror, offset, scale_(wipe_length))); + + Polyline path = original; + REQUIRE(offset_wipe_path_toward_support(path, seam_start, seam_end, seam_end, + -mirror, offset, scale_(wipe_length), inner.lines(), inner.lines(), original.lines(), offset)); + REQUIRE(path.first_point() == seam_start); + REQUIRE(path.points.size() > 2); + // Wipe::wipe replaces the sentinel with the actual extrusion endpoint. + path.points.front() = seam_end; + CHECK_THAT(unscale_(path.length()), Catch::Matchers::WithinAbs(wipe_length, 0.0004)); + CHECK(mirror * (path.points[1].x() - seam_end.x()) < 0); + CHECK(path.points[1].y() < seam_end.y()); + Lines support = inner.lines(); + const Lines current = original.lines(); + support.insert(support.end(), current.begin(), current.end()); + REQUIRE(wipe_path_support_score(path, seam_end, LinesDistancer(inner.lines()), + LinesDistancer(support), offset).has_value()); +} + +TEST_CASE("Stored wipe path retains its length after a loop pre-move at a curved seam", "[WipePath][Regression]") +{ + const int mirror = GENERATE(1, -1); + const bool pre_move = GENERATE(false, true); + CAPTURE(mirror, pre_move); + const auto point = [mirror](coord_t x, coord_t y) { return Point(mirror * x, y); }; + // A 0.02 mm seam gap on a curved 0.24 mm wall, with the adjacent inner + // wall already printed. The loop pre-move advances the nozzle near the seam. + const Polyline original{ + point(686772, 7813199), point(516334, 7887188), point(411017, 7915098), + point(346190, 7926015), point(246957, 7925330), point(-16945, 7881611), + point(-116443, 7842513), point(-255907, 7773886), point(-378126, 7681053), + point(-499258, 7552879), point(-574414, 7438250), point(-613558, 7370259), + point(-626313, 7341394), point(-650774, 7263424), point(-669964, 7169919), + point(-666798, 7058662), point(-631336, 6876547), point(-624410, 6852946), + point(-577832, 6762704), point(-517493, 6693143), point(-455794, 6631765), + point(-315549, 6531304), point(-169627, 6468424), point(-1908, 6443726), + point(143562, 6438395), point(314277, 6465470), point(380448, 6480795), + point(519922, 6526617), point(673990, 6611110), point(801581, 6705610), + point(927505, 6840928), point(969616, 6912718), point(1004269, 7009155), + point(1044144, 7171737), point(1046617, 7228598), point(1028598, 7358360), + point(950280, 7560914), point(867133, 7675444), point(732946, 7788889), + point(706168, 7804780), point(705118, 7805235), + }; + const Polyline inner{ + point(808905, 7211140), point(796801, 7298317), point(739603, 7446245), + point(691575, 7512401), point(595745, 7593416), point(438069, 7661865), + point(360694, 7682370), point(327119, 7688024), point(266586, 7687607), + point(53302, 7653657), point(-20276, 7624745), point(-130307, 7570601), + point(-218687, 7503470), point(-311907, 7404832), point(-371722, 7313599), + point(-401113, 7262547), point(-420207, 7203763), point(-431422, 7149118), + point(-429596, 7084952), point(-401187, 6939055), point(-379519, 6897073), + point(-343546, 6855603), point(-301665, 6813940), point(-197879, 6739595), + point(-104131, 6699197), point(19838, 6680942), point(129154, 6676936), + point(268758, 6699077), point(316366, 6710103), point(424812, 6745731), + point(545430, 6811879), point(642396, 6883697), point(735572, 6983824), + point(753259, 7013976), point(776223, 7077887), point(808905, 7211140), + }; + const Point seam_start = original.first_point(); + const Point seam_end = original.last_point(); + const Point wipe_start = pre_move ? point(652751, 7792162) : seam_end; + const double offset = scale_(0.239999); + Polyline path = original; + REQUIRE(offset_wipe_path_toward_support(path, seam_start, seam_end, wipe_start, + mirror, offset, scale_(0.8), inner.lines(), inner.lines(), original.lines(), offset)); + REQUIRE(path.first_point() == seam_start); + path.points.front() = wipe_start; + CHECK_THAT(unscale_(path.length()), Catch::Matchers::WithinAbs(0.8, 0.0004)); + Lines support = inner.lines(); + const Lines current = original.lines(); + support.insert(support.end(), current.begin(), current.end()); + REQUIRE(wipe_path_support_score(path, wipe_start, LinesDistancer(inner.lines()), + LinesDistancer(support), offset).has_value()); +} + +// Orca: helpers for constructing the extrusion geometry used by wipe tests. + +static ExtrusionPath make_path(const std::vector &pts, ExtrusionRole role = erExternalPerimeter, + float width = 0.4f, float height = 0.2f) +{ + ExtrusionPath p(role, 0.5, width, height); + for (const Point &pt : pts) + p.polyline.append(Point3(pt.x(), pt.y(), coord_t(0))); + return p; +} + +static ExtrusionPaths make_paths(const std::vector &pts, ExtrusionRole role = erExternalPerimeter, + float width = 0.4f) +{ + ExtrusionPaths paths; + paths.push_back(make_path(pts, role, width)); + return paths; +} + +TEST_CASE("Inward wipe support recognizes an inner wall starting on an overhang", "[WipePath][Regression]") +{ + const bool overhang_first = GENERATE(false, true); + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + ExtrusionPaths paths{ + make_path({point(0.4, 0.4), point(0.4, 2.)}, erOverhangPerimeter), + make_path({point(0.4, 2.), point(0.4, 9.6), point(5.6, 9.6), point(5.6, 0.4), point(0.4, 0.4)}, erPerimeter) + }; + if (!overhang_first) + std::rotate(paths.begin(), paths.begin() + 1, paths.end()); + const ExtrusionLoop inner(paths); + REQUIRE(inner.role() == (overhang_first ? erOverhangPerimeter : erPerimeter)); + + WipeInwardSupport support; + support.append(inner); + REQUIRE(support.inner_lines.size() == inner.as_polyline().lines().size()); + // The overhanging portion itself is already printed and can support the wipe. + const LinesDistancer inner_distancer(support.inner_lines); + CHECK_THAT(inner_distancer.distance_from_lines(point(0.4, 1.)), + Catch::Matchers::WithinAbs(0., SCALED_EPSILON)); + const Polyline original{point(0., 0.), point(0., 10.), point(6., 10.), point(6., 0.), point(0., 0.)}; + Polyline wipe = original; + REQUIRE(offset_wipe_path_toward_support(wipe, original.first_point(), original.first_point(), + original.first_point(), -1, scale_(0.2), scale_(2.), support.inner_lines, + support.printed_lines, original.lines(), scale_(0.6))); + CHECK(wipe.points[1].x() > original.first_point().x()); +} + +TEST_CASE("Inward wipe support accumulates earlier walls without treating outer walls as targets", "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + WipeInwardSupport support; + const ExtrusionPath inner = make_path({point(0.4, 0.), point(0.4, 5.)}, erPerimeter); + support.append(inner); + const ExtrusionLoop outer(ExtrusionPaths{ + make_path({point(0., 0.), point(0., 5.)}, erOverhangPerimeter), + make_path({point(0., 5.), point(-5., 5.), point(-5., 0.), point(0., 0.)}) + }); + support.append(outer); + REQUIRE(support.inner_lines.size() == 1); + REQUIRE(support.printed_lines.size() == 5); + const LinesDistancer targets(support.inner_lines); + CHECK_THAT(targets.distance_from_lines(point(0., 2.)), + Catch::Matchers::WithinAbs(scale_(0.4), SCALED_EPSILON)); +} + +static ExtrusionPaths make_loop_paths(const std::vector &contour_pts, float width = 0.4f) +{ + ExtrusionPaths paths; + size_t mid = contour_pts.size() / 2; + ExtrusionPath first(erExternalPerimeter, 0.5, width, 0.2f); + for (size_t i = 0; i <= mid; ++i) + first.polyline.append(Point3(contour_pts[i].x(), contour_pts[i].y(), coord_t(0))); + ExtrusionPath second(erExternalPerimeter, 0.5, width, 0.2f); + for (size_t i = mid; i < contour_pts.size(); ++i) + second.polyline.append(Point3(contour_pts[i].x(), contour_pts[i].y(), coord_t(0))); + second.polyline.append(Point3(contour_pts[0].x(), contour_pts[0].y(), coord_t(0))); + paths.push_back(std::move(first)); + paths.push_back(std::move(second)); + return paths; +} + +// Orca: sample_path_at_distance coverage. + +TEST_CASE("sample_path_at_distance forward returns start for zero target", "[WipePath]") +{ + const coord_t s = scale_(1.0); + auto paths = make_paths({Point(0, 0), Point(100 * s, 0), Point(100 * s, 100 * s)}); + REQUIRE(sample_path_at_distance(paths, true, 0.0) == Point(0, 0)); +} + +TEST_CASE("sample_path_at_distance forward samples along path", "[WipePath]") +{ + const coord_t s = scale_(1.0); + auto paths = make_paths({Point(0, 0), Point(100 * s, 0), Point(100 * s, 100 * s)}); + + Point result = sample_path_at_distance(paths, true, 50 * s); + REQUIRE_THAT(result.x(), Catch::Matchers::WithinAbs(50 * s, 2)); + REQUIRE_THAT(result.y(), Catch::Matchers::WithinAbs(0, 2)); +} + +TEST_CASE("sample_path_at_distance forward crosses segment boundary", "[WipePath]") +{ + const coord_t s = scale_(1.0); + auto paths = make_paths({Point(0, 0), Point(100 * s, 0), Point(100 * s, 100 * s)}); + + Point result = sample_path_at_distance(paths, true, 150 * s); + REQUIRE_THAT(result.x(), Catch::Matchers::WithinAbs(100 * s, 2)); + REQUIRE_THAT(result.y(), Catch::Matchers::WithinAbs(50 * s, 2)); +} + +TEST_CASE("sample_path_at_distance backward from end", "[WipePath]") +{ + const coord_t s = scale_(1.0); + auto paths = make_paths({Point(0, 0), Point(100 * s, 0), Point(100 * s, 100 * s)}); + + Point result = sample_path_at_distance(paths, false, 50 * s); + REQUIRE_THAT(result.x(), Catch::Matchers::WithinAbs(100 * s, 2)); + REQUIRE_THAT(result.y(), Catch::Matchers::WithinAbs(50 * s, 2)); +} + +TEST_CASE("sample_path_at_distance on short path returns reachable point", "[WipePath]") +{ + const coord_t s = scale_(1.0); + auto paths = make_paths({Point(0, 0), Point(10 * s, 0)}); + + Point result = sample_path_at_distance(paths, true, 1000 * s); + REQUIRE(result == Point(10 * s, 0)); +} + +TEST_CASE("sample_path_at_distance on zero-length path returns start", "[WipePath]") +{ + const coord_t s = scale_(1.0); + auto paths = make_paths({Point(50 * s, 50 * s)}); + + REQUIRE(sample_path_at_distance(paths, true, 100 * s) == Point(50 * s, 50 * s)); + REQUIRE(sample_path_at_distance(paths, false, 100 * s) == Point(50 * s, 50 * s)); +} + +TEST_CASE("Wipe offset direction follows the material side", "[WipePath]") +{ + REQUIRE(wipe_offset_direction(true, false) == +1); + REQUIRE(wipe_offset_direction(false, false) == -1); + REQUIRE(wipe_offset_direction(true, true) == -1); + REQUIRE(wipe_offset_direction(false, true) == +1); +} + +TEST_CASE("Stored wipe path leaves source crossings to support validation", "[WipePath]") +{ + const coord_t s = scale_(1.0); + Polyline path{Point(10 * s, 0), Point(100 * s, 0), Point(coord_t(13.4 * s), coord_t(50 * s))}; + + // Orca: crossing the just-printed wall is harmless for a non-extruding wipe. + // The caller decides whether the result is supported by printed geometry. + REQUIRE(offset_wipe_path(path, Point(10 * s, 0), Point(0, 0), Point(0, 0), +1, 5 * s, 1000 * s)); +} + +TEST_CASE("Stored wipe path builds the join after a nonzero seam gap", "[WipePath]") +{ + const coord_t s = scale_(1.0); + Polyline path{Point(10 * s, 0), Point(10 * s, 0), Point(10 * s, 100 * s)}; + + REQUIRE(offset_wipe_path(path, Point(10 * s, 0), Point(0, 0), Point(0, 0), +1, 5 * s, 1000 * s)); + REQUIRE(path.points.size() == 3); + REQUIRE(path.points[1] == Point(5 * s, 5 * s)); + REQUIRE(path.points[2] == Point(5 * s, 100 * s)); + REQUIRE(path.fitting_result.size() == 1); + REQUIRE(path.fitting_result.front().end_point_index == path.points.size() - 1); +} + +TEST_CASE("Stored wipe path rejects an offset seam join that turns backward", "[WipePath][Regression]") +{ + const coord_t s = scale_(1.0); + const Point seam_start(s, s); + const Point seam_end(0, 0); + Polyline path{seam_start, Point(s, -10 * s), Point(s, -20 * s)}; + const Polyline original = path; + + REQUIRE_FALSE(offset_wipe_path(path, seam_start, seam_end, seam_end, +1, s, 5 * s)); + REQUIRE(path.points == original.points); +} + +TEST_CASE("Stored wipe path continues after an inward pre-move", "[WipePath][Regression]") +{ + const coord_t s = scale_(1.0); + const Point seam_start(s, s); + const Point seam_end(0, 0); + const Point wipe_start(2 * s, 2 * s); + Polyline path{seam_start, Point(s, -10 * s), Point(s, -20 * s)}; + + REQUIRE(offset_wipe_path(path, seam_start, seam_end, wipe_start, +1, s, 5 * s)); + REQUIRE(path.points.size() >= 3); + path.points.front() = wipe_start; // Orca: reproduce Wipe::wipe()'s executable representation. + CHECK_THAT(path.length(), Catch::Matchers::WithinAbs(5. * s, 2.)); +} + +TEST_CASE("Stored wipe path does not retrace a translated seam gap", "[WipePath][Regression]") +{ + const coord_t s = scale_(1.0); + const Point seam_start(s, 0); + const Point seam_end(0, 0); + Polyline path{seam_start, seam_end, Point(-10 * s, 0)}; + const Polyline original = path; + const Lines support{Line(Point(-10 * s, s), Point(10 * s, s))}; + + // Orca: the exact reversal at seam_start forces the translated fallback. + // The seam gap supplies its incoming direction but must not become an + // inward-outward-inward detour in the executable path. + REQUIRE(offset_wipe_path_toward_support( + path, seam_start, seam_end, seam_end, +1, s, 5 * s, + support, support, original.lines(), s)); + REQUIRE(path.points.size() == 2); + CHECK(path.points[1].y() > seam_end.y()); +} + +TEST_CASE("Stored wipe path keeps its first offset point when seam gap is zero", "[WipePath]") +{ + const coord_t s = scale_(1.0); + Polyline path{Point(0, 0), Point(100 * s, 0), Point(100 * s, 100 * s), + Point(0, 100 * s), Point(0, 0)}; + + REQUIRE(offset_wipe_path(path, Point(0, 0), Point(0, 0), Point(0, 0), +1, 5 * s, 20 * s)); + REQUIRE(path.points.size() >= 3); + REQUIRE(path.points[0] == Point(0, 0)); + REQUIRE_THAT(path.points[1].x(), Catch::Matchers::WithinAbs(5 * s, 2)); + REQUIRE_THAT(path.points[1].y(), Catch::Matchers::WithinAbs(5 * s, 2)); +} + +TEST_CASE("Stored wipe path ignores unsafe geometry beyond the used prefix", "[WipePath]") +{ + const coord_t s = scale_(1.0); + Polyline path{Point(0, 0), Point(1000 * s, 0), Point(1000 * s, 20 * s), + Point(900 * s, 20 * s), Point(0, 20 * s), Point(0, 0)}; + + REQUIRE(offset_wipe_path(path, Point(0, 0), Point(0, 0), Point(0, 0), +1, 30 * s, 10 * s)); + REQUIRE(path.points.size() == 2); + REQUIRE_THAT(path.length(), Catch::Matchers::WithinAbs(10 * s, 2)); +} + +TEST_CASE("Stored wipe path grows its source until the offset reaches the requested length", "[WipePath]") +{ + const coord_t s = scale_(1.0); + Polyline path{Point(0, 0), Point(100 * s, 0), Point(100 * s, 100 * s), Point(0, 100 * s)}; + const double wipe_length = 250 * s; + + // Orca: two inward corners shorten this offset by more than 2 * offset_dist. + REQUIRE(offset_wipe_path(path, Point(0, 0), Point(0, 0), Point(0, 0), + +1, 10 * s, wipe_length)); + REQUIRE_THAT(path.length(), Catch::Matchers::WithinAbs(wipe_length, 2)); +} + +TEST_CASE("Stored wipe path is unchanged when wipe distance is zero", "[WipePath]") +{ + const coord_t s = scale_(1.0); + Polyline path{Point(0, 0), Point(100 * s, 0)}; + const Polyline orig = path; + + REQUIRE_FALSE(offset_wipe_path(path, Point(0, 0), Point(0, 0), Point(0, 0), +1, 5 * s, 0)); + REQUIRE(path.points == orig.points); +} + +TEST_CASE("Stored wipe path defers actual-start crossings to support validation", "[WipePath]") +{ + const coord_t s = scale_(1.0); + Polyline path{Point(0, 0), Point(100 * s, 0), Point(100 * s, 100 * s), + Point(0, 100 * s), Point(0, 0)}; + const Lines current = path.lines(); + const Lines remote{Line(Point(0, 50 * s), Point(100 * s, 50 * s))}; + const Point wipe_start(50 * s, -10 * s); + + REQUIRE(offset_wipe_path(path, Point(0, 0), Point(0, 0), wipe_start, +1, 5 * s, 100 * s)); + Lines all_support = remote; + all_support.insert(all_support.end(), current.begin(), current.end()); + REQUIRE_FALSE(wipe_path_support_score(path, wipe_start, + LinesDistancer(remote), LinesDistancer(all_support), 5 * s).has_value()); +} + +TEST_CASE("Stored wipe path keeps the closing join when its prefix ends at the closing vertex", "[WipePath]") +{ + const coord_t s = scale_(1.0); + Polyline path{Point(0, 0), Point(0, 100 * s), Point(100 * s, 100 * s), + Point(100 * s, 0), Point(0, 0)}; + + REQUIRE(offset_wipe_path(path, Point(0, 0), Point(0, 0), Point(0, 0), + +1, 5 * s, 300 * s)); + REQUIRE(path.points.size() >= 2); + REQUIRE(path.points[1] == Point(-5 * s, -5 * s)); +} + +TEST_CASE("Stored wipe path rejects a two-point zero-gap loop", "[WipePath]") +{ + const coord_t s = scale_(1.0); + Polyline path{Point(0, 0), Point(100 * s, 0), Point(0, 0)}; + const Polyline original = path; + + REQUIRE_FALSE(offset_wipe_path(path, Point(0, 0), Point(0, 0), Point(0, 0), + +1, 5 * s, 100 * s)); + REQUIRE(path.points == original.points); +} + +TEST_CASE("Stored wipe path tolerates quantized contact at its actual start", "[WipePath]") +{ + const coord_t s = scale_(1.0); + const coord_t quantization = coord_t(SCALED_EPSILON / 2); + Polyline path{Point(0, quantization), Point(100 * s, quantization), + Point(100 * s, 100 * s + quantization), Point(0, 100 * s + quantization), + Point(0, quantization)}; + + // Orca: the executable transition starts within the geometry epsilon of the + // source endpoint. Treat this as the allowed start contact, while contacts + // farther along the transition remain unsafe. + REQUIRE(offset_wipe_path(path, Point(0, quantization), Point(0, quantization), + Point(0, 0), +1, 5 * s, 20 * s)); +} + +TEST_CASE("Stored wipe path requires nearby generated perimeter geometry", "[WipePath]") +{ + const coord_t s = scale_(1.0); + const Polyline path{Point(0, 0), Point(0, 2 * s), Point(10 * s, 2 * s)}; + const Lines adjacent{Line(Point(0, 4 * s), Point(10 * s, 4 * s))}; + const Lines remote{Line(Point(0, 20 * s), Point(10 * s, 20 * s))}; + const Lines current = path.lines(); + + const LinesDistancer adjacent_distancer(adjacent); + const LinesDistancer remote_distancer(remote); + Lines all_support = remote; + all_support.insert(all_support.end(), current.begin(), current.end()); + const LinesDistancer all_support_distancer(all_support); + + const auto score = wipe_path_support_score(path, Point(0, 2 * s), adjacent_distancer, adjacent_distancer, 3 * s); + REQUIRE(score.has_value()); + CHECK_THAT(*score, Catch::Matchers::WithinAbs(2. * s, 2.)); + REQUIRE_FALSE(wipe_path_support_score(path, Point(0, 2 * s), adjacent_distancer, adjacent_distancer, 0).has_value()); + REQUIRE_FALSE(wipe_path_support_score(path, Point(0, 2 * s), remote_distancer, remote_distancer, 3 * s).has_value()); + REQUIRE_FALSE(wipe_path_support_score(path, Point(0, 2 * s), remote_distancer, all_support_distancer, 3 * s).has_value()); + REQUIRE_FALSE(wipe_path_support_score(path, Point(0, 2 * s), LinesDistancer(Lines{}), + all_support_distancer, 3 * s).has_value()); +} + +TEST_CASE("Stored wipe path checks the first segment from its actual start", "[WipePath]") +{ + const coord_t s = scale_(1.0); + const Polyline path{Point(0, 0), Point(10 * s, 0)}; + const Lines support_near_ends{ + Line(Point(0, -s), Point(0, s)), + Line(Point(10 * s, -s), Point(10 * s, s)) + }; + + // Orca: both endpoints are supported, but the middle of the executable segment + // from wipe_start is not. The dummy path[0] must not hide that segment. + const LinesDistancer support_distancer(support_near_ends); + REQUIRE_FALSE(wipe_path_support_score(path, Point(0, 0), support_distancer, support_distancer, 2 * s).has_value()); +} + +TEST_CASE("Stored wipe path rejects unsupported gaps between nearby samples", "[WipePath][Regression]") +{ + const Point start = Point::new_scale(0., 0.); + const Point end = Point::new_scale(0.8, 0.); + const Polyline path{start, end}; + const double support_y = GENERATE(0.8, 0.95); + const Lines support{ + Line(Point::new_scale(0., support_y), Point::new_scale(0., 2.)), + Line(Point::new_scale(0.8, support_y), Point::new_scale(0.8, 2.)) + }; + + // Both endpoints are within 1 mm of support and the move is shorter than + // the old sampling interval. Only the 0.8 mm case supports its midpoint. + const LinesDistancer support_distancer(support); + const bool supported = wipe_path_support_score(path, start, support_distancer, support_distancer, scale_(1.)).has_value(); + CHECK(supported == (support_y < 0.9)); +} + +TEST_CASE("Stored wipe path checks support at the actual nozzle position", "[WipePath][Regression]") +{ + const Point end = Point::new_scale(0., 0.); + const Polyline path{end, end}; + const Lines support{Line(Point::new_scale(-1., 0.), Point::new_scale(1., 0.))}; + + const LinesDistancer support_distancer(support); + REQUIRE_FALSE(wipe_path_support_score(path, Point::new_scale(0., -2.), + support_distancer, support_distancer, scale_(1.)).has_value()); +} + +TEST_CASE("Direct inward fallback respects a short wipe distance before validation", "[WipePath][Regression]") +{ + const Point seam = Point::new_scale(0., 0.); + Polyline path{seam, Point::new_scale(10., 0.), Point::new_scale(10., 10.), + Point::new_scale(0., 10.), seam}; + const Lines current = path.lines(); + const Lines support{Line(Point::new_scale(0.4, 0.4), Point::new_scale(9.6, 0.4))}; + const bool pre_move = GENERATE(false, true); + const Point wipe_start = pre_move ? Point::new_scale(0.02, 0.02) : seam; + const double wipe_length = scale_(0.05); + + REQUIRE(offset_wipe_path_toward_support( + path, seam, seam, wipe_start, +1, scale_(0.2), wipe_length, + support, support, current, scale_(0.4))); + REQUIRE(path.points.size() == 2); + path.points.front() = wipe_start; + CHECK_THAT(path.length(), Catch::Matchers::WithinAbs(wipe_length, 2.)); + CHECK(path.last_point().x() > wipe_start.x()); + CHECK(path.last_point().y() > wipe_start.y()); +} + +TEST_CASE("Stored wipe path uses a stable zero-gap join for nearly parallel segments", "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam = point(58.777, 61.985); + Polyline path{ + seam, point(58.822, 61.918), point(58.900, 61.789), point(58.980, 61.641), + point(59.054, 61.480), point(59.260, 60.980), point(58.412, 62.485), + point(58.631, 62.202), seam, + }; + + REQUIRE(offset_wipe_path(path, seam, seam, seam, -1, scale_(0.23), scale_(0.8))); + REQUIRE(path.points.size() >= 3); + + const Vec2d first = (path.points[1] - seam).cast(); + const Vec2d second = (path.points[2] - path.points[1]).cast(); + CHECK(first.dot(second) >= 0.); +} + +TEST_CASE("Stored wipe path follows the inner wall at a narrow external cusp", "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam = point(55.139, 60.077); + Polyline path{ + seam, point(55.156, 60.010), point(55.205, 59.961), point(55.237, 59.934), + point(55.304, 59.907), point(55.392, 59.872), point(55.630, 59.791), + point(56.564, 59.430), point(55.061, 59.956), point(55.108, 60.008), seam, + }; + const Polyline original = path; + const Lines target_support{ + Line(point(54.983, 59.648), point(55.121, 59.745)), + }; + Lines printed_support = target_support; + printed_support.emplace_back(point(54.75, 60.25), point(55.50, 60.10)); + + REQUIRE(offset_wipe_path_toward_support( + path, seam, seam, seam, -1, scale_(0.270341), scale_(0.8), + target_support, printed_support, original.lines(), scale_(0.4))); + REQUIRE(path.points.size() >= 2); + CHECK(path.points[1].y() < seam.y() - scale_(0.2)); + CHECK(std::abs(path.points[1].x() - seam.x()) < scale_(0.1)); +} + +TEST_CASE("Stored wipe path keeps a supported zero-gap join that initially backtracks", "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam = point(56.737, 62.049); + Polyline path{ + seam, point(56.759, 62.142), point(56.727, 62.294), point(56.682, 62.447), + point(56.631, 62.570), point(56.581, 62.669), point(56.512, 62.776), + point(54.0, 64.0), point(50.0, 60.0), point(54.0, 58.0), + point(56.773, 62.031), seam, + }; + const Polyline original = path; + const Lines target_support{ + Line(point(56.546, 62.012), point(56.534, 62.104)), + Line(point(56.534, 62.104), point(56.506, 62.238)), + Line(point(56.506, 62.238), point(56.467, 62.371)), + Line(point(56.467, 62.371), point(56.424, 62.474)), + Line(point(56.424, 62.474), point(56.382, 62.556)), + }; + + Polyline inward = path; + REQUIRE(offset_wipe_path(inward, seam, seam, seam, +1, scale_(0.23), scale_(0.8))); + REQUIRE(inward.points.size() >= 3); + const Vec2d connector = (inward.points[1] - seam).cast(); + const Vec2d outgoing = (inward.points[2] - inward.points[1]).cast(); + REQUIRE(connector.dot(outgoing) < 0.); + + REQUIRE(offset_wipe_path_toward_support( + path, seam, seam, seam, +1, scale_(0.23), scale_(0.8), + target_support, target_support, original.lines(), scale_(0.4))); + CHECK(path.points[1].x() < seam.x() - scale_(0.1)); +} + +TEST_CASE("Stored wipe path leaves a narrow cusp directly after a seam gap", "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam_start = point(55.139, 60.077); + const Point seam_end = point(55.141, 60.067); + Polyline path{ + seam_start, point(55.107, 60.008), point(55.061, 59.956), point(55.027, 59.943), + point(54.982, 59.924), point(54.922, 59.879), point(54.868, 59.845), + point(54.754, 59.783), point(54.391, 59.635), point(54.053, 59.471), + }; + const Polyline original = path; + const Lines target_support{ + Line(point(55.132, 59.744), point(55.121, 59.745)), + Line(point(55.121, 59.745), point(54.983, 59.648)), + Line(point(54.983, 59.648), point(54.938, 59.623)), + Line(point(54.938, 59.623), point(54.866, 59.584)), + Line(point(54.866, 59.584), point(54.483, 59.427)), + Line(point(54.483, 59.427), point(54.157, 59.268)), + }; + + REQUIRE(offset_wipe_path_toward_support( + path, seam_start, seam_end, seam_end, -1, scale_(0.270341), scale_(0.8), + target_support, target_support, original.lines(), scale_(0.4))); + REQUIRE(path.points.size() >= 2); + CHECK(path.points[1].y() < seam_end.y() - scale_(0.2)); + CHECK(std::abs(path.points[1].x() - seam_end.x()) < scale_(0.05)); + + // Orca: the inward connector must not run back through the first extruded + // point after the gap, which would put the wipe on the external wall. + const Line connector(seam_end, path.points[1]); + CHECK(connector.distance_to(original.points[1]) > scale_(0.02)); +} + +TEST_CASE("Stored wipe path does not reverse after an inward pre-move at a wide gap", "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam_start = point(55.139, 60.077); + const Point seam_end = point(55.163, 60.002); + const Point wipe_start = point(55.142, 60.037); + Polyline path{ + seam_start, point(55.107, 60.008), point(55.061, 59.956), point(55.027, 59.943), + point(54.982, 59.924), point(54.922, 59.879), point(54.868, 59.845), + point(54.754, 59.783), point(54.391, 59.635), point(54.053, 59.471), + point(50.2, 55.0), point(50.2, 50.0), point(60.8, 50.0), point(60.8, 55.0), + point(56.564, 59.430), point(55.824, 59.708), point(55.392, 59.872), + point(55.237, 59.934), point(55.205, 59.961), seam_end, + }; + const Polyline original = path; + const Lines target_support{ + Line(point(55.132, 59.744), point(55.121, 59.745)), + Line(point(55.121, 59.745), point(54.983, 59.648)), + Line(point(54.983, 59.648), point(54.866, 59.584)), + Line(point(54.866, 59.584), point(54.483, 59.427)), + Line(point(54.483, 59.427), point(54.157, 59.268)), + }; + + REQUIRE(offset_wipe_path_toward_support( + path, seam_start, seam_end, wipe_start, -1, scale_(0.270341), scale_(0.8), + target_support, target_support, original.lines(), scale_(0.4))); + REQUIRE(path.points.size() >= 3); + + const Vec2d connector = (path.points[1] - wipe_start).cast(); + const Vec2d outgoing = (path.points[2] - path.points[1]).cast(); + CHECK(connector.dot(outgoing) >= 0.); + path.points.front() = wipe_start; + CHECK_THAT(path.length(), Catch::Matchers::WithinAbs(scale_(0.8), 2.)); +} + +TEST_CASE("Stored wipe path follows the incoming wall when a corner gap truncates the forward path", + "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam_start = point(46.047, 61.988); + const Point seam_end = point(46.118, 61.917); + Polyline path{ + seam_start, point(39.139, 55.080), point(46.047, 48.171), + point(52.956, 55.080), seam_end, + }; + const Polyline original = path; + const Lines target_support{ + Line(point(46.047, 61.672), point(39.461, 55.080)), + Line(point(39.461, 55.080), point(46.047, 48.493)), + Line(point(46.047, 48.493), point(52.633, 55.080)), + Line(point(52.633, 55.080), point(46.047, 61.672)), + }; + + REQUIRE(offset_wipe_path_toward_support( + path, seam_start, seam_end, seam_end, +1, scale_(0.23), scale_(0.8), + target_support, target_support, original.lines(), scale_(0.4))); + path.points.front() = seam_end; + CHECK_THAT(path.length(), Catch::Matchers::WithinAbs(scale_(0.8), 2.)); + REQUIRE(path.points.size() >= 3); + CHECK(path.points[1].x() < seam_end.x()); + CHECK(path.points[1].y() < seam_end.y()); +} + +TEST_CASE("Stored wipe path prefers support on the material side of a seam gap", "[WipePath][Regression]") +{ + const coord_t s = scale_(1.0); + const Point seam_start(s, 0); + const Point seam_end(0, 0); + Polyline path{seam_start, Point(s, 10 * s), Point(s, 20 * s)}; + const Polyline original = path; + const Lines target_support{ + Line(Point(0, s), Point(0, 3 * s)), + Line(Point(s / 2, -s / 10), Point(3 * s / 2, -s / 10)), + }; + + // Orca: the lower line is closest at the cusp and the preferred winding + // points toward it, but the outgoing wall is adjacent to the upper line. + REQUIRE(offset_wipe_path_toward_support( + path, seam_start, seam_end, seam_end, -1, s, 5 * s, + target_support, target_support, original.lines(), 2 * s)); + CHECK(path.points[1].y() > seam_end.y()); +} + +TEST_CASE("Stored wipe path rejects an outward offset at a reflex seam gap", "[WipePath][Regression]") +{ + const double offset = GENERATE(0.2, 0.4); // 50% and 100% of a 0.4 mm wall. + const double mirror = GENERATE(1., -1.); + CAPTURE(offset, mirror); + const auto point = [mirror](double x, double y) { return Point::new_scale(mirror * x, y); }; + const Point seam_start = point(0., 0.); + const double gap_component = 0.04 / std::sqrt(2.); // Default 10% seam gap for a 0.4 mm nozzle. + const Point seam_end = point(-gap_component, -gap_component); + const Polyline original{seam_start, point(0., -10.)}; + const Lines support{Line(point(0.4, -10.), point(0.4, 1.))}; + const int preferred_dir = mirror > 0. ? +1 : -1; + + // The inward miter backtracks. The opposite offset can still be supported + // by the outer bead, so support alone must not make it an inward candidate. + Polyline outward = original; + REQUIRE(offset_wipe_path(outward, seam_start, seam_end, seam_end, + -preferred_dir, scale_(offset), scale_(2.))); + Lines all_support = support; + const Lines current = original.lines(); + all_support.insert(all_support.end(), current.begin(), current.end()); + REQUIRE(wipe_path_support_score(outward, seam_end, + LinesDistancer(support), LinesDistancer(all_support), scale_(0.4)).has_value()); + REQUIRE(mirror * outward.points[1].x() < 0.); + + Polyline path = original; + if (offset_wipe_path_toward_support(path, seam_start, seam_end, seam_end, + preferred_dir, scale_(offset), scale_(2.), support, support, current, scale_(0.4))) { + REQUIRE(path.points.size() >= 2); + CHECK(mirror * path.points[1].x() > 0.); + } else { + CHECK(path.points == original.points); + } + + // An inward pre-move provides a clear connector to the direct fallback. + // The fix must retain this usable inward path, rather than reject all wipes. + const Point wipe_start = point(0.05, -0.04); + path = original; + REQUIRE(offset_wipe_path_toward_support(path, seam_start, seam_end, wipe_start, + preferred_dir, scale_(offset), scale_(2.), support, support, current, scale_(0.4))); + REQUIRE(path.points.size() == 2); + CHECK(mirror * path.points[1].x() > mirror * wipe_start.x()); +} + +TEST_CASE("Direct inward wipes respect the nozzle position and intervening walls", "[WipePath][Regression]") +{ + const int mirror = GENERATE(1, -1); + const bool crossing_wall = GENERATE(false, true); + CAPTURE(mirror, crossing_wall); + const auto point = [mirror](double x, double y) { return Point::new_scale(mirror * x, y); }; + const Point seam_start = point(0., 0.); + const double gap_component = 0.04 / std::sqrt(2.); + const Point seam_end = point(-gap_component, -gap_component); + const Polyline original{seam_start, point(0., -10.)}; + const Lines support{Line(point(0.4, -10.), point(0.4, 1.))}; + Lines current = original.lines(); + // The direct destination is near x=0.172. A nozzle already farther inward + // must not return toward the wall. An inward connector from x=0.05 must + // still be rejected when another wall lies between it and the destination. + const Point wipe_start = point(crossing_wall ? 0.05 : 0.3, -0.04); + if (crossing_wall) + current.emplace_back(point(0.1, -0.2), point(0.1, 0.2)); + Polyline path = original; + REQUIRE_FALSE(offset_wipe_path_toward_support(path, seam_start, seam_end, wipe_start, + mirror, scale_(0.2), scale_(2.), support, support, current, scale_(0.4))); + CHECK(path.points == original.points); +} + +TEST_CASE("Inward wipe checks the material side after leaving an open wall endpoint", "[WipePath][Regression]") +{ + const bool require_clearance = GENERATE(false, true); + CAPTURE(require_clearance); + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam = point(0., 0.); + const LinesDistancer current(Lines{Line(seam, point(2., 0.))}); + const LinesDistancer support(Lines{Line(point(0., 0.4), point(2., 0.4))}); + Polyline path{seam, point(0.1, 0.2), point(0.5, 0.2), point(-0.2, 0.2)}; + REQUIRE(wipe_path_stays_on_material_side( + path, seam, Vec2d(0., 1.), support, current, scale_(0.2), require_clearance)); + + // Rounding the open endpoint keeps 0.2 mm of unsigned clearance while + // moving to the air side. Checking only the first direction cannot catch it. + path.points.push_back(point(-0.2, -0.2)); + path.points.push_back(point(0.5, -0.2)); + REQUIRE_FALSE(wipe_path_stays_on_material_side( + path, seam, Vec2d(0., 1.), support, current, scale_(0.2), require_clearance)); +} + +TEST_CASE("Direct inward fallbacks check the material side without requiring clearance", "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam = point(0., 0.); + const LinesDistancer current(Lines{Line(point(-2., 0.), point(2., 0.))}); + const LinesDistancer support(Lines{Line(point(-2., 0.4), point(2., 0.4))}); + REQUIRE(wipe_path_stays_on_material_side( + Polyline{seam, point(0., 0.05)}, seam, Vec2d(0., 1.), support, current, scale_(0.2), false)); + + // Even if the construction's initial direction points outward, the nearby + // inner wall still identifies the material side independently of that hint. + REQUIRE_FALSE(wipe_path_stays_on_material_side( + Polyline{seam, point(0., -0.05)}, seam, Vec2d(0., -1.), support, current, scale_(0.2), false)); +} + +TEST_CASE("Stored wipe path may return to the current wall after reaching an earlier wall", "[WipePath]") +{ + const coord_t s = scale_(1.0); + const Polyline path{Point(0, 0), Point(0, 2 * s), Point(10 * s, 0)}; + const Lines earlier{Line(Point(0, 2 * s), Point(10 * s, 2 * s))}; + const Lines current{Line(Point(0, 0), Point(10 * s, 0))}; + + Lines all_support = earlier; + all_support.insert(all_support.end(), current.begin(), current.end()); + REQUIRE(wipe_path_support_score(path, Point(0, 0), + LinesDistancer(earlier), LinesDistancer(all_support), s).has_value()); +} + +TEST_CASE("Stored wipe path tolerates compounded coordinate quantization", "[WipePath]") +{ + const coord_t s = scale_(1.0); + const coord_t rounding = coord_t(3.5 * SCALED_EPSILON); + const Point destination(0, 2 * s + rounding); + const Polyline path{Point(0, 0), destination}; + const Lines earlier{Line(Point(-s, 0), Point(s, 0))}; + + const LinesDistancer support_distancer(earlier); + REQUIRE(wipe_path_support_score(path, destination, support_distancer, support_distancer, 2 * s).has_value()); +} + +TEST_CASE("Stored wipe path stays on the inner side of a short external loop", "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam = point(55.270, 41.666); + Polyline path{ + seam, point(55.241, 41.568), point(55.210, 41.518), point(55.195, 41.506), + point(55.173, 41.496), point(55.141, 41.479), point(55.126, 41.473), + point(55.068, 41.421), point(55.055, 41.416), point(55.006, 41.382), + point(54.808, 41.231), point(54.687, 41.153), point(54.590, 41.069), + point(54.529, 41.027), point(54.441, 40.949), point(54.299, 40.803), + point(54.219, 40.674), point(54.183, 40.581), point(54.172, 40.511), + point(54.182, 40.450), point(54.225, 40.358), point(54.256, 40.318), + point(54.341, 40.251), point(54.418, 40.211), point(54.499, 40.176), + point(54.675, 40.124), point(54.797, 40.106), point(54.978, 40.092), + point(55.245, 40.093), point(55.443, 40.103), point(55.591, 40.128), + point(55.771, 40.164), point(55.962, 40.217), point(56.103, 40.264), + point(56.167, 40.295), point(56.246, 40.341), point(56.338, 40.412), + point(56.382, 40.469), point(56.396, 40.527), point(56.386, 40.609), + point(56.313, 40.740), point(56.208, 40.867), point(56.071, 40.991), + point(55.946, 41.094), point(55.812, 41.198), point(55.722, 41.262), + point(55.665, 41.294), point(55.556, 41.398), point(55.520, 41.414), + point(55.495, 41.424), point(55.478, 41.437), point(55.442, 41.469), + point(55.407, 41.505), point(55.386, 41.510), point(55.367, 41.516), + point(55.335, 41.535), point(55.292, 41.575), seam, + }; + const Polyline original = path; + const Polyline inner{ + point(55.111, 41.176), point(54.946, 41.050), point(54.824, 40.970), + point(54.733, 40.892), point(54.668, 40.846), point(54.598, 40.784), + point(54.480, 40.662), point(54.424, 40.572), point(54.403, 40.516), + point(54.420, 40.479), point(54.465, 40.443), point(54.577, 40.390), + point(54.723, 40.347), point(54.823, 40.333), point(54.986, 40.320), + point(55.239, 40.321), point(55.418, 40.330), point(55.550, 40.352), + point(55.718, 40.386), point(55.896, 40.435), point(56.017, 40.476), + point(56.061, 40.497), point(56.118, 40.530), point(56.154, 40.558), + point(56.124, 40.611), point(56.043, 40.709), point(55.921, 40.819), + point(55.804, 40.916), point(55.676, 41.015), point(55.600, 41.069), + point(55.526, 41.114), point(55.426, 41.207), point(55.379, 41.235), + point(55.349, 41.259), point(55.291, 41.317), point(55.252, 41.293), + point(55.192, 41.238), point(55.111, 41.176), + }; + Lines target_support = inner.lines(); + // Orca: a different contour has a slightly closer inner wall on the air + // side of this short loop. It must not override the loop's material side. + target_support.emplace_back(point(55.159, 42.147), point(55.299, 42.011)); + + REQUIRE(offset_wipe_path_toward_support( + path, seam, seam, seam, +1, scale_(0.293166), scale_(0.8), + target_support, target_support, original.lines(), scale_(0.4))); + REQUIRE(path.points.size() >= 2); + + // Orca: the nearest inner wall is below the seam; accepting the opposite + // offset would send the wipe into air outside this small contour. + CHECK(path.points[1].y() < seam.y()); +} + +TEST_CASE("Stored wipe path does not return to the external wall after moving inward", "[WipePath][Regression]") +{ + const auto point = [](double x, double y) { return Point::new_scale(x, y); }; + const Point seam = point(47.451, 54.647); + Polyline path{ + seam, point(47.370, 54.634), point(47.345, 54.619), point(47.333, 54.604), + point(47.322, 54.572), point(47.312, 54.518), point(47.315, 54.445), + point(47.345, 54.257), point(47.357, 54.206), point(47.380, 54.135), + point(47.418, 54.065), point(47.514, 53.917), point(47.537, 53.886), + point(47.597, 53.834), point(47.705, 53.769), point(47.747, 53.748), + point(47.785, 53.734), point(47.825, 53.735), point(47.862, 53.746), + point(47.889, 53.763), point(47.939, 53.817), point(47.964, 53.856), + point(47.979, 53.897), point(47.986, 53.943), point(47.986, 54.005), + point(47.977, 54.075), point(47.949, 54.188), point(47.902, 54.321), + point(47.871, 54.388), point(47.835, 54.444), point(47.765, 54.521), + point(47.741, 54.542), point(47.675, 54.589), point(47.615, 54.620), + point(47.518, 54.642), seam, + }; + const Polyline original = path; + const Polyline inner{ + point(47.541, 54.281), point(47.545, 54.258), point(47.560, 54.212), + point(47.577, 54.181), point(47.682, 54.017), point(47.707, 53.995), + point(47.789, 53.946), point(47.791, 53.958), point(47.791, 53.992), + point(47.785, 54.039), point(47.762, 54.132), point(47.721, 54.249), + point(47.700, 54.294), point(47.680, 54.324), point(47.628, 54.382), + point(47.574, 54.422), point(47.548, 54.435), point(47.513, 54.443), + point(47.541, 54.281), + }; + const double offset = scale_(0.229999); + + REQUIRE(offset_wipe_path_toward_support( + path, seam, seam, seam, +1, offset, scale_(0.8), + inner.lines(), inner.lines(), original.lines(), scale_(0.4))); + + // Orca: after reaching the inner wall, a full-width inward wipe must not + // collapse back onto the external perimeter at a tight turn. + for (size_t index = 1; index < path.points.size(); ++index) { + double clearance = std::numeric_limits::infinity(); + for (const Line &line : original.lines()) + clearance = std::min(clearance, line.distance_to(path.points[index])); + CHECK(clearance >= 0.75 * offset); + } +} + +// Orca: wipe_on_loops_destination coverage for every orientation. + +TEST_CASE("wipe_on_loops destination is on the material side for every orientation", "[WipePath]") +{ + const auto [is_ccw, is_hole] = GENERATE( + table({{true, false}, {false, false}, {false, true}, {true, true}})); + INFO("is_ccw=" << is_ccw << ", is_hole=" << is_hole); + const double nozzle_diameter = GENERATE(0.4, 0.8); + const bool subdivided = GENERATE(false, true); + INFO("nozzle diameter=" << nozzle_diameter << ", subdivided=" << subdivided); + + const coord_t s = scale_(1.0); + std::vector contour = {Point(0, 0), Point(20 * s, 0), Point(20 * s, 20 * s), Point(0, 20 * s)}; + if (subdivided) { + // The same square, with path boundaries inside both sampling distances near the seam. + contour = {Point(0, 0), Point(scale_(0.03), 0.), Point(scale_(0.2), 0.), + Point(20 * s, 0), Point(20 * s, 20 * s), Point(0, 20 * s), + Point(0., scale_(0.2)), Point(0., scale_(0.03))}; + } + if (!is_ccw) + for (Point &point : contour) + std::swap(point.x(), point.y()); + ExtrusionPaths paths; + if (subdivided) { + for (size_t i = 0; i < contour.size(); ++i) + paths.push_back(make_path({contour[i], contour[(i + 1) % contour.size()]})); + } else { + paths = make_loop_paths(contour); + } + + const std::optional destination = + wipe_on_loops_destination(paths, scale_(nozzle_diameter), is_ccw, is_hole); + REQUIRE(destination.has_value()); + + const Point seam_start = paths.front().first_point(); + const Vec2d first_edge = (paths.front().polyline.points[1].to_point() - seam_start).cast(); + Vec2d material_normal(-first_edge.y(), first_edge.x()); + if (is_ccw == is_hole) + material_normal = -material_normal; + + // Orca: contours use their winding's inside; holes use the opposite side. + const Vec2d move = destination->cast() - seam_start.cast(); + REQUIRE(move.dot(material_normal) > 0.); + // Move 20% of the nozzle diameter, turning through one third of the material-side + // corner: 90 degrees for a contour, 270 degrees for a hole. + const double distance = scale_(0.2 * nozzle_diameter); + const double angle = is_hole ? PI / 2. : PI / 6.; + CHECK_THAT(move.dot(first_edge.normalized()), Catch::Matchers::WithinAbs(distance * std::cos(angle), 2.)); + CHECK_THAT(move.dot(material_normal.normalized()), Catch::Matchers::WithinAbs(distance * std::sin(angle), 2.)); +} + +TEST_CASE("wipe_on_loops returns destination for small but nonzero loop", "[WipePath]") +{ + // Orca: a 0.5 mm square is tight for a 0.4 mm nozzle but remains valid. + const coord_t s = scale_(1.0); + auto paths = make_loop_paths({Point(0, 0), Point(s / 2, 0), Point(s / 2, s / 2), Point(0, s / 2)}); + + auto dest = wipe_on_loops_destination(paths, scale_(0.4), true, false); + REQUIRE(dest.has_value()); +} + +TEST_CASE("wipe_on_loops destination is nullopt for degenerate single-point path", "[WipePath]") +{ + const coord_t s = scale_(1.0); + auto paths = make_paths({Point(50 * s, 50 * s)}); + + auto dest = wipe_on_loops_destination(paths, scale_(0.4), true, false); + REQUIRE_FALSE(dest.has_value()); +}