diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 63f74a069e..6019c2bc8b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: 🐞 Bug Report -description: File a bug report +description: Something behaves incorrectly while Orca Slicer keeps running labels: ["bug"] body: - type: markdown @@ -10,6 +10,8 @@ body: Please note that this is not the place to make feature requests or ask for help. For this, please use the [Feature request](https://github.com/OrcaSlicer/OrcaSlicer/issues/new?assignees=&labels=&projects=&template=feature_request.yml) issue type or you can discuss your idea on our [Discord server](https://discord.gg/P4VE9UY9gJ) with others. + If Orca Slicer closes on its own, freezes or stops responding, please use the [Crash report](https://github.com/OrcaSlicer/OrcaSlicer/issues/new?assignees=&labels=&projects=&template=crash_report.yml) form instead. It asks for the logs a crash needs. + Before filing, please check if the issue already exists (either open or closed) by using the search bar on the issues page. If it does, comment there. Even if it's closed, we can reopen it based on your comment. - type: checkboxes attributes: @@ -47,7 +49,7 @@ body: id: os_type attributes: label: "Operating System (OS)" - description: "What OSes are you are experiencing issues on?" + description: "What OSes are you experiencing issues on?" multiple: true options: - Linux @@ -86,7 +88,7 @@ body: id: reproduce_steps attributes: label: How to reproduce - description: Please described the detailed steps to reproduce this issue + description: Please describe the detailed steps to reproduce this issue placeholder: | 1. Go to '...' 2. Click on '...' @@ -108,28 +110,23 @@ body: description: What should happen after the above steps? validations: required: true - - type: markdown - id: file_required - attributes: - value: | - Please be sure to add the following files: - * Please upload a ZIP archive containing the **project file** used when the problem arise. Please export it just before or after the problem occurs. Even if you did nothing and/or there is no object, export it! (We need the configurations in project file). - You can export the project file from the application menu in `File`->`Save project as...`, then zip it - * A **log file** for crashes and similar issues. - You can find your log file here: - Windows: `%APPDATA%\OrcaSlicer\log` or usually `C:\Users\\AppData\Roaming\OrcaSlicer\log` - MacOS: `$HOME/Library/Application Support/OrcaSlicer/log` - Linux: `$HOME/.config/OrcaSlicer/log` - If Orca Slicer still starts, you can also reach this directory from the application menu in `Help` -> `Show Configuration Folder` - You can zip the log directory, or just select the newest logs when this issue happens, and zip them - type: textarea id: file_uploads attributes: label: Project file & Debug log uploads - description: Drop the project file and debug log here + description: | + Attach the files with the **Paste, drop, or click to add files** control directly underneath this box. Zip anything that is not a `.log`, `.txt` or image, since GitHub rejects other file types, and keep each file under 25 MB. + + * The **project file** used when the problem happened, zipped. Export it just before or after the problem occurs. Even if you did nothing and there is no object on the plate, export it, since we need the configuration it carries. `File` -> `Save project as...` + * The **log folder**, zipped. `Help` -> `Show Configuration Folder` opens it, or find it at: + * Windows: `%APPDATA%\OrcaSlicer\log`, usually `C:\Users\\AppData\Roaming\OrcaSlicer\log` + * macOS: `$HOME/Library/Application Support/OrcaSlicer/log` + * Linux: `$HOME/.config/OrcaSlicer/log` + * Flatpak: `$HOME/.var/app/com.orcaslicer.OrcaSlicer/config/OrcaSlicer/log` + * If the zip comes out over 25 MB, attach the newest logs from that folder on their own instead. placeholder: | - Project File: `File` -> `Save project as...` then zip it & drop it here - Log File: `Help` -> `Show Configuration Folder`, then zip the log directory, or just select the newest logs in `log` when this issue happens and zip them, then drop the zip file here + Zipped project file + Zipped log folder validations: required: true - type: checkboxes @@ -144,7 +141,5 @@ body: label: Anything else? description: | Screenshots? References? Anything that will give us more context about the issue you are encountering! - - Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. validations: required: false diff --git a/.github/ISSUE_TEMPLATE/crash_report.yml b/.github/ISSUE_TEMPLATE/crash_report.yml new file mode 100644 index 0000000000..bcbee11d36 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/crash_report.yml @@ -0,0 +1,183 @@ +name: 💥 Crash Report +description: Orca Slicer closes on its own, freezes or stops responding +labels: ["crash"] +body: + - type: markdown + attributes: + value: | + **Thank you for taking the time to report a crash.** + + Use this form when Orca Slicer closes on its own, freezes, or stops responding. + If the application stays open and only produces a wrong result, please use the [Bug report](https://github.com/OrcaSlicer/OrcaSlicer/issues/new?assignees=&labels=&projects=&template=bug_report.yml) form instead. + A printer whose toolhead collides with the print is also a bug report rather than a crash, since the application itself did not stop. + + Before filing, please check if the issue already exists (either open or closed) by using the search bar on the issues page. If it does, comment there. Even if it's closed, we can reopen it based on your comment. + - type: checkboxes + attributes: + label: Is this crash reproducible in the latest nightly build? + description: > + Please verify this crash still happens in the latest nightly build first. It may already be fixed there: + [Nightly builds](https://github.com/OrcaSlicer/OrcaSlicer/releases/tag/nightly-builds). + options: + - label: I have checked the latest nightly build and the crash is still reproducible + required: true + - type: checkboxes + attributes: + label: Is there an existing issue for this crash? + description: Please search to see if an issue already exists for the crash you encountered. + options: + - label: I have searched the existing issues + required: true + - type: input + id: version + attributes: + label: OrcaSlicer Version + description: Which version of Orca Slicer are you running? You can see the full version in `Help` -> `About Orca Slicer`. + placeholder: e.g. 2.5.0 + validations: + required: true + - type: input + id: working_version + attributes: + label: Regression compared to a previous version + description: Did it work in a previous version? + placeholder: e.g. 2.3.2 + validations: + required: false + - type: dropdown + id: os_type + attributes: + label: "Operating System (OS)" + description: "What OSes are you seeing the crash on?" + multiple: true + options: + - Linux + - macOS + - Windows + validations: + required: true + - type: input + id: os_version + attributes: + label: "OS Version" + description: "What OS version does this relate to?" + placeholder: "i.e. OS: Windows 7/8/10/11 ..., Ubuntu 22.04/Fedora 36 ..., macOS 10.15/11.1/12.3 ..." + validations: + required: true + - type: input + id: printer + attributes: + label: Printer + description: Which printer was selected + placeholder: Voron 2.4/VzBot/Prusa MK4/Bambu Lab X1 series/Bambu Lab P1P/... + validations: + required: true + - type: dropdown + id: crash_moment + attributes: + label: When does the crash happen? + description: Pick the point where Orca Slicer stops working. + options: + - Not sure + - On startup, before the main window appears + - When opening or importing a project or model + - While changing printer, filament or process settings + - While slicing + - In the 3D view, Preview or Assembly view + - When exporting G-code or sending a print to the printer + - On the Device tab, or connecting to a printer (camera, sync, login) + - While using a specific tool, dialog or calibration + - After resuming from sleep or changing monitors + - When closing the application + - No clear pattern + validations: + required: true + - type: dropdown + id: crash_frequency + attributes: + label: How often does it happen? + options: + - Not sure + - Every time + - Often, but not every time + - Rarely + - It only happened once + validations: + required: true + - type: dropdown + id: fresh_config + attributes: + label: Does it still crash with a fresh configuration? + description: > + Close Orca Slicer and rename your configuration folder (`%APPDATA%\OrcaSlicer` on Windows, + `$HOME/Library/Application Support/OrcaSlicer` on macOS, `$HOME/.config/OrcaSlicer` on Linux), + then start it again. Renaming keeps your settings, so you can put the folder back afterwards. + options: + - I have not tried this + - Yes, it still crashes + - No, the crash goes away + validations: + required: true + - type: textarea + id: reproduce_steps + attributes: + label: How to reproduce + description: Please describe the detailed steps that lead to the crash. + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. Scroll down to '...' + 4. Orca Slicer closes + validations: + required: true + - type: textarea + id: system_info + attributes: + label: Additional system information + description: > + Display card and driver version are worth adding for crashes on startup or in the 3D view. + CPU and memory are worth adding for crashes while slicing. + placeholder: | + CPU: 11th gen Intel r core tm i7-1185g7/AMD Ryzen 7 6800h/... + Memory: 32/16 GB... + Display Card: NVIDIA Quadro P400/... + validations: + required: false + - type: textarea + id: file_uploads + attributes: + label: Project file, logs and crash report uploads + description: | + A crash report without logs usually cannot be acted on. Attach the files with the **Paste, drop, or click to add files** control directly underneath this box. Zip anything that is not a `.log`, `.txt` or image, since GitHub rejects other file types, and keep each file under 25 MB. + + * The **project file** used when the crash happened, zipped. Export it just before or after the crash, even if the plate is empty, since we need the configuration it carries. `File` -> `Save project as...` + * The whole **log folder**, zipped rather than single files picked out of it. `Help` -> `Show Configuration Folder` opens it, or find it at: + * Windows: `%APPDATA%\OrcaSlicer\log`, usually `C:\Users\\AppData\Roaming\OrcaSlicer\log` + * macOS: `$HOME/Library/Application Support/OrcaSlicer/log` + * Linux: `$HOME/.config/OrcaSlicer/log` + * Flatpak: `$HOME/.var/app/com.orcaslicer.OrcaSlicer/config/OrcaSlicer/log` + * On Windows the crash itself is written to a separate `crash_*.log` in there, and that is the file we need most. If the zip comes out over 25 MB GitHub will refuse it, so attach the newest log and any `crash_*.log` on their own instead. + * The **operating system crash report**, on macOS and Linux, where Orca Slicer cannot write its own crash log. It is often the only record of where it died: + * macOS: Console.app -> Crash Reports, or `$HOME/Library/Logs/DiagnosticReports/`. The file starts with `OrcaSlicer` and ends in `.ips`. Zip it before attaching, GitHub does not accept `.ips` files. + * Linux: run `orca-slicer` from a terminal (Flatpak: `flatpak run com.orcaslicer.OrcaSlicer`) and paste everything it prints when it dies. On systemd systems `coredumpctl info orca-slicer` gives a backtrace. + placeholder: | + Zipped project file + Zipped log folder + Zipped macOS .ips crash report, or the terminal output on Linux + validations: + required: true + - type: checkboxes + id: file_checklist + attributes: + label: Checklist of files to include + options: + - label: Log folder + - label: Project file + - label: Operating system crash report (macOS and Linux) + - type: textarea + attributes: + label: Anything else? + description: | + Screenshots? References? Anything that will give us more context about the crash you are encountering! + validations: + required: false diff --git a/.github/workflows/build_all.yml b/.github/workflows/build_all.yml index 0ab9cfe41d..570d3203ed 100644 --- a/.github/workflows/build_all.yml +++ b/.github/workflows/build_all.yml @@ -283,21 +283,41 @@ jobs: id: fp_cache_key run: echo "key=flatpak-builder-${{ matrix.variant.arch }}-${{ hashFiles('deps/**', 'scripts/flatpak/com.orcaslicer.OrcaSlicer.yml', 'scripts/flatpak/make_deps_tar.sh') }}" >> "$GITHUB_OUTPUT" shell: bash - # Manage flatpak-builder cache externally so PRs restore but never upload + # Manage flatpak-builder cache externally so PRs restore but never upload. + # The compiler cache under it is keyed per run below, so it is left out. - name: Restore flatpak-builder cache if: github.event_name == 'pull_request' uses: actions/cache/restore@v6 with: - path: .flatpak-builder + path: | + .flatpak-builder/* + !.flatpak-builder/ccache key: ${{ steps.fp_cache_key.outputs.key }} restore-keys: flatpak-builder-${{ matrix.variant.arch }}- - name: Save/restore flatpak-builder cache if: github.event_name != 'pull_request' uses: actions/cache@v6 with: - path: .flatpak-builder + path: | + .flatpak-builder/* + !.flatpak-builder/ccache key: ${{ steps.fp_cache_key.outputs.key }} restore-keys: flatpak-builder-${{ matrix.variant.arch }}- + # Compiler cache for the OrcaSlicer module, as in build_orca.yml. Pull + # requests only restore it; every other run (main, release branches, the + # nightly, a dispatch) saves it. orca_deps stays on the state cache above. + - name: Name the compiler cache leg + run: | + leg="Flatpak-${{ matrix.variant.arch }}" + echo "CCACHE_LEG=$leg" >> "$GITHUB_ENV" + echo "CCACHE_ENTRY=ccache-$leg-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_ENV" + shell: bash + - name: Restore compiler cache + uses: actions/cache/restore@v6 + with: + path: .flatpak-builder/ccache + key: ${{ env.CCACHE_ENTRY }} + restore-keys: ccache-${{ env.CCACHE_LEG }}- - name: Disable debug info for faster CI builds run: | sed -i '/^build-options:/a\ no-debuginfo: true\n strip: true' \ @@ -308,6 +328,33 @@ jobs: sed -i "/name: OrcaSlicer/{n;s|buildsystem: simple|buildsystem: simple\n build-options:\n env:\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 + # with clang, so CMake's launcher runs ccache instead; --ccache is still what + # mounts the cache directory into the sandbox. The settings go into that + # directory's own config file, which the sandbox reads too. + - name: Enable compiler cache + run: | + printf ' %s\n' \ + 'CMAKE_C_COMPILER_LAUNCHER: ccache' \ + 'CMAKE_CXX_COMPILER_LAUNCHER: ccache' > "$RUNNER_TEMP/ccache-env.yml" + sed -i "/^ git_commit_hash: /r $RUNNER_TEMP/ccache-env.yml" \ + scripts/flatpak/com.orcaslicer.OrcaSlicer.yml + grep -q '^ CMAKE_CXX_COMPILER_LAUNCHER: ccache$' scripts/flatpak/com.orcaslicer.OrcaSlicer.yml + mkdir -p .flatpak-builder/ccache + export CCACHE_DIR=$PWD/.flatpak-builder/ccache + ccache --set-config=max_size=3G + # The compiler is reinstalled every run, so its mtime means nothing. + ccache --set-config=compiler_check=content + # Headers a fresh checkout has just written, the few files that use + # __DATE__ or __TIME__, and the precompiled header, whose macros ccache + # cannot see. + ccache --set-config=sloppiness=pch_defines,time_macros,include_file_mtime,include_file_ctime + # Hash the includes the compiler reports instead of preprocessing every + # miss before compiling it. + ccache --set-config=depend_mode=true + # The restored directory carries the previous run's counters. + ccache -z + shell: bash - name: Check the manifest keeps orca_deps cacheable run: ./scripts/flatpak/check_manifest_cacheable.sh shell: bash @@ -318,9 +365,42 @@ jobs: with: bundle: OrcaSlicer-Linux-flatpak_${{ env.ver }}_${{ matrix.variant.arch }}.flatpak manifest-path: scripts/flatpak/com.orcaslicer.OrcaSlicer.yml - cache: false + # cache only turns on flatpak-builder --ccache; the caching itself is above. + cache: true + restore-cache: false + save-cache: false arch: ${{ matrix.variant.arch }} upload-artifact: false + - name: Compiler cache statistics + if: always() + run: | + export CCACHE_DIR=$PWD/.flatpak-builder/ccache + ccache -s -v || ccache -s + shell: bash + # Save the new entry first, then drop the older ones for this leg on this + # ref, so a failed save leaves the previous entry in place. + - name: Save compiler cache + id: ccache_save + if: github.event_name != 'pull_request' + uses: actions/cache/save@v6 + with: + path: .flatpak-builder/ccache + key: ${{ env.CCACHE_ENTRY }} + - name: Drop older compiler cache entries + if: ${{ steps.ccache_save.outcome == 'success' }} + # The container has no gh, so this is the list and delete over the REST API. + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: | + api="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/caches" + curl -sSf -H "Authorization: Bearer $GH_TOKEN" \ + "$api?ref=$GITHUB_REF&key=ccache-$CCACHE_LEG-&per_page=100" \ + | jq -r --arg keep "$CCACHE_ENTRY" '.actions_caches[] | select(.key != $keep) | .id' \ + | while read -r id; do + curl -sSf -X DELETE -H "Authorization: Bearer $GH_TOKEN" "$api/$id" + done + shell: bash - name: Upload artifacts Flatpak uses: actions/upload-artifact@v7 with: diff --git a/.github/workflows/build_orca.yml b/.github/workflows/build_orca.yml index 8e1e28db13..1b7fd37a0f 100644 --- a/.github/workflows/build_orca.yml +++ b/.github/workflows/build_orca.yml @@ -76,6 +76,57 @@ jobs: if (-not (Test-Path "$cmakeBin\cmake.exe")) { throw "cmake.exe not found at $cmakeBin" } Add-Content -Path $env:GITHUB_PATH -Value $cmakeBin + # Compiler cache. Pushes save it, so main keeps it warm; pull requests + # restore it and discard what they compiled. Objects are keyed on the + # preprocessed source, the compiler and the flags, so a leg only ever + # hits its own entries. A failed install costs the caching, not the build. + - name: Name the compiler cache leg + if: ${{ !inputs.macos-combine-only }} + shell: bash + run: | + leg="${{ runner.os }}-${{ inputs.arch || 'amd64' }}${{ runner.os == 'Windows' && format('-{0}', inputs.compiler) || '' }}" + echo "CCACHE_LEG=$leg" >> "$GITHUB_ENV" + echo "CCACHE_ENTRY=ccache-$leg-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_ENV" + + # The action only installs and configures ccache. Restore and save go + # through actions/cache with one path string, since the cache service + # only matches entries saved under the identical path and the action + # spells it differently on Windows. + - name: Compiler cache + id: ccache + if: ${{ !inputs.macos-combine-only }} + continue-on-error: true + uses: hendrikmuhs/ccache-action@v1.2 + with: + key: ${{ env.CCACHE_LEG }} + max-size: 3G + restore: false + save: false + + - name: Restore compiler cache + if: ${{ steps.ccache.outcome == 'success' }} + uses: actions/cache/restore@v6 + with: + path: ${{ github.workspace }}/.ccache + key: ${{ env.CCACHE_ENTRY }} + restore-keys: ccache-${{ env.CCACHE_LEG }}- + + - name: Enable compiler cache + if: ${{ steps.ccache.outcome == 'success' }} + shell: bash + run: | + echo "CMAKE_C_COMPILER_LAUNCHER=ccache" >> "$GITHUB_ENV" + echo "CMAKE_CXX_COMPILER_LAUNCHER=ccache" >> "$GITHUB_ENV" + # Headers a fresh checkout has just written, the few files that + # use __DATE__ or __TIME__, and the precompiled header, whose + # macros ccache cannot see. + echo "CCACHE_SLOPPINESS=pch_defines,time_macros,include_file_mtime,include_file_ctime" >> "$GITHUB_ENV" + # Hash the includes the compiler reports instead of preprocessing + # every miss before compiling it. + echo "CCACHE_DEPEND=1" >> "$GITHUB_ENV" + # The restored directory carries the previous run's counters. + ccache -z + - name: Get the version and date on Ubuntu and macOS if: runner.os != 'Windows' run: | @@ -670,3 +721,32 @@ jobs: asset_name: orca_custom_preset_tests.zip asset_content_type: application/octet-stream max_releases: 1 + + - name: Compiler cache statistics + if: ${{ always() && steps.ccache.outcome == 'success' }} + shell: bash + run: ccache -s -v || ccache -s + + # Entries are immutable, so the new one is saved first and the older + # ones for this leg on this ref are dropped afterwards: a failed save + # leaves the previous entry in place. + - name: Save compiler cache + id: ccache_save + if: ${{ steps.ccache.outcome == 'success' && github.event_name != 'pull_request' }} + uses: actions/cache/save@v6 + with: + path: ${{ github.workspace }}/.ccache + key: ${{ env.CCACHE_ENTRY }} + + - name: Drop older compiler cache entries + if: ${{ steps.ccache_save.outcome == 'success' }} + # A read-only token (fork PRs) cannot delete; that only costs storage. + continue-on-error: true + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + gh cache list --ref "$GITHUB_REF" --key "ccache-$CCACHE_LEG-" --limit 100 --json id,key \ + | jq -r --arg keep "$CCACHE_ENTRY" '.[] | select(.key != $keep) | .id' \ + | tr -d '\r' \ + | while read -r id; do gh cache delete "$id"; done diff --git a/.gitignore b/.gitignore index d994d922c1..a2a1520fd5 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,4 @@ __pycache__/ *.pyc *.opc /.test/ +docs/superpowers/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 236aa54c05..4be195b40d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,13 @@ ctest --test-dir ./tests/libslic3r # individual suite ctest --test-dir ./tests/fff_print ``` +## Documentation + +- Docs live in `docs/`; the high-level design of a subsystem goes in `docs/HLSD/.md`. +- Describe the design as it stands — what the subsystem does, why it exists, and the constraints that shape it. Not the route that got there: no phases, task lists, status markers, or "before/after this PR" framing. +- Planning and investigation output (brainstorms, superpowers design and plan docs) stays in `docs/superpowers/`, which is gitignored. Never commit it. +- Write a doc only when the design is not evident from the code, and when a change invalidates an existing one, update it in the same PR. + ## Code Style - C++17, selective C++20. PascalCase classes, snake_case functions/variables diff --git a/CMakeLists.txt b/CMakeLists.txt index 5620875d4f..d2880a7d4b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,6 +4,10 @@ endif() cmake_minimum_required(VERSION 3.13) +if(POLICY CMP0177) + cmake_policy(SET CMP0177 NEW) +endif() + # The following line used to be in tests/CMakeLists.txt # Having it there causes rebuilds of all targets on any CMakeLists.txt change under tests/ @@ -553,59 +557,101 @@ if ((NOT MSVC OR IS_CLANG_CL) AND ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU" OR elseif (NOT MINGW) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall" ) endif () - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-reorder" ) - # On GCC and Clang, no return from a non-void function is a warning only. Here, we make it an error. - add_compile_options(-Werror=return-type) + # Every warning is an error unless it appears in one of the two lists below. + # disabled - never wanted. Off everywhere, so it never warns or errors. + # demoted - wanted, not cleared yet. Still warns, does not error. - # Since some portions of code are just commented out or put under conditional compilation, there are - # a bunch of warning related to unused functions and variables. Suppress those warnings to not pollute - # compilers diagnostics output with warnings we not going to look at - add_compile_options(-Wno-unused-function -Wno-unused-variable -Wno-unused-but-set-variable -Wno-unused-label -Wno-unused-local-typedefs) + # Disabled. + set(warnings_disabled + reorder # members initialised in an order we chose + sign-compare # signed/unsigned comparisons throughout + misleading-indentation # false positives on mixed tabs and spaces + switch # unhandled enum value in a switch + unused-function # commented-out or conditionally compiled code + unused-variable # commented-out or conditionally compiled code + unused-but-set-variable # commented-out or conditionally compiled code + unused-label # commented-out or conditionally compiled code + unused-local-typedefs # commented-out or conditionally compiled code + ) + if (CMAKE_CXX_COMPILER_ID MATCHES "Clang") + list(APPEND warnings_disabled deprecated-declarations) # legacy OpenGL calls + endif () + if (CMAKE_CXX_COMPILER_ID MATCHES "Clang" OR CMAKE_CXX_COMPILER_VERSION VERSION_GREATER 6.0) + list(APPEND warnings_disabled ignored-attributes) # from Eigen headers marked SYSTEM + endif () + if (CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + list(APPEND warnings_disabled unknown-pragmas) # igl pragmas, GCC bug 66943 + endif () + foreach (w IN LISTS warnings_disabled) + add_compile_options(-Wno-${w}) + endforeach () - # Ignore signed/unsigned comparison warnings - add_compile_options(-Wno-sign-compare) + # 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) - # The mismatch of tabs and spaces throughout the project can sometimes - # cause this warning to appear even though the indentation is fine. - # Some includes also cause the warning - add_compile_options(-Wno-misleading-indentation) + # Demoted. Remove a name once its category is cleared on every compiler. + set(warnings_demoted) + if (APPLE) + list(APPEND warnings_demoted + # MacDarkMode.mm makes two calls to AppKit's private titlebarViewController + # and one to a wxWidgets category on NSTableColumn whose header is not + # imported. Clearing it means declaring the private selectors ourselves, which + # needs a macOS build to verify. + objc-method-access + ) + endif () + if (WIN32 AND CMAKE_SYSTEM_PROCESSOR STREQUAL "ARM64") + list(APPEND warnings_demoted + # About two dozen GetProcAddress casts, most in the vendored dark_mode.hpp, + # retype FARPROC to a real signature. The __stdcall typedefs are identical to + # FARPROC on x64, so only arm64 reports them. Clearing them is a separate + # sweep. + 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 - # Disable warning if enum value does not have a corresponding case in switch statement - add_compile_options(-Wno-switch) + # array-bounds is reported once, where ConfigOptionVector::set_at inlines + # into OrcaSlicer.cpp on a branch the preceding type test rules out. + array-bounds - # removes LOTS of extraneous Eigen warnings (GCC only supports it since 6.1) - # https://eigen.tuxfamily.org/bz/show_bug.cgi?id=1221 - if("${CMAKE_CXX_COMPILER_ID}" MATCHES "Clang" OR CMAKE_CXX_COMPILER_VERSION VERSION_GREATER 6.0) - add_compile_options(-Wno-ignored-attributes) # Tamas: Eigen include dirs are marked as SYSTEM - endif() + # 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, + # present through clang 20 and gone in clang 21. + enum-constexpr-conversion + ) + endif () - # Clang reports legacy OpenGL calls as deprecated. Turn off the warning for now - # to reduce the clutter, we know about this one. It should be reenabled after - # we finally get rid of the deprecated code. - if("${CMAKE_CXX_COMPILER_ID}" MATCHES "Clang") - add_compile_options(-Wno-deprecated-declarations) - endif() - - if((${CMAKE_CXX_COMPILER_ID} STREQUAL "Clang" OR ${CMAKE_CXX_COMPILER_ID} STREQUAL "AppleClang") AND ${CMAKE_CXX_COMPILER_VERSION} VERSION_GREATER 15) - include(CheckCXXCompilerFlag) - check_cxx_compiler_flag(-Wno-error=enum-constexpr-conversion HAS_WNO_ERROR_ENUM_CONSTEXPR_CONV) - if(HAS_WNO_ERROR_ENUM_CONSTEXPR_CONV) - add_compile_options(-Wno-error=enum-constexpr-conversion) - endif() - endif() - - #GCC generates loads of -Wunknown-pragmas when compiling igl. The fix is not easy due to a bug in gcc, see - # https://gcc.gnu.org/bugzilla/show_bug.cgi?id=66943 or - # https://gcc.gnu.org/bugzilla/show_bug.cgi?id=53431 - # We will turn the warning of for GCC for now: - if("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU") - # GCC generates loads of -Wunknown-pragmas when compiling igl. The fix is not easy due to a bug in gcc, see - # https://gcc.gnu.org/bugzilla/show_bug.cgi?id=66943 or - # https://gcc.gnu.org/bugzilla/show_bug.cgi?id=53431 - # We will turn the warning of for GCC for now: - add_compile_options(-Wno-unknown-pragmas) - endif() + # The list mixes names not every compiler has, so add each exception only where the + # compiler knows the warning. Probe with the positive -W, which an unknown + # warning fails on both compilers (GCC errors, Clang reports unknown-warning-option). + # An option that takes a =N argument rejects the bare -W, so fall back to + # -W=1 and demote with the trailing =. + include(CheckCXXCompilerFlag) + foreach (category IN LISTS warnings_demoted) + string(MAKE_C_IDENTIFIER "ORCA_HAS_W_${category}" _orca_has_w) + check_cxx_compiler_flag("-W${category}" ${_orca_has_w}) + if (${_orca_has_w}) + add_compile_options(-Wno-error=${category}) + else () + check_cxx_compiler_flag("-W${category}=1" ${_orca_has_w}_arg) + if (${${_orca_has_w}_arg}) + add_compile_options(-Wno-error=${category}=) + endif () + endif () + endforeach () # Compress the debug info with zstd to save space in Flatpak CI builds if(FLATPAK) @@ -615,10 +661,6 @@ if ((NOT MSVC OR IS_CLANG_CL) AND ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU" OR endif() endif() - if("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU" AND CMAKE_CXX_COMPILER_VERSION VERSION_GREATER 14) - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-error=template-id-cdtor" ) - endif() - endif() if (SLIC3R_ASAN) @@ -1208,8 +1250,9 @@ endif () if (NOT SLIC3R_WARNINGS) add_compile_options(-w) elseif (MSVC AND NOT IS_CLANG_CL) - # /we4715 is C4715, no return from a non-void function, matching the - # -Werror=return-type the GNU/Clang builds apply. + # /we4715 is C4715, no return from a non-void function, an error on the GNU/Clang + # builds under -Werror. MSVC is not in that model, so this stays a single promoted + # warning. add_compile_options(/W3 /we4715) endif () diff --git a/cmake/modules/PrecompiledHeader.cmake b/cmake/modules/PrecompiledHeader.cmake index 7ef80aacff..7d8b3a5603 100644 --- a/cmake/modules/PrecompiledHeader.cmake +++ b/cmake/modules/PrecompiledHeader.cmake @@ -256,6 +256,13 @@ function(add_precompiled_header _target _input) message(STATUS "Adding precompiled header ${_input} to target ${_target}.") target_precompile_headers(${_target} PRIVATE ${_input}) + # Clang records the modification time of every input in the precompiled + # header, which makes it differ between two checkouts of the same source + # and defeats a compiler cache. The build system already rebuilds the + # header when an input changes. + target_compile_options(${_target} PRIVATE + "$<$:SHELL:-Xclang -fno-pch-timestamp>") + get_target_property(_sources ${_target} SOURCES) list(FILTER _sources INCLUDE REGEX ".*\\.mm?") diff --git a/deps_src/admesh/stlinit.cpp b/deps_src/admesh/stlinit.cpp index a69bd5497a..9d44cdf266 100644 --- a/deps_src/admesh/stlinit.cpp +++ b/deps_src/admesh/stlinit.cpp @@ -162,7 +162,7 @@ static bool stl_read(stl_file *stl, FILE *fp, int first_facet, bool first, Impor rewind(fp); try{ char solid_name[256]; - int res_solid = fscanf(fp, " solid %[^\n]", solid_name); + int res_solid = fscanf(fp, " solid %255[^\n]", solid_name); if (res_solid == 1) { char* mw_position = strstr(solid_name, "MW"); if (mw_position != NULL) { @@ -170,7 +170,7 @@ static bool stl_read(stl_file *stl, FILE *fp, int first_facet, bool first, Impor char version_str[16]; char model_id_str[128]; char country_code_str[16]; - int num_values = sscanf(mw_position + 3, "%s %s %s", version_str, model_id_str, country_code_str); + int num_values = sscanf(mw_position + 3, "%15s %127s %15s", version_str, model_id_str, country_code_str); if (num_values == 3) { if (strcmp(version_str, "1.0") == 0) { model_id = model_id_str; diff --git a/docs/superpowers/plans/2026-07-23-wx-inspectable-on-dpiaware-plan.md b/docs/superpowers/plans/2026-07-23-wx-inspectable-on-dpiaware-plan.md deleted file mode 100644 index 5b3669ace4..0000000000 --- a/docs/superpowers/plans/2026-07-23-wx-inspectable-on-dpiaware-plan.md +++ /dev/null @@ -1,111 +0,0 @@ -# Move `wxInspectable` into `DPIAware` — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Move `wxInspector::wxInspectable` from individual leaf classes into the common `DPIAware

` template so every DPIAware widget is automatically inspectable and gets the inspector keyboard shortcut. - -**Architecture:** `DPIAware

` gains `wxInspector::wxInspectable` as a second base class and calls `SetupInspectorAccelerator(this)` in its constructor. `DPIDialog` and `MainFrame` drop their now-redundant `wxInspectable` inheritance and `SetupInspectorAccelerator` calls. - -**Tech Stack:** C++17, wxWidgets, wxInspector - -## Global Constraints - -- Build with `D:\VisualStudio\2026\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe` -- Use `--config RelWithDebInfo` for all builds -- Cross-platform: must compile on Windows, macOS, and Linux -- Match existing code style: PascalCase classes, `#pragma once` -- Do NOT commit files under `.superpowers/` -- Do NOT commit `task.md` - ---- - -### Task 1: Move `wxInspectable` and `SetupInspectorAccelerator` into `DPIAware

` - -**Files:** -- Modify: `src/slic3r/GUI/GUI_Utils.hpp:92` (DPIAware template — add wxInspectable base + SetupInspectorAccelerator call) -- Modify: `src/slic3r/GUI/GUI_Utils.hpp:276` (DPIDialog — drop wxInspectable + SetupInspectorAccelerator) -- Modify: `src/slic3r/GUI/MainFrame.hpp:96` (MainFrame — drop wxInspectable) -- Modify: `src/slic3r/GUI/MainFrame.cpp:304` (MainFrame constructor — drop SetupInspectorAccelerator) - -**Interfaces:** -- Consumes: Nothing (standalone refactor) -- Produces: All DPIAware widgets automatically inherit `wxInspector::wxInspectable` and get Ctrl+Shift+I accelerator - -- [ ] **Step 1: Add `wxInspectable` to `DPIAware

` and call `SetupInspectorAccelerator`** - -In `src/slic3r/GUI/GUI_Utils.hpp`, line 92, change the base class: - -```cpp -// Before: -template class DPIAware : public P -// After: -template class DPIAware : public P, public wxInspector::wxInspectable -``` - -In the constructor body of `DPIAware

`, after `this->CenterOnParent();` (currently line 110), add: - -```cpp -SetupInspectorAccelerator(this); -``` - -(`` is already included at line 23.) - -- [ ] **Step 2: Remove redundant `wxInspectable` and `SetupInspectorAccelerator` from `DPIDialog`** - -In `src/slic3r/GUI/GUI_Utils.hpp`, line 276, change: - -```cpp -// Before: -class DPIDialog : public DPIAware, public wxInspector::wxInspectable -// After: -class DPIDialog : public DPIAware -``` - -In the `DPIDialog` constructor body, remove the `SetupInspectorAccelerator(this);` line (currently line 286). The rest of the constructor stays. - -- [ ] **Step 3: Remove redundant `wxInspectable` from `MainFrame`** - -In `src/slic3r/GUI/MainFrame.hpp`, line 96, change: - -```cpp -// Before: -class MainFrame : public DPIFrame, public wxInspector::wxInspectable -// After: -class MainFrame : public DPIFrame -``` - -`MainFrame` now gets `wxInspectable` through `DPIFrame` → `DPIAware`. - -- [ ] **Step 4: Remove redundant `SetupInspectorAccelerator` from `MainFrame` constructor** - -In `src/slic3r/GUI/MainFrame.cpp`, line 304, remove the line: - -```cpp -SetupInspectorAccelerator(this); -``` - -It is now called automatically by the `DPIAware` constructor. - -- [ ] **Step 5: Build to verify compilation** - -```powershell -$cmakePath = "D:\VisualStudio\2026\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe" -& $cmakePath --build . --config RelWithDebInfo --target ALL_BUILD -- -m -``` - -Expected: Build succeeds with zero new errors or warnings. - -- [ ] **Step 6: Commit** - -```bash -git add src/slic3r/GUI/GUI_Utils.hpp src/slic3r/GUI/MainFrame.hpp src/slic3r/GUI/MainFrame.cpp -git commit -m "refactor: move wxInspectable and SetupInspectorAccelerator into DPIAware - -DPIAware

now inherits wxInspector::wxInspectable and calls -SetupInspectorAccelerator in its constructor, making all DPIAware -widgets automatically appear in the inspector tree with the -Ctrl+Shift+I shortcut. Remove redundant wxInspectable inheritance -and SetupInspectorAccelerator calls from DPIDialog and MainFrame. - -Co-Authored-By: Claude " -``` diff --git a/docs/superpowers/plans/2026-07-23-wx-inspector-plugins-plan.md b/docs/superpowers/plans/2026-07-23-wx-inspector-plugins-plan.md deleted file mode 100644 index c3061b223d..0000000000 --- a/docs/superpowers/plans/2026-07-23-wx-inspector-plugins-plan.md +++ /dev/null @@ -1,753 +0,0 @@ -# wxInspector Plugins for OrcaSlicer — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build two wxInspector plugins (DPIAware + CustomWidgets) that expose OrcaSlicer custom control properties in the inspector's property grid. - -**Architecture:** Two plugins in a shared folder under `src/slic3r/Utils/wxInspectorPlugins/`. DPIAwarePlugin uses `dynamic_cast/` for detection; CustomWidgetsPlugin uses per-type `dynamic_cast`. Both registered as static singletons via a single inline function in `Registration.hpp`, called from `MainFrame` constructor. - -**Tech Stack:** C++17, wxWidgets, wxInspector plugin API (`wx/inspector/plugin.h`, `wx/inspector/inspector.h`), OrcaSlicer custom widget headers - -## Global Constraints - -- Plugins placed under `src/slic3r/Utils/wxInspectorPlugins/` -- Build with `D:\VisualStudio\2026\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe` -- Minimal source changes: only trivial (one-line) getters/setters added to existing classes -- Cross-platform: must compile on Windows, macOS, and Linux -- Match existing code style: PascalCase classes, snake_case functions, `#pragma once` - ---- - -### Task 1: Add getters/setters to existing Orca widget headers - -**Files:** -- Modify: `src/slic3r/GUI/GUI_Utils.hpp` (DPIAware template — add 4 methods) -- Modify: `src/slic3r/GUI/Widgets/Button.hpp` (add 3 getters) -- Modify: `src/slic3r/GUI/Widgets/CheckBox.hpp` (add 1 getter) -- Modify: `src/slic3r/GUI/Widgets/TextInput.hpp` (add 1 getter) -- Modify: `src/slic3r/GUI/Widgets/LabeledStaticBox.hpp` (add 4 getter declarations) -- Modify: `src/slic3r/GUI/Widgets/LabeledStaticBox.cpp` (add 4 getter implementations) - -**Interfaces:** -- Consumes: Nothing (prerequisite for all other tasks) -- Produces: - - `DPIAware

::set_scale_factor(float)`, `DPIAware

::set_prev_scale_factor(float)`, `DPIAware

::set_em_unit(int)`, `DPIAware

::force_rescale() const` - - `Button::GetStyle()`, `Button::GetType()`, `Button::IsSelected()` - - `CheckBox::IsHalfChecked()` - - `TextInput::GetCornerRadius()` - - `LabeledStaticBox::GetCornerRadius()`, `LabeledStaticBox::GetBorderWidth()`, `LabeledStaticBox::GetBorderColor()`, `LabeledStaticBox::GetScale()` - -- [ ] **Step 1: Add DPIAware setters/getter in GUI_Utils.hpp** - -After line 184 (`float prev_scale_factor() const { return m_prev_scale_factor; }`), add: - -```cpp -void set_scale_factor(float v) { m_scale_factor = v; } -void set_prev_scale_factor(float v) { m_prev_scale_factor = v; } -void set_em_unit(int v) { m_em_unit = v; } -bool force_rescale() const { return m_force_rescale; } -``` - -- [ ] **Step 2: Add Button getters in Button.hpp** - -After line 79 (`void SetSelected(bool selected = true) { m_selected = selected; }`), add: - -```cpp -ButtonStyle GetStyle() const { return m_style; } -ButtonType GetType() const { return m_type; } -bool IsSelected() const { return m_selected; } -``` - -- [ ] **Step 3: Add CheckBox getter in CheckBox.hpp** - -After line 16 (`void SetHalfChecked(bool value = true);`), add: - -```cpp -bool IsHalfChecked() const { return m_half_checked; } -``` - -- [ ] **Step 4: Add TextInput getter in TextInput.hpp** - -After line 44 (`void SetCornerRadius(double radius);`), add: - -```cpp -int GetCornerRadius() const { return static_cast(radius); } -``` - -(Note: `radius` is inherited from `StaticBox` which has it as a protected `double` member.) - -- [ ] **Step 5: Add LabeledStaticBox getter declarations in LabeledStaticBox.hpp** - -After line 46 (`bool Enable(bool enable) override;`), add: - -```cpp -int GetCornerRadius() const { return m_radius; } -int GetBorderWidth() const { return m_border_width; } -StateColor GetBorderColor() const { return border_color; } -float GetScale() const { return m_scale; } -``` - -(Note: all of `m_radius`, `m_border_width`, `border_color`, `m_scale` are protected members, accessible to inline methods.) - -- [ ] **Step 6: Commit** - -```bash -git add src/slic3r/GUI/GUI_Utils.hpp src/slic3r/GUI/Widgets/Button.hpp src/slic3r/GUI/Widgets/CheckBox.hpp src/slic3r/GUI/Widgets/TextInput.hpp src/slic3r/GUI/Widgets/LabeledStaticBox.hpp -git commit -m "feat: add getters/setters for wxInspector plugin access - -Add minimal public accessors to DPIAware (set_scale_factor, -set_prev_scale_factor, set_em_unit, force_rescale), Button -(GetStyle, GetType, IsSelected), CheckBox (IsHalfChecked), -TextInput (GetCornerRadius), and LabeledStaticBox -(GetCornerRadius, GetBorderWidth, GetBorderColor, GetScale)." -``` - ---- - -### Task 2: Create Registration helper header - -**Files:** -- Create: `src/slic3r/Utils/wxInspectorPlugins/Registration.hpp` - -**Interfaces:** -- Consumes: Nothing (forward-declares plugin classes) -- Produces: `RegisterOrcaInspectorPlugins()` - -- [ ] **Step 1: Create directory** - -```bash -mkdir -p src/slic3r/Utils/wxInspectorPlugins -``` - -- [ ] **Step 2: Write Registration.hpp** - -```cpp -#pragma once - -namespace wxInspector { -class wxInspectorPlugin; -void RegisterPlugin(wxInspectorPlugin* plugin); -} - -// Forward declare our plugins -class DPIAwarePlugin; -class CustomWidgetsPlugin; - -inline void RegisterOrcaInspectorPlugins() -{ - static DPIAwarePlugin dpiaware; - static CustomWidgetsPlugin customWidgets; - wxInspector::RegisterPlugin(&dpiaware); - wxInspector::RegisterPlugin(&customWidgets); -} -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/slic3r/Utils/wxInspectorPlugins/Registration.hpp -git commit -m "feat: add wxInspector plugin registration helper - -Add RegisterOrcaInspectorPlugins() inline function that creates -and registers the DPIAwarePlugin and CustomWidgetsPlugin as -static instances (matching wxInspector's built-in pattern)." -``` - ---- - -### Task 3: Create DPIAwarePlugin - -**Files:** -- Create: `src/slic3r/Utils/wxInspectorPlugins/DPIAwarePlugin.hpp` -- Create: `src/slic3r/Utils/wxInspectorPlugins/DPIAwarePlugin.cpp` - -**Interfaces:** -- Consumes: Task 1 (DPIAware getters/setters), Task 2 (registration pattern) -- Produces: `class DPIAwarePlugin : public wxInspector::wxInspectorPlugin` - -- [ ] **Step 1: Write DPIAwarePlugin.hpp** - -```cpp -#pragma once - -#include - -class DPIAwarePlugin : public wxInspector::wxInspectorPlugin -{ -public: - wxString GetName() const override; - - bool CanProvideProperties(wxClassInfo* info) override; - - wxVector GetProperties( - wxInspector::InspectableObject& obj) override; -}; -``` - -- [ ] **Step 2: Write DPIAwarePlugin.cpp** - -```cpp -#include "DPIAwarePlugin.hpp" - -#include "slic3r/GUI/GUI_Utils.hpp" // DPIFrame, DPIDialog, DPIAware

- -#include - -namespace { - -template -void addDPIProps(T* dpi, wxVector& props) -{ - using namespace wxInspector; - - props.push_back({"Scale Factor", "DPI Scaling", PropertyType::String, - wxString::Format("%.2f", dpi->scale_factor()), false, {}, - [dpi]() { return wxString::Format("%.2f", dpi->scale_factor()); }, - [dpi](const wxString& v) { - double val; - if (wxSscanf(v, "%lf", &val) != 1) return false; - dpi->set_scale_factor((float) val); - return true; - }}); - - props.push_back({"Prev Scale Factor", "DPI Scaling", PropertyType::String, - wxString::Format("%.2f", dpi->prev_scale_factor()), false, {}, - [dpi]() { return wxString::Format("%.2f", dpi->prev_scale_factor()); }, - [dpi](const wxString& v) { - double val; - if (wxSscanf(v, "%lf", &val) != 1) return false; - dpi->set_prev_scale_factor((float) val); - return true; - }}); - - props.push_back({"EM Unit", "DPI Scaling", PropertyType::Integer, - wxString::Format("%d", dpi->em_unit()), false, {}, - [dpi]() { return wxString::Format("%d", dpi->em_unit()); }, - [dpi](const wxString& v) { - long val; - if (!v.ToLong(&val)) return false; - dpi->set_em_unit((int) val); - return true; - }}); - - props.push_back({"Normal Font", "DPI Scaling", PropertyType::ReadOnly, - dpi->normal_font().GetNativeFontInfoDesc(), true, {}, - [dpi]() { return dpi->normal_font().GetNativeFontInfoDesc(); }, - nullptr}); - - props.push_back({"Force Rescale", "DPI Scaling", PropertyType::Boolean, - dpi->force_rescale() ? "true" : "false", true, {}, - [dpi]() { return dpi->force_rescale() ? "true" : "false"; }, - nullptr}); -} - -} // anonymous namespace - -wxString DPIAwarePlugin::GetName() const -{ - return "OrcaDPIAware"; -} - -bool DPIAwarePlugin::CanProvideProperties(wxClassInfo* info) -{ - return info->IsKindOf(CLASSINFO(wxWindow)); -} - -wxVector DPIAwarePlugin::GetProperties( - wxInspector::InspectableObject& obj) -{ - wxVector props; - wxWindow* win = obj.AsWindow(); - if (!win) return props; - - if (auto* frame = dynamic_cast(win)) { - addDPIProps(frame, props); - } else if (auto* dlg = dynamic_cast(win)) { - addDPIProps(dlg, props); - } - - return props; -} -``` - -- [ ] **Step 3: Commit** - -```bash -git add src/slic3r/Utils/wxInspectorPlugins/DPIAwarePlugin.hpp src/slic3r/Utils/wxInspectorPlugins/DPIAwarePlugin.cpp -git commit -m "feat: add DPIAware wxInspector plugin - -Exposes DPI scaling properties (scale_factor, prev_scale_factor, -em_unit, normal_font, force_rescale) on DPIFrame and DPIDialog -widgets. Uses dynamic_cast for detection and a template helper -to capture the correct static type for lambda accessors." -``` - ---- - -### Task 4: Create CustomWidgetsPlugin - -**Files:** -- Create: `src/slic3r/Utils/wxInspectorPlugins/CustomWidgetsPlugin.hpp` -- Create: `src/slic3r/Utils/wxInspectorPlugins/CustomWidgetsPlugin.cpp` - -**Interfaces:** -- Consumes: Task 1 (all widget getters), Task 2 (registration pattern) -- Produces: `class CustomWidgetsPlugin : public wxInspector::wxInspectorPlugin` - -- [ ] **Step 1: Write CustomWidgetsPlugin.hpp** - -```cpp -#pragma once - -#include - -class CustomWidgetsPlugin : public wxInspector::wxInspectorPlugin -{ -public: - wxString GetName() const override; - - bool CanProvideProperties(wxClassInfo* info) override; - - wxVector GetProperties( - wxInspector::InspectableObject& obj) override; - -private: - void addButtonProps(class Button* btn, - wxVector& props); - void addCheckBoxProps(class CheckBox* cb, - wxVector& props); - void addTextInputProps(class TextInput* ti, - wxVector& props); - void addSwitchButtonProps(class SwitchButton* sb, - wxVector& props); - void addProgressBarProps(class ProgressBar* pb, - wxVector& props); - void addLabelProps(class Label* lbl, - wxVector& props); - void addLabeledStaticBoxProps(class LabeledStaticBox* lsb, - wxVector& props); -}; -``` - -- [ ] **Step 2: Write CustomWidgetsPlugin.cpp — includes and GetName/CanProvideProperties** - -```cpp -#include "CustomWidgetsPlugin.hpp" - -#include "slic3r/GUI/Widgets/Button.hpp" -#include "slic3r/GUI/Widgets/CheckBox.hpp" -#include "slic3r/GUI/Widgets/TextInput.hpp" -#include "slic3r/GUI/Widgets/SwitchButton.hpp" -#include "slic3r/GUI/Widgets/ProgressBar.hpp" -#include "slic3r/GUI/Widgets/Label.hpp" -#include "slic3r/GUI/Widgets/LabeledStaticBox.hpp" - -#include -#include - -wxString CustomWidgetsPlugin::GetName() const -{ - return "OrcaCustomWidgets"; -} - -bool CustomWidgetsPlugin::CanProvideProperties(wxClassInfo* info) -{ - return info->IsKindOf(CLASSINFO(wxWindow)); -} - -wxVector CustomWidgetsPlugin::GetProperties( - wxInspector::InspectableObject& obj) -{ - wxVector props; - wxWindow* win = obj.AsWindow(); - if (!win) return props; - - if (auto* btn = dynamic_cast(win)) - addButtonProps(btn, props); - if (auto* cb = dynamic_cast(win)) - addCheckBoxProps(cb, props); - if (auto* ti = dynamic_cast(win)) - addTextInputProps(ti, props); - if (auto* sb = dynamic_cast(win)) - addSwitchButtonProps(sb, props); - if (auto* pb = dynamic_cast(win)) - addProgressBarProps(pb, props); - if (auto* lbl = dynamic_cast(win)) - addLabelProps(lbl, props); - if (auto* lsb = dynamic_cast(win)) - addLabeledStaticBoxProps(lsb, props); - - return props; -} -``` - -- [ ] **Step 3: Write CustomWidgetsPlugin.cpp — addButtonProps** - -```cpp -void CustomWidgetsPlugin::addButtonProps(Button* btn, - wxVector& props) -{ - using namespace wxInspector; - - wxVector styleChoices; - styleChoices.push_back("Regular"); - styleChoices.push_back("Confirm"); - styleChoices.push_back("Alert"); - styleChoices.push_back("Disabled"); - - auto styleToStr = [](ButtonStyle s) -> wxString { - switch (s) { - case ButtonStyle::Regular: return "Regular"; - case ButtonStyle::Confirm: return "Confirm"; - case ButtonStyle::Alert: return "Alert"; - case ButtonStyle::Disabled: return "Disabled"; - } - return "Regular"; - }; - - props.push_back({"Button Style", "Orca Button", PropertyType::Choice, - styleToStr(btn->GetStyle()), false, styleChoices, - [btn, styleToStr]() { return styleToStr(btn->GetStyle()); }, - [btn](const wxString& v) { - ButtonStyle s = ButtonStyle::Regular; - if (v == "Confirm") s = ButtonStyle::Confirm; - else if (v == "Alert") s = ButtonStyle::Alert; - else if (v == "Disabled") s = ButtonStyle::Disabled; - btn->SetStyle(s, btn->GetType()); - return true; - }}); - - wxVector typeChoices; - typeChoices.push_back("Compact"); - typeChoices.push_back("Window"); - typeChoices.push_back("Choice"); - typeChoices.push_back("Parameter"); - typeChoices.push_back("Icon"); - typeChoices.push_back("Expanded"); - - auto typeToStr = [](ButtonType t) -> wxString { - switch (t) { - case ButtonType::Compact: return "Compact"; - case ButtonType::Window: return "Window"; - case ButtonType::Choice: return "Choice"; - case ButtonType::Parameter: return "Parameter"; - case ButtonType::Icon: return "Icon"; - case ButtonType::Expanded: return "Expanded"; - } - return "Compact"; - }; - - props.push_back({"Button Type", "Orca Button", PropertyType::Choice, - typeToStr(btn->GetType()), false, typeChoices, - [btn, typeToStr]() { return typeToStr(btn->GetType()); }, - [btn](const wxString& v) { - ButtonType t = ButtonType::Compact; - if (v == "Window") t = ButtonType::Window; - else if (v == "Choice") t = ButtonType::Choice; - else if (v == "Parameter") t = ButtonType::Parameter; - else if (v == "Icon") t = ButtonType::Icon; - else if (v == "Expanded") t = ButtonType::Expanded; - btn->SetStyle(btn->GetStyle(), t); - return true; - }}); - - props.push_back({"Selected", "Orca Button", PropertyType::Boolean, - btn->IsSelected() ? "true" : "false", false, {}, - [btn]() { return btn->IsSelected() ? "true" : "false"; }, - [btn](const wxString& v) { - btn->SetSelected(v == "true"); - btn->Refresh(); - return true; - }}); -} -``` - -- [ ] **Step 4: Write CustomWidgetsPlugin.cpp — addCheckBoxProps** - -```cpp -void CustomWidgetsPlugin::addCheckBoxProps(CheckBox* cb, - wxVector& props) -{ - using namespace wxInspector; - - props.push_back({"Half Checked", "Orca CheckBox", PropertyType::Boolean, - cb->IsHalfChecked() ? "true" : "false", false, {}, - [cb]() { return cb->IsHalfChecked() ? "true" : "false"; }, - [cb](const wxString& v) { - cb->SetHalfChecked(v == "true"); - return true; - }}); -} -``` - -- [ ] **Step 5: Write CustomWidgetsPlugin.cpp — addTextInputProps** - -```cpp -void CustomWidgetsPlugin::addTextInputProps(TextInput* ti, - wxVector& props) -{ - using namespace wxInspector; - - props.push_back({"Label", "Orca TextInput", PropertyType::String, - ti->GetLabel(), false, {}, - [ti]() { return ti->GetLabel(); }, - [ti](const wxString& v) { ti->SetLabel(v); return true; }}); - - props.push_back({"Text Value", "Orca TextInput", PropertyType::String, - ti->GetTextCtrl()->GetValue(), false, {}, - [ti]() { return ti->GetTextCtrl()->GetValue(); }, - [ti](const wxString& v) { ti->GetTextCtrl()->SetValue(v); return true; }}); - - props.push_back({"Corner Radius", "Orca TextInput", PropertyType::Integer, - wxString::Format("%d", ti->GetCornerRadius()), false, {}, - [ti]() { return wxString::Format("%d", ti->GetCornerRadius()); }, - [ti](const wxString& v) { - long val; - if (!v.ToLong(&val)) return false; - ti->SetCornerRadius((double) val); - ti->Refresh(); - return true; - }}); -} -``` - -- [ ] **Step 6: Write CustomWidgetsPlugin.cpp — addSwitchButtonProps** - -```cpp -void CustomWidgetsPlugin::addSwitchButtonProps(SwitchButton* sb, - wxVector& props) -{ - using namespace wxInspector; - - props.push_back({"Value", "Orca SwitchButton", PropertyType::Boolean, - sb->GetValue() ? "true" : "false", false, {}, - [sb]() { return sb->GetValue() ? "true" : "false"; }, - [sb](const wxString& v) { - sb->SetValue(v == "true"); - return true; - }}); -} -``` - -(Note: `GetValue()` and `SetValue()` are inherited from `wxBitmapToggleButton` → `wxToggleButton`.) - -- [ ] **Step 7: Write CustomWidgetsPlugin.cpp — addProgressBarProps** - -```cpp -void CustomWidgetsPlugin::addProgressBarProps(ProgressBar* pb, - wxVector& props) -{ - using namespace wxInspector; - - props.push_back({"Proportion", "Orca ProgressBar", PropertyType::String, - wxString::Format("%.2f", pb->m_proportion), false, {}, - [pb]() { return wxString::Format("%.2f", pb->m_proportion); }, - [pb](const wxString& v) { - double val; - if (wxSscanf(v, "%lf", &val) != 1) return false; - pb->m_proportion = val; - pb->Refresh(); - return true; - }}); - - props.push_back({"Show Number", "Orca ProgressBar", PropertyType::Boolean, - pb->m_shownumber ? "true" : "false", false, {}, - [pb]() { return pb->m_shownumber ? "true" : "false"; }, - [pb](const wxString& v) { - pb->m_shownumber = (v == "true"); - pb->Refresh(); - return true; - }}); -} -``` - -(Note: `m_proportion` and `m_shownumber` are public members on `ProgressBar`.) - -- [ ] **Step 8: Write CustomWidgetsPlugin.cpp — addLabelProps** - -```cpp -void CustomWidgetsPlugin::addLabelProps(Label* lbl, - wxVector& props) -{ - using namespace wxInspector; - - bool isHyperlink = (lbl->GetWindowStyleFlag() & 0x0020) != 0; // LB_HYPERLINK - - props.push_back({"Is Hyperlink", "Orca Label", PropertyType::Boolean, - isHyperlink ? "true" : "false", true, {}, - [lbl]() { - return (lbl->GetWindowStyleFlag() & 0x0020) ? "true" : "false"; - }, - nullptr}); - - props.push_back({"Font Point Size", "Orca Label", PropertyType::ReadOnly, - wxString::Format("%d", lbl->GetFont().GetPointSize()), true, {}, - [lbl]() { - return wxString::Format("%d", lbl->GetFont().GetPointSize()); - }, - nullptr}); -} -``` - -- [ ] **Step 9: Write CustomWidgetsPlugin.cpp — addLabeledStaticBoxProps** - -```cpp -void CustomWidgetsPlugin::addLabeledStaticBoxProps(LabeledStaticBox* lsb, - wxVector& props) -{ - using namespace wxInspector; - - props.push_back({"Corner Radius", "LabeledStaticBox", PropertyType::Integer, - wxString::Format("%d", lsb->GetCornerRadius()), false, {}, - [lsb]() { return wxString::Format("%d", lsb->GetCornerRadius()); }, - [lsb](const wxString& v) { - long val; - if (!v.ToLong(&val)) return false; - lsb->SetCornerRadius((int) val); - return true; - }}); - - props.push_back({"Border Width", "LabeledStaticBox", PropertyType::Integer, - wxString::Format("%d", lsb->GetBorderWidth()), false, {}, - [lsb]() { return wxString::Format("%d", lsb->GetBorderWidth()); }, - [lsb](const wxString& v) { - long val; - if (!v.ToLong(&val)) return false; - lsb->SetBorderWidth((int) val); - return true; - }}); - - // Border Color: display as hex string - wxColour bc = lsb->GetBorderColor().colorForStates(0); - props.push_back({"Border Color", "LabeledStaticBox", PropertyType::String, - bc.GetAsString(wxC2S_HTML_SYNTAX), false, {}, - [lsb]() { - return lsb->GetBorderColor() - .colorForStates(0) - .GetAsString(wxC2S_HTML_SYNTAX); - }, - [lsb](const wxString& v) { - wxColour c(v); - if (!c.IsOk()) return false; - lsb->SetBorderColor(StateColor(c)); - return true; - }}); - - props.push_back({"Scale", "LabeledStaticBox", PropertyType::ReadOnly, - wxString::Format("%.2f", lsb->GetScale()), true, {}, - [lsb]() { return wxString::Format("%.2f", lsb->GetScale()); }, - nullptr}); -} -``` - -- [ ] **Step 10: Commit** - -```bash -git add src/slic3r/Utils/wxInspectorPlugins/CustomWidgetsPlugin.hpp src/slic3r/Utils/wxInspectorPlugins/CustomWidgetsPlugin.cpp -git commit -m "feat: add OrcaCustomWidgets wxInspector plugin - -Exposes Orca-specific properties on 7 widget types: -- Button: Style, Type, Selected -- CheckBox: Half Checked -- TextInput: Label, Text Value, Corner Radius -- SwitchButton: Value -- ProgressBar: Proportion, Show Number -- Label: Is Hyperlink, Font Point Size -- LabeledStaticBox: Corner Radius, Border Width, Border Color, Scale - -Each widget type uses dynamic_cast for safe detection." -``` - ---- - -### Task 5: Wire plugins into MainFrame and CMakeLists - -**Files:** -- Modify: `src/slic3r/GUI/MainFrame.cpp` (add include + registration call) -- Modify: `src/slic3r/CMakeLists.txt` (add 4 source files) - -**Interfaces:** -- Consumes: Tasks 1-4 (all plugins and registration helper) -- Produces: Registered plugins available at runtime, buildable project - -- [ ] **Step 1: Add include in MainFrame.cpp** - -After the existing includes (around line 30, near the other Utils includes), add: - -```cpp -#include "slic3r/Utils/wxInspectorPlugins/Registration.hpp" -``` - -- [ ] **Step 2: Add registration call in MainFrame constructor** - -After `SetupInspectorAccelerator(this);` (currently line ~303), add: - -```cpp -RegisterOrcaInspectorPlugins(); -``` - -- [ ] **Step 3: Add source files to CMakeLists.txt** - -Find the `SLIC3R_GUI_SOURCES` list in `src/slic3r/CMakeLists.txt`. After the existing `Utils/*.cpp` entries (around line 650-754), add: - -```cmake -Utils/wxInspectorPlugins/DPIAwarePlugin.hpp -Utils/wxInspectorPlugins/DPIAwarePlugin.cpp -Utils/wxInspectorPlugins/CustomWidgetsPlugin.hpp -Utils/wxInspectorPlugins/CustomWidgetsPlugin.cpp -Utils/wxInspectorPlugins/Registration.hpp -``` - -(Note: Add all 5 files — 2 .hpp + 2 .cpp + 1 Registration.hpp. wxWidgets cmake needs headers listed too for the resource system.) - -- [ ] **Step 4: Commit** - -```bash -git add src/slic3r/GUI/MainFrame.cpp src/slic3r/CMakeLists.txt -git commit -m "feat: wire wxInspector plugins into MainFrame and build - -- Call RegisterOrcaInspectorPlugins() after SetupInspectorAccelerator -- Add all plugin source files to SLIC3R_GUI_SOURCES" -``` - ---- - -### Task 6: Build and verify - -**Files:** -- None modified (verification only) - -- [ ] **Step 1: Configure the build** - -```powershell -$cmakePath = "D:\VisualStudio\2026\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe" -& $cmakePath --build . --config Debug --target ALL_BUILD -- -m -``` - -Expected: Build succeeds with zero errors and zero warnings from our new files. - -- [ ] **Step 2: Fix any compilation errors** - -If the build fails: -- Check that `#include` paths resolve (the `slic3r/GUI/…` relative paths use `src/` as the include root — verify this is set up in CMake via `include_directories`) -- Check that `ButtonStyle` and `ButtonType` enums are visible (they're defined in `Button.hpp`) -- Check that `StateColor` constructor from `wxColour` is valid (it has `StateColor(wxColour const&)`) -- Check that `LabeledStaticBox::GetBorderColor()` returns by value (StateColor copy is fine) -- On macOS: static box margin removal call needs `#ifdef __WXOSX__` guard - -- [ ] **Step 3: Launch OrcaSlicer and verify inspector** - -Launch the built OrcaSlicer, press Ctrl+Shift+I to open the inspector: -1. Select the MainFrame in the tree — verify "DPI Scaling" category appears with Scale Factor, Prev Scale Factor, EM Unit, Normal Font, Force Rescale -2. Select an Orca Button — verify "Orca Button" category appears -3. Select an Orca CheckBox — verify "Orca CheckBox" category appears -4. Edit a property value (e.g., Scale Factor) — verify the setter applies correctly -5. Select a LabeledStaticBox — verify corner radius, border width, border color, scale appear - -- [ ] **Step 5: Commit (if fixes were needed) or mark complete** - -```bash -git status -``` - -If clean: verification complete. If changes were made: `git add` and commit with fix message. diff --git a/docs/superpowers/plans/2026-08-14-ffmpeg-player-macos.md b/docs/superpowers/plans/2026-08-14-ffmpeg-player-macos.md deleted file mode 100644 index 811626fc8b..0000000000 --- a/docs/superpowers/plans/2026-08-14-ffmpeg-player-macos.md +++ /dev/null @@ -1,376 +0,0 @@ -# macOS FFmpeg Media Player Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make macOS use the same FFmpeg-based media player (`wxMediaCtrl3` + `AVVideoDecoder`) as Windows/Linux, linking the static FFmpeg libraries from the deps build, and remove the old `wxMediaCtrl2.mm` BambuPlayer-based player. - -**Architecture:** The new player is platform-neutral C++ already used on Linux/Windows. Enabling it on macOS is pure build wiring: compile `wxMediaCtrl3.cpp` + `AVVideoDecoder.cpp` on macOS, drop the `__WXMAC__` alias that redirects `wxMediaCtrl3` to the old `wxMediaCtrl2`, and link static FFmpeg (`libavcodec.a`/`libswscale.a`/`libavutil.a`) from the deps install. The Bambu stream API is dlsym'd at runtime from the network plugin (`libBambuSource.dylib`), which already exports it — no plugin changes needed. Rendering reuses the existing `wxImage` → `DrawBitmap` paint path (same as Linux). - -**Tech Stack:** C++17, wxWidgets, CMake, FFmpeg 7.0.3 (libavcodec/libswscale/libavutil), macOS (Xcode generator), `deps/` ExternalProject build system. - -## Global Constraints - -- Branch: `dev/ffmpeg-player-macos`. Commit after every task. -- **Linux and Windows builds must not change** — the FFmpeg deps flag change is guarded by `APPLE`; Linux keeps `--enable-shared`, Windows keeps its prebuilt DLL zips. -- Static FFmpeg only on macOS: deps produce `libavcodec.a`/`libswscale.a`/`libavutil.a`; the app links those explicitly — the app binary must have **no** `libav*` dylib references (`otool -L` check). -- Follow existing code style: PascalCase classes, snake_case functions, C++17. -- No changes to `StatusPanel.cpp`, `MediaPlayCtrl.*`, or the BambuTunnel interface — the app already creates `wxMediaCtrl3` and uses only its public interface. -- The player cannot be unit-tested (hardware/plugin-dependent GUI code); verification is build-level, link-level, and manual runtime on a Mac. -- `localization/i18n/list.txt` references only `wxMediaCtrl2.cpp` (Win/Linux, stays) — no translation-list changes needed. -- Build dirs on the dev machine: main app = `build_arm64/` (Xcode generator, multi-config), deps = `deps/build/arm64/` (Unix Makefiles). App target name: `OrcaSlicer`. Substitute your own configured build dirs where noted. - ---- - -### Task 1: Enable wxMediaCtrl3 on macOS and link static FFmpeg - -**Files:** -- Modify: `src/slic3r/GUI/wxMediaCtrl3.h` (lines 18–22: the `#ifdef __WXMAC__` alias branch) -- Modify: `src/slic3r/GUI/wxMediaCtrl3.cpp:13` (uncomment the event define) -- Modify: `src/slic3r/GUI/wxMediaCtrl2.cpp:101` (remove the event define) -- Modify: `src/slic3r/CMakeLists.txt` (APPLE source list ~lines 779–792; FFmpeg link block ~lines 905–910) - -**Interfaces:** -- Consumes: nothing new (all classes already exist). -- Produces: `wxMediaCtrl3` class compiled on macOS with the same interface as Linux/Windows — `Load(wxURI)`, `Play()`, `Stop()`, `SetIdleImage(wxString)`, `GetState()`, `GetLastError()`, `GetVideoSize()`, event `EVT_MEDIA_CTRL_STAT` defined once in the lib (from `wxMediaCtrl3.cpp`). - -- [ ] **Step 1: Remove the macOS alias in wxMediaCtrl3.h** - -Current (lines 16–23 of `src/slic3r/GUI/wxMediaCtrl3.h`): - -```cpp -void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, int height); - -#ifdef __WXMAC__ - -#include "wxMediaCtrl2.h" -#define wxMediaCtrl3 wxMediaCtrl2 - -#else - -#define BAMBU_DYNAMIC -``` - -New: - -```cpp -void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, int height); - -#define BAMBU_DYNAMIC -``` - -Also remove the matching `#endif` that closed the `#else` branch (the one before the final `#endif /* wxMediaCtrl3_h */`), so the file's `#ifndef`/`#endif` guard pair stays balanced. - -- [ ] **Step 2: Move the EVT_MEDIA_CTRL_STAT definition into wxMediaCtrl3.cpp** - -In `src/slic3r/GUI/wxMediaCtrl3.cpp:13`, uncomment: - -```cpp -//wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); -``` - -becomes: - -```cpp -wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); -``` - -In `src/slic3r/GUI/wxMediaCtrl2.cpp:101`, delete: - -```cpp -wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); -``` - -(One definition total in the lib — `MediaPlayCtrl.cpp:59` binds this event on the media ctrl.) - -- [ ] **Step 3: Update the APPLE source list in CMakeLists.txt** - -In `src/slic3r/CMakeLists.txt`, the APPLE branch (currently compiles `wxMediaCtrl2.mm`, which becomes dead on macOS): - -```cmake - GUI/wxMediaCtrl2.mm - GUI/wxMediaCtrl2.h - GUI/wxMediaCtrl3.h - ) -``` - -becomes: - -```cmake - GUI/AVVideoDecoder.cpp - GUI/AVVideoDecoder.hpp - GUI/wxMediaCtrl3.cpp - GUI/wxMediaCtrl3.h - ) -``` - -(The `else ()` branch — Win/Linux — stays exactly as it is.) - -- [ ] **Step 4: Link static FFmpeg on macOS** - -In `src/slic3r/CMakeLists.txt`, the FFmpeg block (currently `if (NOT APPLE)`): - -```cmake -if (NOT APPLE) - pkg_check_modules(LIBAV REQUIRED IMPORTED_TARGET - libavcodec - libswscale - libavutil - ) - target_link_libraries(libslic3r_gui PkgConfig::LIBAV) -endif() -``` - -becomes: - -```cmake -if (APPLE) - # Static FFmpeg from the deps install: nothing to bundle into the .app, - # no rpath/install_name handling. Order matters: avcodec -> swscale -> avutil. - find_library(LIBAVCODEC_LIBRARY NAMES libavcodec.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) - find_library(LIBSWSCALE_LIBRARY NAMES libswscale.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) - find_library(LIBAVUTIL_LIBRARY NAMES libavutil.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) - target_link_libraries(libslic3r_gui ${LIBAVCODEC_LIBRARY} ${LIBSWSCALE_LIBRARY} ${LIBAVUTIL_LIBRARY}) - target_include_directories(libslic3r_gui SYSTEM PRIVATE ${CMAKE_PREFIX_PATH}/include) -else () - pkg_check_modules(LIBAV REQUIRED IMPORTED_TARGET - libavcodec - libswscale - libavutil - ) - target_link_libraries(libslic3r_gui PkgConfig::LIBAV) -endif() -``` - -The deps install (`${CMAKE_PREFIX_PATH}/lib`) already contains the three `.a` files from the existing arm64 deps build — no deps rebuild needed for this task. - -- [ ] **Step 5: Reconfigure and build the app** - -Run (Xcode generator; `cmake` re-runs automatically on build): - -```bash -cmake --build build_arm64 --config RelWithDebInfo --target OrcaSlicer -``` - -Expected: configure succeeds (no `pkg_check_modules` errors on macOS, `find_library` finds all three `.a` files), compile succeeds (`wxMediaCtrl3.cpp` and `AVVideoDecoder.cpp` compile on macOS without changes), link succeeds. - -If CMake complains that `wxMediaCtrl3.h` is included but not in the source list or similar IDE-only warnings — ignore; headers in the list are cosmetic. - -- [ ] **Step 6: Verify no dynamic FFmpeg dependency** - -```bash -otool -L build_arm64/src/RelWithDebInfo/OrcaSlicer.app/Contents/MacOS/OrcaSlicer | grep -i "libav" || echo "OK: no dynamic FFmpeg" -``` - -Expected: prints `OK: no dynamic FFmpeg` (empty grep output). This is the whole point of static linking — nothing to bundle into the `.app`. - -- [ ] **Step 7: Quick sanity — macOS unit tests still pass** - -```bash -ctest --test-dir build_arm64/tests/libslic3r --output-on-failure -``` - -Expected: passes (add `-C RelWithDebInfo` if the multi-config generator requires it). If no tests were built in this build dir, build target `tests` first (`cmake --build build_arm64 --config RelWithDebInfo --target tests`). - -- [ ] **Step 8: Commit** - -```bash -git add src/slic3r/CMakeLists.txt src/slic3r/GUI/wxMediaCtrl3.h src/slic3r/GUI/wxMediaCtrl3.cpp src/slic3r/GUI/wxMediaCtrl2.cpp -git commit -m "feat: use FFmpeg media player on macOS with static FFmpeg" -``` - ---- - -### Task 2: Static-only FFmpeg in the macOS deps build - -**Files:** -- Modify: `deps/FFMPEG/FFMPEG.cmake` (non-MSVC branch, APPLE section and CONFIGURE_COMMAND) - -**Interfaces:** -- Consumes: nothing. -- Produces: a deps install on macOS containing only `libavcodec.a`, `libswscale.a`, `libavutil.a` (+ headers) — no `libav*` dylibs, so no bundling/rpath machinery is ever needed on macOS. Linux and Windows output are unchanged. - -- [ ] **Step 1: Add the static flag variable** - -In `deps/FFMPEG/FFMPEG.cmake`, inside the non-MSVC `else ()` branch, in the existing `if (APPLE)` block: - -```cmake - if (APPLE) - set(_minos_cmd - "CFLAGS=-mmacosx-version-min=${DEP_OSX_TARGET}" - "LDFLAGS=-mmacosx-version-min=${DEP_OSX_TARGET}" - ) -``` - -add after the `_minos_cmd` set: - -```cmake - # Static FFmpeg: nothing to bundle into the .app, no rpath handling. - # Shared flags must come AFTER --enable-shared below so they win. - set(_link_cmd --enable-static --disable-shared) -``` - -and add a matching `else ()` after the `if (IS_CROSS_COMPILE) ... endif()` block inside that `if (APPLE)`, so non-Apple Unix keeps shared: - -```cmake - else () - set(_link_cmd --enable-shared) - endif () -``` - -(If the existing `if (IS_CROSS_COMPILE)` block is the last thing inside `if (APPLE)`, the new `else ()` closes the `if (APPLE)` itself.) - -- [ ] **Step 2: Use the variable in CONFIGURE_COMMAND** - -In the `ExternalProject_Add(dep_FFMPEG ...)` configure command: - -```cmake - "--prefix=${DESTDIR}" - --enable-shared -``` - -becomes: - -```cmake - "--prefix=${DESTDIR}" - --enable-shared - ${_link_cmd} -``` - -Order matters: `--enable-shared` comes first, then `--enable-static --disable-shared` (APPLE) or `--enable-shared` (Linux) — the last flag wins in FFmpeg configure. - -- [ ] **Step 3: Rebuild the FFmpeg dep (slow — several minutes, run in background)** - -The changed CONFIGURE_COMMAND invalidates the ExternalProject stamp, so this re-configures and rebuilds FFmpeg: - -```bash -cmake --build deps/build/arm64 --target dep_FFMPEG -``` - -For a fully clean static-only check (removes the previous shared build tree, which can leave stale `.dylib` files behind in the in-source build): - -```bash -rm -rf deps/build/arm64/dep_FFMPEG-prefix -cmake --build deps/build/arm64 --target dep_FFMPEG -``` - -- [ ] **Step 4: Verify the artifacts** - -```bash -ls deps/build/arm64/dep_FFMPEG-prefix/src/dep_FFMPEG/libavcodec/*.a -ls deps/build/arm64/dep_FFMPEG-prefix/src/dep_FFMPEG/libavcodec/*.dylib 2>/dev/null || echo "OK: no dylibs" -``` - -Expected: `libavcodec.a` present, second command prints `OK: no dylibs`. Check `libavutil` and `libswscale` the same way. - -- [ ] **Step 5: Verify the app still links against the static libs** - -```bash -cmake --build build_arm64 --config RelWithDebInfo --target OrcaSlicer -otool -L build_arm64/src/RelWithDebInfo/OrcaSlicer.app/Contents/MacOS/OrcaSlicer | grep -i "libav" || echo "OK: no dynamic FFmpeg" -``` - -Expected: build succeeds, `OK: no dynamic FFmpeg`. - -- [ ] **Step 6: Commit** - -```bash -git add deps/FFMPEG/FFMPEG.cmake -git commit -m "build: build static-only FFmpeg for macOS deps" -``` - ---- - -### Task 3: Remove the old macOS player - -**Files:** -- Delete: `src/slic3r/GUI/wxMediaCtrl2.mm` -- Delete: `src/slic3r/GUI/BambuPlayer/BambuPlayer.h` (and the empty `BambuPlayer/` dir) -- Modify: `src/slic3r/GUI/wxMediaCtrl2.h` (remove the `#ifdef __WXMAC__` section, lines 22–60) - -**Interfaces:** -- Consumes: Task 1 (macOS no longer references `wxMediaCtrl2` — nothing includes `wxMediaCtrl2.h` on macOS anymore; `wxMediaCtrl2` is never instantiated on any platform). -- Produces: a clean tree where the old BambuPlayer-based player is gone from macOS. The `BambuPlayer` ObjC class itself remains inside the network plugin (external prebuilt binary) — only the GUI-side consumer is removed. - -- [ ] **Step 1: Delete the old player files** - -```bash -git rm src/slic3r/GUI/wxMediaCtrl2.mm -git rm src/slic3r/GUI/BambuPlayer/BambuPlayer.h -rmdir src/slic3r/GUI/BambuPlayer 2>/dev/null || true -``` - -- [ ] **Step 2: Strip the __WXMAC__ section from wxMediaCtrl2.h** - -In `src/slic3r/GUI/wxMediaCtrl2.h`, remove the entire macOS branch of the `#ifdef __WXMAC__` guard — from `#ifdef __WXMAC__` (line 22) through the closing `};` of the mac class (line 60), and the `#else` marker — leaving only the non-mac `class wxMediaCtrl2 : public wxMediaCtrl { ... };` definition followed by the final `#endif /* wxMediaCtrl2_h */`. The resulting file keeps its `#ifndef`/`#endif` include guard pair balanced. - -The file stays on disk because Win/Linux compile `wxMediaCtrl2.cpp`, which includes it. - -- [ ] **Step 3: Grep for leftover references** - -```bash -grep -rn "wxMediaCtrl2.mm\|BambuPlayer/BambuPlayer.h\|BambuPlayer" src/slic3r --include="*.cpp" --include="*.h" --include="*.mm" --include="*.txt" -``` - -Expected: no hits in `src/slic3r/GUI` (ignore `localization/i18n/list.txt:196`, which lists the Win/Linux `wxMediaCtrl2.cpp` and stays). - -- [ ] **Step 4: Rebuild the app** - -```bash -cmake --build build_arm64 --config RelWithDebInfo --target OrcaSlicer -``` - -Expected: configure + compile + link succeed with the deleted files gone. - -- [ ] **Step 5: Commit** - -```bash -git add -A src/slic3r/GUI -git commit -m "refactor: remove old BambuPlayer-based media player from macOS" -``` - ---- - -### Task 4: Runtime verification on hardware - -**Files:** none — manual verification. - -**Interfaces:** consumes all prior tasks. Final gate: the new player must actually stream on a Mac. - -- [ ] **Step 1: Launch the freshly built app** - -```bash -open build_arm64/src/RelWithDebInfo/OrcaSlicer.app -``` - -Expected: app launches normally; no crash in the network/device subsystem. - -- [ ] **Step 2: Load the network plugin and open the Device tab** - -Log in / ensure the network plugin (`libBambuSource.dylib`) loads, select a printer, open the Device tab (camera monitoring panel). - -Expected: the camera preview area shows the idle image initially (no crash — this exercises `wxMediaCtrl3::SetIdleImage` and the `wxImage` load path on macOS for the first time). - -- [ ] **Step 3: Start the stream and watch it render** - -Click play / wait for `MediaPlayCtrl` to start the stream. - -Expected: live video renders in the panel. Check the console/log output (`BOOST_LOG` goes to the terminal if run from it, or check the log file): -- `stat_log ...` lines appear (the `EVT_MEDIA_CTRL_STAT` path is live — proves the Bambu C API dlsym worked from `libBambuSource.dylib`); -- no repeated decode/error messages like `AVVideoDecoder: ...` or `can not find function ...` (proves `StaticBambuLib::get` resolved all Bambu functions); -- Stop/Play toggle works; idle image reappears on stop; -- window resize keeps aspect ratio (exercises `DoSetSize`/`adjust_frame_size`/`paintEvent`). - -- [ ] **Step 4: Confirm the old player is really gone** - -Expected: nothing in the logs references `BambuPlayer` (the ObjC class is no longer dlsym'd); the video path is entirely `wxMediaCtrl3` + `AVVideoDecoder`. - -If a printer is unavailable, at minimum verify Steps 1–2 (launch + idle image) and note in the PR that live-stream verification needs hardware. - -- [ ] **Step 5: Final review pass** - -```bash -git log --oneline -6 -git show --stat HEAD # and each of the three task commits -``` - -Expected: the last 4 commits are the design doc + the 3 implementation tasks (each task commit touches only its listed files). Review the diff for scope: no Linux/Windows changes beyond the two `EVT_MEDIA_CTRL_STAT` lines in Task 1, no `StatusPanel`/`MediaPlayCtrl` changes. diff --git a/docs/superpowers/specs/2026-07-23-wx-inspectable-on-dpiaware-design.md b/docs/superpowers/specs/2026-07-23-wx-inspectable-on-dpiaware-design.md deleted file mode 100644 index 3a4e873af6..0000000000 --- a/docs/superpowers/specs/2026-07-23-wx-inspectable-on-dpiaware-design.md +++ /dev/null @@ -1,102 +0,0 @@ -# Move `wxInspectable` into `DPIAware` — Design Spec - -Date: 2026-07-23 -Branch: `dev/layout-inspector` - -## Overview - -Move the `wxInspector::wxInspectable` base class from individual leaf classes (`DPIDialog`, `MainFrame`) into the common `DPIAware

` template. This makes every DPIAware widget automatically visible in the inspector tree without requiring each subclass to opt in. - -## Motivation - -Currently, only `DPIDialog` and `MainFrame` explicitly inherit `wxInspectable`. `DPIFrame` (which `MainFrame` inherits from) does not — `MainFrame` adds it manually. This means: - -- Any `DPIAware` widget that isn't `DPIDialog` or `MainFrame` is invisible in the inspector tree -- `DPIFrame` subclasses (`BaseTransparentDPIFrame`, `ImageDPIFrame`, `ModelMallDialog`, `MediaFileFrame`, `SecondaryCheckDialog`, `PrintErrorDialog`, etc.) don't appear -- Adding a new DPIAware widget type requires remembering to also inherit `wxInspectable` - -Moving `wxInspectable` to `DPIAware` fixes this for all current and future DPIAware widgets at once. - -## Design - -### Change 1: `GUI_Utils.hpp` — `DPIAware

` - -Add `wxInspector::wxInspectable` as a second base class, and call `SetupInspectorAccelerator(this)` in the constructor (after `this->CenterOnParent()`): - -```cpp -// Before: -template class DPIAware : public P - -// After: -template class DPIAware : public P, public wxInspector::wxInspectable -``` - -Add in the constructor body (after `this->CenterOnParent()` at line 110): -```cpp -SetupInspectorAccelerator(this); -``` - -This gives every `DPIAware` widget both inspectability and the Ctrl+Shift+I keyboard shortcut automatically. `#include ` is already present in the file. - -### Change 2: `GUI_Utils.hpp` — `DPIDialog` - -Remove the now-redundant `wxInspector::wxInspectable` and the `SetupInspectorAccelerator(this)` call: - -```cpp -// Before: -class DPIDialog : public DPIAware, public wxInspector::wxInspectable -// ... - SetupInspectorAccelerator(this); - -// After: -class DPIDialog : public DPIAware -// (SetupInspectorAccelerator call removed — now done in DPIAware constructor) -``` - -`DPIDialog` gets `wxInspectable` and the accelerator through `DPIAware` now. - -### Change 3: `MainFrame.hpp` — `MainFrame` - -Remove the now-redundant `wxInspector::wxInspectable`: - -```cpp -// Before: -class MainFrame : public DPIFrame, public wxInspector::wxInspectable - -// After: -class MainFrame : public DPIFrame -``` - -`MainFrame` gets `wxInspectable` through `DPIFrame` → `DPIAware`. - -### Change 4: `MainFrame.cpp` — `MainFrame` constructor - -Remove the now-redundant `SetupInspectorAccelerator(this)` call (line 304). It will be called automatically by the `DPIAware` constructor. - -## Impact - -| Widget | Before | After | -|--------|--------|-------| -| `DPIDialog` subclasses (~80) | ✓ inspectable | ✓ inspectable (transitive) | -| `MainFrame` | ✓ inspectable | ✓ inspectable (transitive) | -| `DPIFrame` subclasses (8 others) | ✗ invisible | ✓ inspectable | -| Future `DPIAware` | ✗ invisible | ✓ inspectable | - -## Files Modified - -| File | Change | -|------|--------| -| `src/slic3r/GUI/GUI_Utils.hpp` | `DPIAware

` gains `wxInspector::wxInspectable` + `SetupInspectorAccelerator(this)` call; `DPIDialog` drops redundant `wxInspector::wxInspectable` and `SetupInspectorAccelerator(this)` | -| `src/slic3r/GUI/MainFrame.hpp` | `MainFrame` drops redundant `wxInspector::wxInspectable` | -| `src/slic3r/GUI/MainFrame.cpp` | Remove redundant `SetupInspectorAccelerator(this)` from MainFrame constructor | - -## Non-Goals - -- The `DPIAwarePlugin` detection logic (`dynamic_cast` / `dynamic_cast`) is unchanged -- No new DPI properties — this is purely about tree visibility and accelerator setup - -## Risk Assessment - -- **Multiple inheritance**: `DPIAware

` already has a vtable (virtual destructor). Adding `wxInspectable` adds a second base but no additional data members. The `wxInspector::wxInspectable` class is expected to be a lightweight marker interface. -- **Build**: No new includes needed; `` is already included in `GUI_Utils.hpp`. -- **Cross-platform**: The change is standard C++ multiple inheritance — no platform-specific concerns. diff --git a/docs/superpowers/specs/2026-07-23-wx-inspector-plugins-design.md b/docs/superpowers/specs/2026-07-23-wx-inspector-plugins-design.md deleted file mode 100644 index 5715635969..0000000000 --- a/docs/superpowers/specs/2026-07-23-wx-inspector-plugins-design.md +++ /dev/null @@ -1,244 +0,0 @@ -# wxInspector Plugins for OrcaSlicer Custom Controls — Design Spec - -Date: 2026-07-23 -Branch: `dev/layout-inspector` - -## Overview - -Create wxInspector plugins that expose OrcaSlicer's custom widget properties in the inspector's property grid. Without these plugins, the inspector shows only generic wxWidgets properties — missing all DPI-awareness data, custom styling, and Orca-specific control state. - -## Goals - -1. **DPIAware properties** — Inspect and update `scale_factor`, `prev_scale_factor`, `em_unit`, and `normal_font` on any DPIAware-derived widget -2. **Custom widget properties** — Surface Orca-specific properties on `Button`, `CheckBox`, `TextInput`, `SwitchButton`, `ProgressBar`, `Label`, and `LabeledStaticBox` -3. **Minimal source changes** — Only add trivial (one-line) getters/setters to existing classes; no architectural refactoring of Orca's widget hierarchy - -## Non-Goals - -- Custom inspector panels or AUI tabs (use the existing property grid and method invoker) -- Python-plugin integration (this is C++ wxInspector, not Orca's Python plugin system) -- Event logging customization (the built-in event logger already works) - -## Architecture - -### Two Plugins - -| Plugin | Class | Files | -|--------|-------|-------| -| DPIAware plugin | `DPIAwarePlugin` | `DPIAwarePlugin.hpp`, `DPIAwarePlugin.cpp` | -| Custom widgets plugin | `CustomWidgetsPlugin` | `CustomWidgetsPlugin.hpp`, `CustomWidgetsPlugin.cpp` | -| Registration helper | inline function | `Registration.hpp` | - -All files live under `src/slic3r/Utils/wxInspectorPlugins/`. - -### Plugin Detection Strategy - -**DPIAware plugin**: Uses `dynamic_cast` and `dynamic_cast` as detection gates. `DPIFrame` = `DPIAware`, `DPIDialog` = `DPIAware`. Since these are concrete typedefs, `dynamic_cast` works at runtime. This covers `MainFrame`, `SettingsDialog`, and all 8 calibration dialogs (which inherit `DPIDialog`). - -**Custom widgets plugin**: Gates broadly on `CLASSINFO(wxWindow)`, then uses per-type `dynamic_cast` inside `GetProperties` to check each Orca-specific type. Only matching types append properties. - -### Registration - -A single `RegisterOrcaInspectorPlugins()` inline function in `Registration.hpp` creates both plugins as function-local statics (matching the wxInspector built-in provider pattern) and registers them via `wxInspector::RegisterPlugin()`. - -Called once from `MainFrame::MainFrame()` after `SetupInspectorAccelerator(this)`. - -### Why Separate Plugins? - -- DPIAware is a C++ template concept (not a wxClassInfo-isKindOf check), so it needs its own detection logic -- Custom widgets use standard wxClassInfo-based detection, matching the built-in provider pattern -- Two focused files are easier to review and maintain than one monolithic plugin -- Compile-time failure isolation: if a widget header changes, only one plugin breaks - -## DPIAware Plugin — Property Specification - -### Source Changes (GUI_Utils.hpp) - -Four one-liner methods added to the `DPIAware

` template class (public section): - -```cpp -float scale_factor() const { return m_scale_factor; } // already exists -float prev_scale_factor() const { return m_prev_scale_factor; } // already exists -int em_unit() const { return m_em_unit; } // already exists -void set_scale_factor(float v) { m_scale_factor = v; } // NEW -void set_prev_scale_factor(float v) { m_prev_scale_factor = v; } // NEW -void set_em_unit(int v) { m_em_unit = v; } // NEW -bool force_rescale() const { return m_force_rescale; } // NEW -// m_normal_font getter already exists: normal_font() -``` - -### Detection - -```cpp -bool CanProvideProperties(wxClassInfo* info) override { - // Gated in GetProperties via dynamic_cast on the window itself - return info->IsKindOf(CLASSINFO(wxWindow)); -} -``` - -In `GetProperties`: -```cpp -auto* win = obj.AsWindow(); -bool isDPI = dynamic_cast(win) || dynamic_cast(win); -if (!isDPI) return props; -``` - -### Property Table (category: "DPI Scaling") - -| Name | Type | Editable | Getter | Setter | -|------|------|----------|--------|--------| -| Scale Factor | String (float) | Yes | `dpi->scale_factor()` | `dpi->set_scale_factor(v)` | -| Prev Scale Factor | String (float) | Yes | `dpi->prev_scale_factor()` | `dpi->set_prev_scale_factor(v)` | -| EM Unit | Integer | Yes | `dpi->em_unit()` | `dpi->set_em_unit(v)` | -| Normal Font | ReadOnly | No | `dpi->normal_font().GetNativeFontInfoDesc()` | — | -| Force Rescale | Boolean (ReadOnly) | No | `dpi->force_rescale()` | — | - -**Note on setters**: The setters simply store values. They do NOT trigger a widget rescale/layout. To see the effect of a changed scale factor, use the inspector's Methods panel to call `Layout()` or resize the window — which triggers the DPI_CHANGED event path naturally. - -## Custom Widgets Plugin — Property Specification - -All properties are appended to the built-in wxWindow properties. Each widget type is independently detected via `dynamic_cast`. - -### Detection gates (in `GetProperties`) - -```cpp -auto* win = obj.AsWindow(); -if (auto* btn = dynamic_cast(win)) { addButtonProperties(btn, props); } -if (auto* cb = dynamic_cast(win)) { addCheckBoxProperties(cb, props); } -if (auto* ti = dynamic_cast(win)) { addTextInputProperties(ti, props); } -if (auto* sb = dynamic_cast(win)) { addSwitchButtonProperties(sb, props); } -if (auto* pb = dynamic_cast(win)) { addProgressBarProperties(pb, props); } -if (auto* lbl = dynamic_cast(win)) { addLabelProperties(lbl, props); } -if (auto* lsb = dynamic_cast(win)) { addLabeledStaticBoxProperties(lsb, props); } -``` - -### Orca Button (`Button`) — category: "Orca Button" - -| Name | Type | Editable | Getter | Setter | -|------|------|----------|--------|--------| -| Button Style | Choice | Yes | enum→string | string→enum | -| Button Type | Choice | Yes | enum→string | string→enum | -| Selected | Boolean | Yes | `m_selected` (needs getter) | `SetSelected(v)` | -| Active Icon | ReadOnly | No | icon name string | — | -| Inactive Icon | ReadOnly | No | icon name string | — | - -Choices for Button Style: `Regular`, `Confirm`, `Alert`, `Disabled` -Choices for Button Type: `Compact`, `Window`, `Choice`, `Parameter`, `Icon`, `Expanded` - -**Source changes needed**: Button's `m_selected` is private. Add one-liner getter: -```cpp -bool IsSelected() const { return m_selected; } -``` - -### Orca CheckBox (`CheckBox`) — category: "Orca CheckBox" - -| Name | Type | Editable | Getter | Setter | -|------|------|----------|--------|--------| -| Half Checked | Boolean | Yes | `m_half_checked` (needs getter) | `SetHalfChecked(v)` | - -**Source changes needed**: `m_half_checked` is private. Add one-liner getter: -```cpp -bool IsHalfChecked() const { return m_half_checked; } -``` - -### Orca TextInput (`TextInput`) — category: "Orca TextInput" - -| Name | Type | Editable | Getter | Setter | -|------|------|----------|--------|--------| -| Label | String | Yes | `GetLabel()` (inherited from wxWindow) | `SetLabel(v)` (exists) | -| Text Value | String | Yes | `GetTextCtrl()->GetValue()` (GetTextCtrl is public) | `GetTextCtrl()->SetValue(v)` | -| Corner Radius | Integer | Yes | `GetCornerRadius()` (NEW) | `SetCornerRadius(v)` (exists) | - -**Source changes needed**: Add one getter to `TextInput`: -```cpp -int GetCornerRadius() const { return static_cast(radius); } -``` -(`radius` is inherited from StaticBox. `SetCornerRadius(double)` already exists. `GetTextCtrl()` is already public.) - -### Orca SwitchButton (`SwitchButton`) — category: "Orca SwitchButton" - -| Name | Type | Editable | Getter | Setter | -|------|------|----------|--------|--------| -| Value | Boolean | Yes | existing getter | existing setter | - -### Orca ProgressBar (`ProgressBar`) — category: "Orca ProgressBar" - -| Name | Type | Editable | Getter | Setter | -|------|------|----------|--------|--------| -| Proportion | Float (0-1) | Yes | `pb->m_proportion` (public member) | `pb->m_proportion = v` | -| Show Number | Boolean | Yes | `pb->m_shownumber` (public member) | `pb->m_shownumber = v` | - -**No source changes needed**: `m_proportion` and `m_shownumber` are already public members. `SetValue(int)` and `SetProgress(int)` already exist as public methods. - -### Orca Label (`Label`) — category: "Orca Label" - -| Name | Type | Editable | Getter | Setter | -|------|------|----------|--------|--------| -| Is Hyperlink | Boolean | No | existing flag check | — | -| Font Size | ReadOnly | No | `GetFont().GetPointSize()` | — | - -### LabeledStaticBox — category: "LabeledStaticBox" - -| Name | Type | Editable | Getter | Setter | -|------|------|----------|--------|--------| -| Corner Radius | Integer | Yes | `GetCornerRadius()` (NEW) | `SetCornerRadius(v)` (exists) | -| Border Width | Integer | Yes | `GetBorderWidth()` (NEW) | `SetBorderWidth(v)` (exists) | -| Border Color | String (hex) | Yes | `GetBorderColor()` (NEW) | `SetBorderColor(v)` (exists) | -| Scale | Float (ReadOnly) | No | `m_scale` (protected, needs getter) | — | - -**Source changes needed**: Four one-liner getters added to `LabeledStaticBox`: -```cpp -int GetCornerRadius() const { return m_radius; } -int GetBorderWidth() const { return m_border_width; } -StateColor GetBorderColor() const { return border_color; } -float GetScale() const { return m_scale; } -``` - -## Files Modified (Existing Code) - -| File | Changes | -|------|---------| -| `src/slic3r/GUI/GUI_Utils.hpp` | +4 methods in `DPIAware

`: `set_scale_factor()`, `set_prev_scale_factor()`, `set_em_unit()`, `force_rescale()` | -| `src/slic3r/GUI/Widgets/LabeledStaticBox.hpp` | +4 getter declarations: `GetCornerRadius()`, `GetBorderWidth()`, `GetBorderColor()`, `GetScale()` | -| `src/slic3r/GUI/Widgets/LabeledStaticBox.cpp` | +4 getter implementations | -| `src/slic3r/GUI/Widgets/Button.hpp` | +1 getter: `IsSelected()` | -| `src/slic3r/GUI/Widgets/CheckBox.hpp` | +1 getter: `IsHalfChecked()` | -| `src/slic3r/GUI/Widgets/TextInput.hpp` | +1 getter: `GetCornerRadius()` | -| `src/slic3r/GUI/Widgets/ProgressBar.hpp` | None (public members are used directly) | -| `src/slic3r/GUI/MainFrame.cpp` | +1 `#include`, +1 call to `RegisterOrcaInspectorPlugins()` | -| `src/slic3r/CMakeLists.txt` | +4 entries in `SLIC3R_GUI_SOURCES` (the .cpp plugin files) | - -## Files Created - -``` -src/slic3r/Utils/wxInspectorPlugins/ -├── DPIAwarePlugin.hpp -├── DPIAwarePlugin.cpp -├── CustomWidgetsPlugin.hpp -├── CustomWidgetsPlugin.cpp -└── Registration.hpp -``` - -## Build & Linking - -The `wxInspector` dependency is already wired: -- `deps/wxInspector/wxInspector.cmake` fetches and builds wxInspector -- `src/CMakeLists.txt` lines 92-93 link `wxInspector::wxInspector` into `wxWidgets_LIBRARIES` -- The plugin files only need `#include ` and `#include ` — both available from the installed dependency - -No new CMake dependencies needed. Only the new source files need listing in `SLIC3R_GUI_SOURCES`. - -## Error Handling & Edge Cases - -- **Stale pointers**: Plugin lambdas capture raw pointers, regenerated on every `GetProperties` call (matching wxInspector's built-in provider pattern). Pointers live only until the next tree selection. -- **Widget destruction**: If a widget is destroyed while the inspector is showing its properties, `InspectableObject::IsValid()` returns false and properties are not displayed. The inspector won't show stale data. -- **Invalid property values**: Setters use `sscanf` / `ToLong` with validation (matching built-in patterns). Bogus input is rejected — setter returns `false`, property grid shows error state. -- **DPI drift**: Setting `scale_factor` without triggering rescale means displayed sizes don't match the new factor. This is acceptable — the inspector is a developer tool; operators know to call `Layout()` after making changes. -- **Missing widget type**: If a `dynamic_cast` fails for all types, only built-in wxWindow properties are shown. No crash, no error — just reduced info. - -## Future Work (Out of Scope) - -- **StateColor visualization**: `StateColor` is a multi-value type (maps bitmask states to colors). A full solution would need a custom property editor (e.g., a table showing each state→color pair). Keep it simple for now. -- **ScalableBitmap display**: Could show the bitmap as an inline thumbnail. Complex property editor work — deferred. -- **More widget types**: `SwitchBoard`, `MultiSwitchButton`, `StepCtrl`, `FanControl`, `DropDown`, `ComboBox`, `AMS*` widgets could all benefit. Add as needed. -- **Property refresh on tree selection**: Currently properties are static snapshots. A "refresh" button or auto-poll could keep values current for rapidly-changing widgets (progress bars, etc.). The built-in wxInspector already provides a tree-refresh button. diff --git a/docs/superpowers/specs/2026-08-14-ffmpeg-player-macos-design.md b/docs/superpowers/specs/2026-08-14-ffmpeg-player-macos-design.md deleted file mode 100644 index 812a7bf735..0000000000 --- a/docs/superpowers/specs/2026-08-14-ffmpeg-player-macos-design.md +++ /dev/null @@ -1,110 +0,0 @@ -# FFmpeg Media Player for macOS — Design - -Date: 2026-08-14 -Branch: `dev/ffmpeg-player-macos` - -## Problem - -The branch's new FFmpeg-based media player (`wxMediaCtrl3` + `AVVideoDecoder`) is used on -Windows and Linux, but macOS still runs the old player: `wxMediaCtrl2.mm`, an ObjC -`BambuPlayer` class dlsym'd from the Bambu network plugin that renders via CALayer. -On macOS, `wxMediaCtrl3` is currently aliased to `wxMediaCtrl2` and FFmpeg is not linked -into the app at all. - -Goal: make macOS use the same FFmpeg player as Windows/Linux, linking the **static** -FFmpeg libraries from the deps build instead of dynamic ones. - -## Current state (verified) - -- New player (Win/Linux): `GUI/wxMediaCtrl3.cpp` + `GUI/AVVideoDecoder.cpp`. Decodes with - FFmpeg (libavcodec/libswscale/libavutil), renders frames into `wxImage` (non-Windows) / - `wxBitmap` (Windows) drawn in a `paintEvent`, feeds via the `Bambu_*` C API - (`BambuTunnel.h`, `BAMBU_DYNAMIC`) dlsym'd from the network plugin through - `StaticBambuLib::get()` (`GUI/Printer/PrinterFileSystem.cpp`, compiled on all platforms). -- Old player (macOS): `GUI/wxMediaCtrl2.mm` uses the ObjC `BambuPlayer` class found via - `dlsym(module, "OBJC_CLASS_$_BambuPlayer")` in `libBambuSource.dylib`. -- The macOS network plugin `libBambuSource.dylib` already exports the full Bambu C API - (verified with `nm`), so the new player needs zero plugin changes. -- FFmpeg linking in `src/slic3r/CMakeLists.txt` is guarded by `if (NOT APPLE)` — - macOS currently does not link FFmpeg. -- `deps/FFMPEG/FFMPEG.cmake`: non-MSVC branch builds FFmpeg from source with - `--enable-shared`. The existing arm64 deps build on the dev machine happened to be - configured with both static and shared enabled, so `libavcodec.a` / `libswscale.a` / - `libavutil.a` are already present at - `deps/build/arm64/OrcaSlicer_dep/usr/local/lib/`. -- `EVT_MEDIA_CTRL_STAT` is `wxDEFINE_EVENT`'d in `wxMediaCtrl2.cpp` (Win/Linux) and - `wxMediaCtrl2.mm` (macOS); the define in `wxMediaCtrl3.cpp` is commented out. -- `wxMediaCtrl2` is never instantiated anywhere on any platform — dead code. -- `StatusPanel` already creates `wxMediaCtrl3`; `MediaPlayCtrl` only uses the - `wxMediaCtrl3` interface (`Load/Play/Stop/GetState/GetVideoSize/GetLastError/SetIdleImage`), - so no UI-side changes are needed. - -## Approach (approved) - -**Reuse the shared player on macOS.** Compile the existing `wxMediaCtrl3.cpp` + -`AVVideoDecoder.cpp` on macOS so all three platforms run one implementation. -Rendering uses the existing `wxImage` → `DrawBitmap` paint path, identical to Linux. -Known trade-off: frames are scaled to the widget's logical (1x) size, so Retina is -slightly soft compared to the old CALayer player. Accepted for now; a Retina-aware -scaling follow-up is possible later. - -Rejected alternative: a native CGImage/CALayer renderer for macOS — faster and -Retina-crisp, but adds a second render implementation to maintain. - -## Changes - -### 1. Enable the FFmpeg player on macOS (source) - -- `GUI/wxMediaCtrl3.h`: remove the `#ifdef __WXMAC__` branch (lines 18–22) that aliases - `wxMediaCtrl3` → `wxMediaCtrl2`. macOS then compiles the real `wxMediaCtrl3` class, - including the `BAMBU_DYNAMIC` BambuTunnel path used on Linux. -- Event symbol fix: move `wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent)` into - `wxMediaCtrl3.cpp` (uncomment the existing line) and remove it from - `wxMediaCtrl2.cpp`. One definition total in the lib; all three platforms resolve it. - -### 2. Static FFmpeg linking (deps + app) - -- `deps/FFMPEG/FFMPEG.cmake`: in the non-MSVC branch, pass - `--disable-shared --enable-static` when `APPLE`. Linux keeps `--enable-shared`; - Windows keeps its prebuilt shared DLL zips. Fresh macOS deps builds install only - `libavcodec.a` / `libswscale.a` / `libavutil.a` — no dylibs to bundle, no - rpath/install_name handling. (The existing local arm64 deps build already contains - the `.a` files, so no deps rebuild is strictly needed to try the change locally, - but a fresh CI deps build must produce them.) -- `src/slic3r/CMakeLists.txt`: - - APPLE branch of `SLIC3R_GUI_SOURCES`: add `GUI/wxMediaCtrl3.cpp`, - `GUI/wxMediaCtrl3.h`, `GUI/AVVideoDecoder.cpp`, `GUI/AVVideoDecoder.hpp`; - remove `GUI/wxMediaCtrl2.mm` and `GUI/wxMediaCtrl2.h` (the `.h` stays on - disk for the Win/Linux build of `wxMediaCtrl2.cpp`, but nothing on macOS - includes it after this change). - - Add an APPLE mirror of the `NOT APPLE` FFmpeg block: `find_library` for - `libavcodec.a`, `libswscale.a`, `libavutil.a` under `${CMAKE_PREFIX_PATH}/lib` - with `NO_DEFAULT_PATH`, link them (order avcodec → swscale → avutil), and add - `${CMAKE_PREFIX_PATH}/include` as a SYSTEM include directory. Deps are built with - `--disable-zlib` and no external codecs, so the three static libs link cleanly. - -### 3. Remove the old player - -- Delete `GUI/wxMediaCtrl2.mm` and `GUI/BambuPlayer/BambuPlayer.h` (header used only - by the `.mm`; the real `BambuPlayer` lives inside the network plugin). -- Remove the now-dead `__WXMAC__` section of `GUI/wxMediaCtrl2.h`. -- `wxMediaCtrl2.cpp` (Win/Linux) stays in the build as-is (dead but harmless; out of - scope to remove on this branch). - -### 4. Verification - -- Build on macOS: `cmake --build build_arm64` (or `build/arm64`). -- Confirm no dynamic FFmpeg dependency: `otool -L` on the app binary shows no `libav*` - dylib references. -- Runtime: with the network plugin loaded, the Device tab camera preview streams via - the FFmpeg player (check the device page / `MediaPlayCtrl`). -- macOS `ctest` still passes — static linking means no test-executable `.so` copying - hacks (unlike the Linux shared-lib setup). - -## Out of scope - -- Linux (shared libs, AppImage/flatpak bundling) and Windows (prebuilt DLL zips) - keep their current FFmpeg setup. -- Retina-aware frame scaling / native CGImage rendering (follow-up if visual quality - is judged insufficient). -- Audio streaming (neither player plays audio in this UI path). diff --git a/localization/i18n/OrcaSlicer.pot b/localization/i18n/OrcaSlicer.pot index db62fb4443..a063ab0484 100644 --- a/localization/i18n/OrcaSlicer.pot +++ b/localization/i18n/OrcaSlicer.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -2293,8 +2293,6 @@ msgstr "" msgid "%s has been removed." msgstr "" -msgid "Switching application language" -msgstr "" msgid "Select the language" msgstr "" @@ -4571,6 +4569,9 @@ msgid "" "No - Cancel enabling spiral mode" msgstr "" +msgid "N/A" +msgstr "" + msgid "Printing" msgstr "" @@ -4994,9 +4995,7 @@ msgstr "" #, possible-c-format, possible-boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" #, possible-boost-format @@ -5019,9 +5018,6 @@ msgstr "" msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "" -msgid "N/A" -msgstr "" - msgid "System agents" msgstr "" @@ -5951,6 +5947,12 @@ msgstr "" msgid "Save current project as" msgstr "" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "" @@ -7696,6 +7698,12 @@ msgstr "" msgid "Customized Preset" msgstr "" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "" @@ -8074,6 +8082,17 @@ msgstr "" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "" +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "" @@ -8271,8 +8290,6 @@ msgstr "" msgid "Language selection" msgstr "" -msgid "Switching application language while some presets are modified." -msgstr "" msgid "Asia-Pacific" msgstr "" @@ -9124,9 +9141,6 @@ msgstr "" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "" -msgid "Publish" -msgstr "" - msgid "Publish was canceled" msgstr "" @@ -9142,6 +9156,64 @@ msgstr "" msgid "Jump to webpage" msgstr "" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, possible-c-format, possible-boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, possible-c-format, possible-boost-format msgid "Save %s as" msgstr "" @@ -19337,6 +19409,15 @@ msgstr "" msgid "Skipping objects." msgstr "" +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "" diff --git a/localization/i18n/ca/OrcaSlicer_ca.po b/localization/i18n/ca/OrcaSlicer_ca.po index 2b42f300ac..4fee47786f 100644 --- a/localization/i18n/ca/OrcaSlicer_ca.po +++ b/localization/i18n/ca/OrcaSlicer_ca.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2025-03-15 10:55+0100\n" "Last-Translator: \n" "Language-Team: \n" @@ -2522,8 +2522,6 @@ msgstr "Hi ha una actualització disponible. Obriu el quadre de diàleg del paqu msgid "%s has been removed." msgstr "%s s'ha eliminat." -msgid "Switching application language" -msgstr "Canvi d'idioma de l'aplicació" msgid "Select the language" msgstr "Seleccioneu l'idioma" @@ -4995,6 +4993,9 @@ msgstr "" "Sí: canviar aquesta configuració i activar el mode d'espiral automàticament\n" "No - Renunciar a utilitzar el mode espiral aquesta vegada" +msgid "N/A" +msgstr "N/D" + msgid "Printing" msgstr "Imprimint" @@ -5428,13 +5429,9 @@ msgstr "El valor %s està fora de rang. El rang vàlid és de %d a %d." #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"És %s%% or %s %s?\n" -"SÍ per %s%%.\n" -"NO per %s %s." +"És %s%% or %s %s?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5456,9 +5453,6 @@ msgstr "Patró no vàlid. Utilitzeu N, N#K, o una llista separada per comes amb msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Format no vàlid. Format vectorial esperat: \"%1%\"" -msgid "N/A" -msgstr "N/D" - # AI Translated msgid "System agents" msgstr "Agents del sistema" @@ -6421,6 +6415,12 @@ msgstr "Desa el projecte com a" msgid "Save current project as" msgstr "Desar el projecte actual com" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importar 3MF STL/STEP/SVG/OBJ/AMF" @@ -8275,6 +8275,12 @@ msgstr "Confirmeu que els Codis-G d'aquests perfils són segurs per evitar danys msgid "Customized Preset" msgstr "Perfil personalitzat" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + # AI Translated msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Els noms dels components dins del fitxer STEP no tenen format UTF-8!" @@ -8687,6 +8693,17 @@ msgstr "Desa el fitxer Laminat com a:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "El fitxer %s s'ha enviat a l'emmagatzematge de la impressora i es pot visualitzar a la impressora." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Publicar" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "El tipus de broquet no està establert. Establiu el broquet i torneu-ho a provar." @@ -8901,8 +8918,6 @@ msgstr "Voleu continuar?" msgid "Language selection" msgstr "Selecció d'idiomes" -msgid "Switching application language while some presets are modified." -msgstr "Canviant l'idioma de l'aplicació mentre es modifiquen alguns perfils." msgid "Asia-Pacific" msgstr "Àsia-Pacífic" @@ -9880,9 +9895,6 @@ msgstr "Anar a la pàgina web de publicació de models" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Nota: La preparació pot trigar uns quants minuts. Si us plau, sigui pacient." -msgid "Publish" -msgstr "Publicar" - msgid "Publish was canceled" msgstr "La publicació ha estat cancel·lada" @@ -9898,6 +9910,64 @@ msgstr "Carregant dades" msgid "Jump to webpage" msgstr "Anar a la pàgina web" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Desar %s com a" @@ -21387,6 +21457,15 @@ msgstr "Aquesta acció no es pot desfer. Continuar?" msgid "Skipping objects." msgstr "Ometent objectes." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Seleccioneu filament" diff --git a/localization/i18n/cs/OrcaSlicer_cs.po b/localization/i18n/cs/OrcaSlicer_cs.po index d2ef18cd2a..f79c90bcd1 100644 --- a/localization/i18n/cs/OrcaSlicer_cs.po +++ b/localization/i18n/cs/OrcaSlicer_cs.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: \n" "Last-Translator: Jakub Hencl\n" "Language-Team: \n" @@ -2482,8 +2482,6 @@ msgstr "Je k dispozici aktualizace. Otevřete dialog balíčku předvoleb a prov msgid "%s has been removed." msgstr "%s bylo odstraněno." -msgid "Switching application language" -msgstr "Přepnutí jazyka aplikace" msgid "Select the language" msgstr "Zvolte jazyk" @@ -4953,6 +4951,10 @@ msgstr "" "Ano – změnit tato nastavení a automaticky povolit spirálový režim\n" "Ne – tentokrát nepoužít spirálový režim" +# AI Translated +msgid "N/A" +msgstr "N/A" + msgid "Printing" msgstr "Tisk" @@ -5384,13 +5386,9 @@ msgstr "Hodnota %s je mimo rozsah. Platný rozsah je od %d do %d." #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"Je to %s%% nebo %s %s?\n" -"ANO pro %s%%,\n" -"NE pro %s %s." +"Je to %s%% nebo %s %s?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5412,10 +5410,6 @@ msgstr "Neplatný vzor. Použijte N, N#K nebo seznam oddělený čárkami s voli msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Neplatný formát. Očekávaný vektorový formát: \"%1%\"" -# AI Translated -msgid "N/A" -msgstr "N/A" - # AI Translated msgid "System agents" msgstr "Systémoví agenti" @@ -6382,6 +6376,12 @@ msgstr "Uložit projekt jako" msgid "Save current project as" msgstr "Uložit aktuální projekt jako" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importovat 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8237,6 +8237,12 @@ msgstr "Potvrďte prosím, že je G-code v těchto předvolbách bezpečný, aby msgid "Customized Preset" msgstr "Přizpůsobená předvolba" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Názvy komponent v souboru STEP nejsou ve formátu UTF-8!" @@ -8647,6 +8653,17 @@ msgstr "Uložit rozřezaný soubor jako:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Soubor %s byl odeslán do úložiště tiskárny a lze jej zobrazit na tiskárně." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Publikovat" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Typ trysky není nastaven. Nastavte prosím trysku a zkuste to znovu." @@ -8859,8 +8876,6 @@ msgstr "Chcete pokračovat?" msgid "Language selection" msgstr "Výběr jazyka" -msgid "Switching application language while some presets are modified." -msgstr "Přepnutí jazyka aplikace, když jsou některé předvolby upraveny." msgid "Asia-Pacific" msgstr "Asie-Pacifik" @@ -9828,9 +9843,6 @@ msgstr "Přejít na webovou stránku publikace modelu" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Poznámka: Příprava může trvat několik minut. Buďte prosím trpěliví." -msgid "Publish" -msgstr "Publikovat" - msgid "Publish was canceled" msgstr "Publikování bylo zrušeno" @@ -9846,6 +9858,64 @@ msgstr "Nahrávání dat" msgid "Jump to webpage" msgstr "Přejít na webovou stránku" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Uložit %s jako" @@ -21331,6 +21401,15 @@ msgstr "Tuto akci nelze vrátit zpět. Pokračovat?" msgid "Skipping objects." msgstr "Přeskakování objektů." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + # AI Translated msgid "Select Filament" msgstr "Vybrat filament" diff --git a/localization/i18n/de/OrcaSlicer_de.po b/localization/i18n/de/OrcaSlicer_de.po index 4ff61fb6ea..2a3e4e19d6 100644 --- a/localization/i18n/de/OrcaSlicer_de.po +++ b/localization/i18n/de/OrcaSlicer_de.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: \n" "Last-Translator: Heiko Liebscher \n" "Language-Team: \n" @@ -2430,8 +2430,6 @@ msgstr "Es ist ein Update verfügbar. Öffnen Sie den Profilbündel-Dialog, um e msgid "%s has been removed." msgstr "%s wurde entfernt." -msgid "Switching application language" -msgstr "Wechsel der Sprache" msgid "Select the language" msgstr "Sprache wählen" @@ -4859,6 +4857,9 @@ msgstr "" "Ja - Diese Einstellungen ändern und den Spiralmodus automatisch aktivieren\n" "Nein - Spiralmodus nicht aktivieren" +msgid "N/A" +msgstr "Nicht verfügbar" + msgid "Printing" msgstr "Drucken" @@ -5290,13 +5291,9 @@ msgstr "Wert %s ist außerhalb des Bereichs. Der gültige Bereich liegt zwischen #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"Heißt es %s%% oder %s %s?\n" -"Ja für %s%%, \n" -"Nein für %s %s." +"Heißt es %s%% oder %s %s?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5318,9 +5315,6 @@ msgstr "Ungültiges Muster. Verwenden Sie N, N#K oder eine durch Kommas getrennt msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Ungültiges Format. Erwartetes Vektorformat: \"%1%\"" -msgid "N/A" -msgstr "Nicht verfügbar" - # AI Translated msgid "System agents" msgstr "Systemagenten" @@ -6274,6 +6268,12 @@ msgstr "Projekt speichern als" msgid "Save current project as" msgstr "Aktuelles Projekt speichern als" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importiere 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8107,6 +8107,12 @@ msgstr "Bitte bestätigen Sie, dass die G-Codes innerhalb dieser Profile sicher msgid "Customized Preset" msgstr "Benutzerdefinierte Profile" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Der Name der Komponenten in der Step-Datei ist nicht im UTF-8-Format!" @@ -8519,6 +8525,17 @@ msgstr "Geslicte Datei speichern unter:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Die Datei %s wurde an den Speicher des Druckers gesendet und kann auf dem Drucker angezeigt werden." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Veröffentlichen" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Die Düsenart ist nicht eingestellt. Bitte stellen Sie die Düse ein und versuchen Sie es erneut." @@ -8731,8 +8748,6 @@ msgstr "Möchten Sie fortfahren?" msgid "Language selection" msgstr "Sprachauswahl" -msgid "Switching application language while some presets are modified." -msgstr "Umschalten der Anwendungssprache, während einige Profile geändert werden." msgid "Asia-Pacific" msgstr "Asien-Pazifik" @@ -9662,9 +9677,6 @@ msgstr "Zur Modellveröffentlichungs-Webseite springen" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Hinweis: Die Vorbereitung kann einige Minuten dauern. Bitte haben Sie Geduld." -msgid "Publish" -msgstr "Veröffentlichen" - msgid "Publish was canceled" msgstr "Veröffentlichung wurde abgebrochen" @@ -9680,6 +9692,64 @@ msgstr "Daten werden hochgeladen" msgid "Jump to webpage" msgstr "Zu einer Website springen" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "%s speichern als" @@ -20816,6 +20886,15 @@ msgstr "Diese Aktion kann nicht rückgängig gemacht werden. Fortsetzen?" msgid "Skipping objects." msgstr "Objekte werden übersprungen." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Filament auswählen" diff --git a/localization/i18n/en/OrcaSlicer_en.po b/localization/i18n/en/OrcaSlicer_en.po index 67d4d196d4..37485ba662 100644 --- a/localization/i18n/en/OrcaSlicer_en.po +++ b/localization/i18n/en/OrcaSlicer_en.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2026-06-17 15:44-0300\n" "Last-Translator: Alexandre Folle de Menezes\n" "Language-Team: \n" @@ -2289,8 +2289,6 @@ msgstr "" msgid "%s has been removed." msgstr "" -msgid "Switching application language" -msgstr "" msgid "Select the language" msgstr "" @@ -4567,6 +4565,9 @@ msgid "" "No - Cancel enabling spiral mode" msgstr "" +msgid "N/A" +msgstr "" + msgid "Printing" msgstr "" @@ -4990,9 +4991,7 @@ msgstr "" #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" #, boost-format @@ -5015,9 +5014,6 @@ msgstr "" msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "" -msgid "N/A" -msgstr "" - msgid "System agents" msgstr "" @@ -5947,6 +5943,12 @@ msgstr "" msgid "Save current project as" msgstr "" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "" @@ -7692,6 +7694,12 @@ msgstr "" msgid "Customized Preset" msgstr "" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "" @@ -8070,6 +8078,17 @@ msgstr "" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "" +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "" @@ -8267,8 +8286,6 @@ msgstr "" msgid "Language selection" msgstr "" -msgid "Switching application language while some presets are modified." -msgstr "" msgid "Asia-Pacific" msgstr "" @@ -9120,9 +9137,6 @@ msgstr "" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "" -msgid "Publish" -msgstr "" - msgid "Publish was canceled" msgstr "" @@ -9138,6 +9152,64 @@ msgstr "" msgid "Jump to webpage" msgstr "" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "" @@ -19333,6 +19405,15 @@ msgstr "" msgid "Skipping objects." msgstr "" +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "" diff --git a/localization/i18n/es/OrcaSlicer_es.po b/localization/i18n/es/OrcaSlicer_es.po index bc8e1e9f79..9dfcde53e5 100644 --- a/localization/i18n/es/OrcaSlicer_es.po +++ b/localization/i18n/es/OrcaSlicer_es.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: \n" "Last-Translator: Ian A. Bassi <>\n" "Language-Team: \n" @@ -2356,8 +2356,6 @@ msgstr "Hay una actualización disponible. Abra el cuadro de diálogo del paquet msgid "%s has been removed." msgstr "Se ha eliminado %s." -msgid "Switching application language" -msgstr "Cambiando el idioma de la aplicación" msgid "Select the language" msgstr "Seleccionar el idioma" @@ -4729,6 +4727,9 @@ msgstr "" "Sí - Cambiar estos ajustes y activar el modo espiral automáticamente\n" "No - Dejar de usar el modo espiral esta vez" +msgid "N/A" +msgstr "N/A" + msgid "Printing" msgstr "Imprimiendo" @@ -5154,13 +5155,9 @@ msgstr "El valor %s está fuera de rango. El rango válido es de %d a %d." #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"¿Es %s%% o %s %s?\n" -"SÍ para %s%%, \n" -"NO para %s %s." +"¿Es %s%% o %s %s?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5182,9 +5179,6 @@ msgstr "Patrón inválido. Use N, N#K, o una lista separada por comas con #K opc msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Formato inválido. Formato de vector esperado: \"%1%\"" -msgid "N/A" -msgstr "N/A" - msgid "System agents" msgstr "Agentes del sistema" @@ -6129,6 +6123,12 @@ msgstr "Guardar proyecto como" msgid "Save current project as" msgstr "Guardar el proyecto actual como" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importar 3MF/STL/STEP/SVG/OBJ/AMF" @@ -7910,6 +7910,12 @@ msgstr "¡Por favor, confirme que el G-Code dentro de los perfiles son seguros p msgid "Customized Preset" msgstr "Perfil Personalizado" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "¡El nombre de los componentes dentro del archivo de pasos no tiene formato UTF-8!" @@ -8305,6 +8311,17 @@ msgstr "Guardar el archivo laminado como:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "El archivo %s ha sido mandado al almacenamiento de la impresora y puede ser visualizado en la impresora." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Publicar" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "El tipo de boquilla no está establecido. Configure la boquilla e inténtelo de nuevo." @@ -8506,8 +8523,6 @@ msgstr "¿Quieres continuar?" msgid "Language selection" msgstr "Selección de idiomas" -msgid "Switching application language while some presets are modified." -msgstr "Cambiando idioma de la aplicación mientras se modifican algunos perfiles." msgid "Asia-Pacific" msgstr "Asia-Pacífico" @@ -9419,9 +9434,6 @@ msgstr "Ir a la página web de publicación de modelos" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Nota: La preparación puede llevar varios minutos. Por favor, sea paciente." -msgid "Publish" -msgstr "Publicar" - msgid "Publish was canceled" msgstr "La publicación fue cancelada" @@ -9437,6 +9449,64 @@ msgstr "Cargando datos" msgid "Jump to webpage" msgstr "Ir a la página web" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Guardar %s como" @@ -20409,6 +20479,15 @@ msgstr "Esta acción no se puede deshacer. ¿Continuar?" msgid "Skipping objects." msgstr "Omitiendo objetos." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Seleccionar Filamento" diff --git a/localization/i18n/eu/OrcaSlicer_eu.po b/localization/i18n/eu/OrcaSlicer_eu.po index 9ee2e6451d..b966a124cb 100644 --- a/localization/i18n/eu/OrcaSlicer_eu.po +++ b/localization/i18n/eu/OrcaSlicer_eu.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2026-07-20 13:33+0200\n" "Last-Translator: Manu Goiogana \n" "Language-Team: \n" @@ -2390,8 +2390,6 @@ msgstr "Eguneratze bat dago erabilgarri. Ireki aurrezarpen-paketeen elkarrizketa msgid "%s has been removed." msgstr "%s kendu da." -msgid "Switching application language" -msgstr "Aplikazioaren hizkuntza aldatzen" msgid "Select the language" msgstr "Hautatu hizkuntza" @@ -4771,6 +4769,9 @@ msgstr "" "Bai - Aldatu ezarpen hauek eta gaitu espiral/loreontzi modua automatikoki\n" "Ez - Utzi bertan behera espiral modua gaitzea" +msgid "N/A" +msgstr "N/A" + msgid "Printing" msgstr "Inprimatzen" @@ -5202,13 +5203,9 @@ msgstr "%s balioa tartetik kanpo dago. Baliozko tartea %d eta %d artekoa da." #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"%s%% edo %s %s da?\n" -"BAI %s%%-(r)entzat,\n" -"EZ %s %s-(r)entzat." +"%s%% edo %s %s da?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5231,9 +5228,6 @@ msgstr "Patroi baliogabea. Erabili N, N#K edo komaz bereizitako zerrenda bat, sa msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Formatuak ez du balio. Espero den formatu bektoriala: \"%1%\"" -msgid "N/A" -msgstr "N/A" - msgid "System agents" msgstr "Sistema-agenteak" @@ -6175,6 +6169,12 @@ msgstr "Gorde proiektua honela" msgid "Save current project as" msgstr "Gorde uneko proiektua honela" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Inportatu 3MF/STL/STEP/SVG/OBJ/AMF" @@ -7988,6 +7988,12 @@ msgstr "Berretsi aurrezarpen hauetako G-codea segurua dela, makinari kalterik ez msgid "Customized Preset" msgstr "Aurrezarpen pertsonalizatua" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "STEP fitxategiko osagai-izena(k) ez dago/daude UTF-8 formatuan!" @@ -8388,6 +8394,17 @@ msgstr "Gorde xerratutako fitxategia honela:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "%s fitxategia inprimagailuaren biltegiratze-eremura bidali da eta inprimagailuan ikus daiteke." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Argitaratu" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Pita mota ez dago ezarrita. Ezarri pita eta saiatu berriro." @@ -8587,8 +8604,6 @@ msgstr "Jarraitu nahi duzu?" msgid "Language selection" msgstr "Hizkuntza-hautaketa" -msgid "Switching application language while some presets are modified." -msgstr "Aplikazioaren hizkuntza aldatzen ari da aurrezarpen batzuk aldatuta dauden bitartean." msgid "Asia-Pacific" msgstr "Asia-Pazifikoa" @@ -9505,9 +9520,6 @@ msgstr "Joan modeloa argitaratzeko web-orrira" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Oharra: prestaketak minutu batzuk iraun ditzake. Izan pazientzia." -msgid "Publish" -msgstr "Argitaratu" - msgid "Publish was canceled" msgstr "Argitaratzea bertan behera utzi da" @@ -9523,6 +9535,64 @@ msgstr "Datuak igotzen" msgid "Jump to webpage" msgstr "Joan web-orrira" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Gorde %s honela" @@ -20596,6 +20666,15 @@ msgstr "Ekintza hau ezin da desegin. Jarraitu?" msgid "Skipping objects." msgstr "Objektuak saltatzen." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Hautatu filamentua" diff --git a/localization/i18n/fr/OrcaSlicer_fr.po b/localization/i18n/fr/OrcaSlicer_fr.po index e56aee63a7..9d8739babe 100644 --- a/localization/i18n/fr/OrcaSlicer_fr.po +++ b/localization/i18n/fr/OrcaSlicer_fr.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: Guislain Cyril, Thomas Lété\n" @@ -2414,8 +2414,6 @@ msgstr "Une mise à jour est disponible. Ouvrez la boîte de dialogue du paquet msgid "%s has been removed." msgstr "%s a été supprimé." -msgid "Switching application language" -msgstr "Changer la langue de l'application" msgid "Select the language" msgstr "Sélectionner la langue" @@ -4809,6 +4807,9 @@ msgstr "" "Oui - Modifiez ces paramètres et activez automatiquement le mode spirale/vase\n" "Non - Annuler l'activation du mode spirale" +msgid "N/A" +msgstr "N/A" + msgid "Printing" msgstr "Impression" @@ -5240,13 +5241,9 @@ msgstr "La valeur %s est hors plage. La plage valide est comprise entre %d et %d #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"Est-ce %s%% ou %s %s ?\n" -"OUI pour %s%%, \n" -"NON pour %s %s." +"Est-ce %s%% ou %s %s ?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5268,9 +5265,6 @@ msgstr "Motif invalide. Utilisez N, N#K, ou une liste séparée par des virgules msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Format invalide. Format vectoriel attendu : \"%1%\"" -msgid "N/A" -msgstr "N/A" - # AI Translated msgid "System agents" msgstr "Agents système" @@ -6223,6 +6217,12 @@ msgstr "Enregistrer le projet sous" msgid "Save current project as" msgstr "Enregistrer le projet actuel sous" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importer des fichiers 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8044,6 +8044,12 @@ msgstr "Veuillez vous assurer que les G-codes de ces préréglages sont sûrs af msgid "Customized Preset" msgstr "Préréglage personnalisé" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Le nom des composants dans le fichier STEP n'est pas au format UTF-8 !" @@ -8445,6 +8451,17 @@ msgstr "Enregistrer le fichier découpé sous :" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Le fichier %s a été envoyé vers l'espace de stockage de l'imprimante et peut être visualisé sur l'imprimante." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Publier" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Le type de buse n'est pas défini. Veuillez définir la buse et réessayer." @@ -8655,8 +8672,6 @@ msgstr "Voulez-vous continuer ?" msgid "Language selection" msgstr "Sélection de la langue" -msgid "Switching application language while some presets are modified." -msgstr "Changement de langue de l’application alors que certains préréglages sont modifiés." msgid "Asia-Pacific" msgstr "Asie-Pacifique" @@ -9576,9 +9591,6 @@ msgstr "Accéder à la page internet de publication des modèles" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Remarque : La préparation peut prendre plusieurs minutes. Veuillez patienter." -msgid "Publish" -msgstr "Publier" - msgid "Publish was canceled" msgstr "La publication a été annulée" @@ -9594,6 +9606,64 @@ msgstr "Téléversement des données" msgid "Jump to webpage" msgstr "Ouvrir la page internet" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Enregistrer %s sous" @@ -20708,6 +20778,15 @@ msgstr "Cette action ne peut pas être annulée. Continuer ?" msgid "Skipping objects." msgstr "Ignorer des objets." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Sélectionner le filament" diff --git a/localization/i18n/hu/OrcaSlicer_hu.po b/localization/i18n/hu/OrcaSlicer_hu.po index a15d471073..198d3decac 100644 --- a/localization/i18n/hu/OrcaSlicer_hu.po +++ b/localization/i18n/hu/OrcaSlicer_hu.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "Language: hu\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2460,8 +2460,6 @@ msgstr "Frissítés érhető el. Nyisd meg a beállításcsomag párbeszédablak msgid "%s has been removed." msgstr "%s eltávolítva." -msgid "Switching application language" -msgstr "Alkalmazás nyelvének váltása" msgid "Select the language" msgstr "Válaszd ki a nyelvet" @@ -4905,6 +4903,10 @@ msgstr "" "Igen - Módosítsd ezeket a beállításokat, és automatikusan kapcsold be a spirál módot\n" "Nem - Most ne kapcsold be a spirál módot" +# AI Translated +msgid "N/A" +msgstr "N/A" + msgid "Printing" msgstr "Nyomtatás" @@ -5336,13 +5338,9 @@ msgstr "%s érték tartományon kívül van. Az érvényes tartomány: %d - %d." #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"%s%% vagy %s %s?\n" -"IGEN %s%%, \n" -"NEM %s %s." +"%s%% vagy %s %s?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5364,10 +5362,6 @@ msgstr "Érvénytelen minta. Használj N, N#K formátumot, vagy vesszővel elvá msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Érvénytelen formátum. Elvárt vektor formátum: \"%1%\"" -# AI Translated -msgid "N/A" -msgstr "N/A" - # AI Translated msgid "System agents" msgstr "Rendszerügynökök" @@ -6325,6 +6319,12 @@ msgstr "Projekt mentése másként" msgid "Save current project as" msgstr "Jelenlegi projekt mentése másként" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importálás 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8159,6 +8159,12 @@ msgstr "Kérlek, győződj meg arról, hogy a beállításokban található G-k msgid "Customized Preset" msgstr "Egyedi beállítás" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "A STEP fájlon belüli komponens neve nem UTF-8 formátumban van!" @@ -8571,6 +8577,17 @@ msgstr "Szeletelt fájl mentése mint:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "A(z) %s fájlt elküldtük a nyomtató tárhelyére. A fájl a nyomtatón tekinthető meg." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Közzététel" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "A fúvókatípus nincs beállítva. Állítsd be a fúvókát, majd próbáld újra." @@ -8783,8 +8800,6 @@ msgstr "Szeretnéd folytatni?" msgid "Language selection" msgstr "Nyelv kiválasztása" -msgid "Switching application language while some presets are modified." -msgstr "Alkalmazás nyelvének átváltása, miközben egyes beállítások módosultak." msgid "Asia-Pacific" msgstr "Ázsia-Csendes-óceáni térség" @@ -9736,9 +9751,6 @@ msgstr "Ugrás a modell közzététele weboldalra" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Megjegyzés: Az előkészítés több percig is eltarthat. Kérlek, várj." -msgid "Publish" -msgstr "Közzététel" - msgid "Publish was canceled" msgstr "A közzététel törlésre került" @@ -9754,6 +9766,64 @@ msgstr "Adatok feltöltése" msgid "Jump to webpage" msgstr "Ugrás a weboldalra" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "%s mentése" @@ -21110,6 +21180,15 @@ msgstr "Ez a művelet nem vonható vissza. Folytatod?" msgid "Skipping objects." msgstr "Objektumok kihagyása." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Filament kiválasztása" diff --git a/localization/i18n/it/OrcaSlicer_it.po b/localization/i18n/it/OrcaSlicer_it.po index 9af642f536..bc9a0980f2 100644 --- a/localization/i18n/it/OrcaSlicer_it.po +++ b/localization/i18n/it/OrcaSlicer_it.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: \n" @@ -2466,8 +2466,6 @@ msgstr "È disponibile un aggiornamento. Apri la finestra di dialogo del bundle msgid "%s has been removed." msgstr "%s è stato rimosso." -msgid "Switching application language" -msgstr "Cambio lingua applicazione" msgid "Select the language" msgstr "Seleziona la lingua" @@ -4907,6 +4905,9 @@ msgstr "" "Sì - Modifica queste impostazioni ed abilita la modalità spirale automaticamente\n" "No - Annulla l'attivazione della modalità a spirale" +msgid "N/A" +msgstr "N/D" + msgid "Printing" msgstr "Stampa" @@ -5338,13 +5339,9 @@ msgstr "Il valore %s è fuori intervallo. L'intervallo valido è da %d a %d." #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"È %s%% o %s %s?\n" -"Sì per %s%%, \n" -"NO per %s %s." +"È %s%% o %s %s?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5366,9 +5363,6 @@ msgstr "Schema non valido. Utilizzare N, N#K o un elenco separato da virgole con msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Formato non valido. Formato vettoriale previsto: \"%1%\"" -msgid "N/A" -msgstr "N/D" - # AI Translated msgid "System agents" msgstr "Agenti di sistema" @@ -6327,6 +6321,12 @@ msgstr "Salva progetto con nome" msgid "Save current project as" msgstr "Salva progetto corrente con nome" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importa 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8163,6 +8163,12 @@ msgstr "Si prega di confermare che i G-code all'interno di questi profili sono s msgid "Customized Preset" msgstr "Profilo personalizzato" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Il nome dei componenti all'interno del file STEP non è in formato UTF-8!" @@ -8570,6 +8576,17 @@ msgstr "Salva file elaborato con nome:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Il file %s è stato inviato alla memoria della stampante e può essere visualizzato da lì." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Pubblica" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Il tipo di ugello non è impostato. Impostare l'ugello e riprovare." @@ -8784,8 +8801,6 @@ msgstr "Vuoi continuare?" msgid "Language selection" msgstr "Selezione lingua" -msgid "Switching application language while some presets are modified." -msgstr "Cambio lingua applicazione durante la modifica di alcuni profili." msgid "Asia-Pacific" msgstr "Asia-Pacifico" @@ -9754,9 +9769,6 @@ msgstr "Vai alla pagina web di pubblicazione del modello" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Nota: la preparazione può richiedere alcuni minuti. Si prega di avere pazienza." -msgid "Publish" -msgstr "Pubblica" - msgid "Publish was canceled" msgstr "La pubblicazione è stata annullata" @@ -9772,6 +9784,64 @@ msgstr "Caricamento dati" msgid "Jump to webpage" msgstr "Vai alla pagina web" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Salva %s con nome" @@ -21134,6 +21204,15 @@ msgstr "Questa azione non può essere annullata. Continuare?" msgid "Skipping objects." msgstr "Salto degli oggetti." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Seleziona filamento" diff --git a/localization/i18n/ja/OrcaSlicer_ja.po b/localization/i18n/ja/OrcaSlicer_ja.po index 8ee8813f1d..ed9960bb8a 100644 --- a/localization/i18n/ja/OrcaSlicer_ja.po +++ b/localization/i18n/ja/OrcaSlicer_ja.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: \n" @@ -2473,8 +2473,6 @@ msgstr "アップデートが利用可能です。プリセットバンドルの msgid "%s has been removed." msgstr "%sを削除しました。" -msgid "Switching application language" -msgstr "アプリケーション言語の切り替え" msgid "Select the language" msgstr "言語を選択" @@ -4920,6 +4918,10 @@ msgstr "" "はい - 変更して、スパイラルモードを有効にします\n" "いいえ - 変更せず、スパイラルモードを有効しません" +# AI Translated +msgid "N/A" +msgstr "N/A" + msgid "Printing" msgstr "造形中" @@ -5351,12 +5353,9 @@ msgstr "値%sは範囲外です。有効な範囲は%dから%dです。" #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"%s%% か、それとも %s %sですか?\n" -"%s%% の場合ははい、 %s %s はいいえ。" +"%s%% か、それとも %s %sですか?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5378,10 +5377,6 @@ msgstr "無効なパターンです。N、N#K、またはオプション#K付き msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "無効なフォーマット、%1%であるはずです。" -# AI Translated -msgid "N/A" -msgstr "N/A" - # AI Translated msgid "System agents" msgstr "システムエージェント" @@ -6336,6 +6331,12 @@ msgstr "プロジェクトを名前を付けて保存" msgid "Save current project as" msgstr "プロジェクトを名前を付けて保存" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "3MF/STL/STEP/SVG/OBJ/AMFをインポート" @@ -8177,6 +8178,12 @@ msgstr "これらのプリセット内のG-codeがマシンに損傷を与えな msgid "Customized Preset" msgstr "カスタマイズされたプリセット" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "ファイルのエンコーディング方式はUTF-8形式ではありません" @@ -8589,6 +8596,17 @@ msgstr "名前を付けて保存:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "%sを送信しました、プリンターにて確認できます" +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "公開する" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "ノズルタイプが設定されていません。ノズルを設定して再試行してください。" @@ -8802,8 +8820,6 @@ msgstr "続行しますか?" msgid "Language selection" msgstr "言語選択" -msgid "Switching application language while some presets are modified." -msgstr "アプリケーション言語を切り替える時に、プリセットの変更があります" msgid "Asia-Pacific" msgstr "アジア太平洋地域" @@ -9776,9 +9792,6 @@ msgstr "モデル公開ページに移動" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "注意: 準備するには数分かかる場合があります、暫くお待ち下さい。" -msgid "Publish" -msgstr "公開する" - msgid "Publish was canceled" msgstr "公開は取り消しました" @@ -9794,6 +9807,64 @@ msgstr "データをアップロード中" msgid "Jump to webpage" msgstr "ウェブページに移動" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "%sを名前つけて保存" @@ -21678,6 +21749,15 @@ msgstr "この操作は元に戻せません。続行しますか?" msgid "Skipping objects." msgstr "オブジェクトをスキップ中。" +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "フィラメントを選択" diff --git a/localization/i18n/ko/OrcaSlicer_ko.po b/localization/i18n/ko/OrcaSlicer_ko.po index c415c90195..16dc582437 100644 --- a/localization/i18n/ko/OrcaSlicer_ko.po +++ b/localization/i18n/ko/OrcaSlicer_ko.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2025-06-02 17:12+0900\n" "Last-Translator: crwusiz \n" "Language-Team: \n" @@ -2481,8 +2481,6 @@ msgstr "사용 가능한 업데이트가 있습니다. 사전 설정 번들 대 msgid "%s has been removed." msgstr "%s이(가) 제거되었습니다." -msgid "Switching application language" -msgstr "응용 프로그램 언어 전환" msgid "Select the language" msgstr "언어 선택" @@ -4931,6 +4929,10 @@ msgstr "" "예 - 이 설정을 변경하고 나선 모드를 자동으로 활성화합니다\n" "아니오 - 이번에는 나선 모드 사용을 포기합니다" +# AI Translated +msgid "N/A" +msgstr "해당 없음" + msgid "Printing" msgstr "출력 중" @@ -5362,13 +5364,9 @@ msgstr "값 %s이 범위를 벗어났습니다. 유효한 범위는 %d에서 %d #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"%s%% 또는 %s %s입니까?\n" -"%s%%에 대해 예,\n" -"%s %s에 대해 아니요." +"%s%% 또는 %s %s입니까?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5390,10 +5388,6 @@ msgstr "잘못된 패턴입니다. N, N#K 또는 항목당 선택적 #K가 있 msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "잘못된 형식입니다. 필요한 벡터 형식: \"%1%\"" -# AI Translated -msgid "N/A" -msgstr "해당 없음" - # AI Translated msgid "System agents" msgstr "시스템 에이전트" @@ -6350,6 +6344,12 @@ msgstr "프로젝트 다른 이름으로 저장" msgid "Save current project as" msgstr "현재 프로젝트 다른 이름으로 저장" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "3MF/STL/STEP/SVG/OBJ/AMF 가져오기" @@ -8195,6 +8195,12 @@ msgstr "이러한 사전 설정 내의 Gcode가 기계 손상을 방지할 수 msgid "Customized Preset" msgstr "사용자 정의 프리셋" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + # AI Translated msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "STEP 파일 내부의 구성 요소 이름이 UTF-8 형식이 아닙니다!" @@ -8622,6 +8628,17 @@ msgstr "슬라이스 파일을 다음으로 저장:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "%s 파일이 프린터의 저장 공간으로 전송되었으며 프린터에서 볼 수 있습니다." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "게시" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "노즐 유형이 설정되지 않았습니다. 노즐을 설정하고 다시 시도하세요." @@ -8837,8 +8854,6 @@ msgstr "계속하시겠습니까?" msgid "Language selection" msgstr "언어 선택" -msgid "Switching application language while some presets are modified." -msgstr "일부 사전 설정이 수정되는 동안 응용 프로그램 언어를 전환합니다." msgid "Asia-Pacific" msgstr "아시아 태평양" @@ -9865,9 +9880,6 @@ msgstr "모델 게시 웹 페이지로 이동" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "참고: 준비하는 데 몇 분 정도 걸릴 수 있습니다. 조금만 기다려 주십시오." -msgid "Publish" -msgstr "게시" - msgid "Publish was canceled" msgstr "게시가 취소되었습니다" @@ -9884,6 +9896,64 @@ msgstr "데이터 업로드 중" msgid "Jump to webpage" msgstr "웹 페이지로 이동" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "%s을(를) 다음으로 저장" @@ -21536,6 +21606,15 @@ msgstr "이 작업은 취소할 수 없습니다. 계속하시겠습니까?" msgid "Skipping objects." msgstr "물체 건너뛰기" +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "필라멘트 선택" diff --git a/localization/i18n/list.txt b/localization/i18n/list.txt index 5174679abf..a36ecd5cab 100644 --- a/localization/i18n/list.txt +++ b/localization/i18n/list.txt @@ -136,6 +136,7 @@ src/slic3r/GUI/BackgroundSlicingProcess.cpp src/slic3r/GUI/BedShapeDialog.cpp src/slic3r/GUI/BedShapeDialog.hpp src/slic3r/GUI/ConfigManipulation.cpp +src/slic3r/GUI/ConfigValueFormatter.cpp src/slic3r/GUI/DeviceManager.cpp src/slic3r/GUI/DeviceErrorDialog.cpp src/slic3r/GUI/ExtraRenderers.cpp @@ -175,6 +176,7 @@ src/slic3r/GUI/ProgressStatusBar.cpp src/slic3r/GUI/PlateSettingsDialog.cpp src/slic3r/GUI/PrivacyUpdateDialog.cpp src/slic3r/GUI/PublishDialog.cpp +src/slic3r/GUI/PublishSettingsDialog.cpp src/slic3r/GUI/SavePresetDialog.cpp src/slic3r/GUI/Search.cpp src/slic3r/GUI/Selection.cpp diff --git a/localization/i18n/lt/OrcaSlicer_lt.po b/localization/i18n/lt/OrcaSlicer_lt.po index b3458ec0e4..0141917d77 100644 --- a/localization/i18n/lt/OrcaSlicer_lt.po +++ b/localization/i18n/lt/OrcaSlicer_lt.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2026-07-02 14:13+0300\n" "Last-Translator: Gintaras Kučinskas \n" "Language-Team: \n" @@ -2449,8 +2449,6 @@ msgstr "Yra prieinamas atnaujinimas. Atidarykite profilių paketo dialogo langą msgid "%s has been removed." msgstr "%s buvo pašalintas." -msgid "Switching application language" -msgstr "Perjungiama programos kalba" msgid "Select the language" msgstr "Pasirinkite kalbą" @@ -4893,6 +4891,9 @@ msgstr "" "Taip – pakeisti šiuos nustatymus ir automatiškai įjungti spiralinį režimą\n" "Ne – nenaudoti spiralinio režimo" +msgid "N/A" +msgstr "N/A" + msgid "Printing" msgstr "Spausdinimas" @@ -5324,13 +5325,9 @@ msgstr "Reikšmė %s yra už ribų. Galimas diapazonas yra nuo %d iki %d." #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"Ar tai %s%% ar %s %s?\n" -"TAIP %s%%, \n" -"NE %s %s." +"Ar tai %s%% ar %s %s?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5352,9 +5349,6 @@ msgstr "Neteisingas šablonas. Naudokite N, N#K arba kableliais atskirtą sąra msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Netinkamas formatas. Tinkamas vektorinis formatas: \"%1%\"" -msgid "N/A" -msgstr "N/A" - # AI Translated msgid "System agents" msgstr "Sisteminiai agentai" @@ -6312,6 +6306,12 @@ msgstr "Įrašyti projektą kaip" msgid "Save current project as" msgstr "Įrašyti dabartinį projektą kaip" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importuoti 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8153,6 +8153,12 @@ msgstr "Patvirtinkite, kad šiuose profiliuose esantis G-kodas yra saugus, kad i msgid "Customized Preset" msgstr "Pritaikytas profilis" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Komponentų pavadinimai STEP faile nėra UTF-8 formato!" @@ -8564,6 +8570,17 @@ msgstr "Išsaugoti susluoksniuotą failą kaip:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Failas %s išsiųstas į spausdintuvo laikmeną ir gali būti peržiūrimas spausdintuve." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Talpinti" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Purkštuko tipas nenustatytas. Nustatykite purkštuką ir bandykite dar kartą." @@ -8774,8 +8791,6 @@ msgstr "Ar norite tęsti?" msgid "Language selection" msgstr "Kalbos pasirinkimas" -msgid "Switching application language while some presets are modified." -msgstr "Keičiama programos kalba, kai yra pakeistų profilių." msgid "Asia-Pacific" msgstr "Azija-Ramusis vandenynas" @@ -9693,9 +9708,6 @@ msgstr "Pereiti į modelio talpinimo interneto puslapį" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Pastaba: paruošimas gali užtrukti kelias minutes. Būkite kantrūs." -msgid "Publish" -msgstr "Talpinti" - msgid "Publish was canceled" msgstr "Publikavimas buvo atšauktas" @@ -9711,6 +9723,64 @@ msgstr "Įkeliami duomenys" msgid "Jump to webpage" msgstr "Pereiti į interneto puslapį" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Išsaugoti %s kaip" @@ -20865,6 +20935,15 @@ msgstr "Šio veiksmo atšaukti nebus galima. Tęsti?" msgid "Skipping objects." msgstr "Praleidžiami objektai." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Pasirinkti giją" diff --git a/localization/i18n/nl/OrcaSlicer_nl.po b/localization/i18n/nl/OrcaSlicer_nl.po index 4fd3601c0e..a767093bb8 100644 --- a/localization/i18n/nl/OrcaSlicer_nl.po +++ b/localization/i18n/nl/OrcaSlicer_nl.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: \n" "Last-Translator: \n" "Language-Team: \n" @@ -2679,8 +2679,6 @@ msgstr "Er is een update beschikbaar. Open het dialoogvenster voor de voorinstel msgid "%s has been removed." msgstr "%s is verwijderd." -msgid "Switching application language" -msgstr "De taal van de applicatie wordt aangepast" msgid "Select the language" msgstr "Kies de taal" @@ -5325,6 +5323,9 @@ msgstr "" "Ja - Pas de instellingen aan en zet de vaas modus automatisch aan\n" "Nee - Pas de vaas modus deze keer niet toe" +msgid "N/A" +msgstr "N/B" + msgid "Printing" msgstr "Printen" @@ -5830,13 +5831,9 @@ msgstr "Waarde %s valt buiten het bereik. Het geldige bereik loopt van %d tot %d #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"Is het %s%% or %s %s?\n" -"JA voor %s%%, \n" -"NEE voor %s %s." +"Is het %s%% or %s %s?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5861,9 +5858,6 @@ msgstr "Ongeldig patroon. Gebruik N, N#K of een door komma's gescheiden lijst me msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Onjuist formaat. Het Vector formaat wordt verwacht: \"%1%\"" -msgid "N/A" -msgstr "N/B" - # AI Translated msgid "System agents" msgstr "Systeemagenten" @@ -6888,6 +6882,12 @@ msgstr "Bewaar project als" msgid "Save current project as" msgstr "Bewaar huidig project als" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "3MF/STL/STEP/SVG/OBJ/AMF importeren" @@ -8904,6 +8904,12 @@ msgstr "Controleer of de G-codes in deze presets veilig zijn om schade aan de ma msgid "Customized Preset" msgstr "Aangepaste voorinstelling" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Naam van componenten in step-bestand is niet UTF-8-formaat!" @@ -9339,6 +9345,17 @@ msgstr "Bewaar het geslicede bestand als:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Het bestand %s is naar de opslagruimte van de printer gestuurd en kan op de printer worden bekeken." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Publiceren" + # AI Translated msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Het mondstuktype is niet ingesteld. Stel het mondstuk in en probeer het opnieuw." @@ -9579,8 +9596,6 @@ msgstr "Wilt u doorgaan?" msgid "Language selection" msgstr "Taal selectie" -msgid "Switching application language while some presets are modified." -msgstr "De taal van de toepassing aanpaasen terwijl sommige voorinstellingen zijn aangepast." msgid "Asia-Pacific" msgstr "Azië-Pacific" @@ -10626,9 +10641,6 @@ msgstr "Ga naar de website om het model te publiceren" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Notitie: het voorbereiden kan enkele minuten duren. Even geduld alstublieft." -msgid "Publish" -msgstr "Publiceren" - msgid "Publish was canceled" msgstr "Het publiceren is geannuleerd" @@ -10645,6 +10657,64 @@ msgstr "Gegevens uploaden" msgid "Jump to webpage" msgstr "Ga naar de website" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Bewaar %s als" @@ -23245,6 +23315,15 @@ msgstr "Deze actie kan niet ongedaan worden gemaakt. Doorgaan?" msgid "Skipping objects." msgstr "Objecten worden overgeslagen." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Selecteer filament" diff --git a/localization/i18n/pl/OrcaSlicer_pl.po b/localization/i18n/pl/OrcaSlicer_pl.po index 0f6fa10e3b..39b0c71d89 100644 --- a/localization/i18n/pl/OrcaSlicer_pl.po +++ b/localization/i18n/pl/OrcaSlicer_pl.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: OrcaSlicer 2.3.0-rc\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: \n" "Last-Translator: Krzysztof Morga <>\n" "Language-Team: \n" @@ -2512,8 +2512,6 @@ msgstr "Dostępna jest aktualizacja. Otwórz okno pakietu profili, aby ją zains msgid "%s has been removed." msgstr "%s został usunięty." -msgid "Switching application language" -msgstr "Zmiana języka aplikacji" msgid "Select the language" msgstr "Wybierz język" @@ -5013,6 +5011,10 @@ msgstr "" "Tak - Zmień te ustawienia automatycznie i włącz tryb Wazy\n" "Nie - Zrezygnuj tym razem z używania trybu Wazy" +# AI Translated +msgid "N/A" +msgstr "Nie dotyczy" + msgid "Printing" msgstr "Drukowanie" @@ -5450,13 +5452,9 @@ msgstr "Wartość %s jest spoza zakresu. Poprawny zakres wynosi od %d do %d." #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"Czy to %s%% czy %s %s?\n" -"TAK dla %s%%,\n" -"NIE dla %s %s." +"Czy to %s%% czy %s %s?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5480,10 +5478,6 @@ msgstr "Nieprawidłowy wzorzec. Użyj N, N#K lub listy rozdzielonej przecinkami msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Nieprawidłowy format. Oczekiwano formatu wektorowego: „%1%”" -# AI Translated -msgid "N/A" -msgstr "Nie dotyczy" - # AI Translated msgid "System agents" msgstr "Agenci systemowi" @@ -6469,6 +6463,12 @@ msgstr "Zapisz projekt jako" msgid "Save current project as" msgstr "Zapisz bieżący projekt jako" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importuj 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8353,6 +8353,12 @@ msgstr "Proszę potwierdź, że G-code w tych profilach jest bezpieczny, aby zap msgid "Customized Preset" msgstr "Dostosowany profil" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Nazwa komponentów w pliku step nie jest w formacie UTF-8!" @@ -8779,6 +8785,17 @@ msgstr "Zapisz plik po wykonaniu cięcia jako:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Plik %s został wysłany do pamięci drukarki i można go obejrzeć na urządzeniu." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Opublikuj" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Nie ustawiono typu dyszy Wprowadź ustawienia dyszy i spróbuj ponownie." @@ -8993,8 +9010,6 @@ msgstr "Czy kontynuować?" msgid "Language selection" msgstr "Wybór języka" -msgid "Switching application language while some presets are modified." -msgstr "Zmiana języka aplikacji przy jednoczesnym istniejących zmodyfikowanych ustawieniach." msgid "Asia-Pacific" msgstr "Azja i Pacyfik" @@ -10021,9 +10036,6 @@ msgstr "Przejdź do strony publikacji modelu" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Uwaga: Przygotowanie może zająć kilka minut. Proszę o cierpliwość." -msgid "Publish" -msgstr "Opublikuj" - msgid "Publish was canceled" msgstr "Publikacja została anulowana" @@ -10040,6 +10052,64 @@ msgstr "Przesyłanie danych" msgid "Jump to webpage" msgstr "Przejdź na stronę" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Zapisz %s jako" @@ -21723,6 +21793,15 @@ msgstr "Tego działania nie będzie można cofnąć. Kontynuować?" msgid "Skipping objects." msgstr "Pomijanie obiektów." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Wybierz filament" @@ -25353,15 +25432,6 @@ msgstr "" #~ msgid "Low-temperature filament (PLA/PETG/TPU) is loaded in the extruder. In order to avoid extruder clogging, it is not allowed to set the chamber temperature above 45℃." #~ msgstr "W ekstruzorze jest załadowany filament o niskiej temperaturze (PLA/PETG/TPU). Aby uniknąć zatkania ekstruzora, nie wolno ustawiać temperatury komory powyżej 45℃." -#~ msgid "" -#~ "Is it %s%% or %s %s?\n" -#~ "YES for %s%%,\n" -#~ "NO for %s %s." -#~ msgstr "" -#~ "Czy to %s%% czy %s %s?\n" -#~ "TAK dla %s%%,\n" -#~ "NIE dla %s %s." - #~ msgid "Allow multiple materials on the same plate" #~ msgstr "Pozwól na kilka filamentów na tej samej płycie" diff --git a/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po b/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po index 0d9698d746..ca92c4c09e 100644 --- a/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po +++ b/localization/i18n/pt_BR/OrcaSlicer_pt_BR.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2026-07-26 11:14-0300\n" "Last-Translator: Alexandre Folle de Menezes\n" "Language-Team: Portuguese, Brazilian\n" @@ -2128,7 +2128,7 @@ msgid "" "OrcaSlicer has attempted to recreate the configuration file.\n" "Please note, application settings will be lost, but printer profiles will not be affected." msgstr "" -"O arquivo de configuração do OrcaSlicer pode estar corrompido e não pode ser analisado.\n" +"O arquivo de configuração do OrcaSlicer pode estar corrompido e não pode ser processado.\n" "O OrcaSlicer tentou recriar o arquivo de configuração.\n" "Por favor, note que as configurações do aplicativo serão perdidas, mas os perfis de impressora não serão afetados." @@ -2356,8 +2356,6 @@ msgstr "Há uma atualização disponível. Abra a caixa de diálogo do pacote de msgid "%s has been removed." msgstr "%s foi removido." -msgid "Switching application language" -msgstr "Alternando o idioma do aplicativo" msgid "Select the language" msgstr "Selecione o idioma" @@ -4743,6 +4741,9 @@ msgstr "" "Sim - Alterar essas configurações e ativar o modo espiral/vaso automaticamente\n" "Não - Cancelar ativação do modo espiral" +msgid "N/A" +msgstr "N/D" + msgid "Printing" msgstr "Imprimindo" @@ -5168,13 +5169,9 @@ msgstr "Valor %s está fora do intervalo. O intervalo válido é de %d para %d." #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"É %s%% ou %s %s?\n" -"SIM para %s%%, \n" -"NÃO para %s %s." +"É %s%% ou %s %s?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5196,9 +5193,6 @@ msgstr "Padrão inválido. Use N, N#K, ou uma lista separa por vírgulas com #K msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Formato inválido. Formato de vetor esperado: \"%1%\"" -msgid "N/A" -msgstr "N/D" - msgid "System agents" msgstr "Agentes do sistema" @@ -6145,6 +6139,12 @@ msgstr "Salvar projeto como" msgid "Save current project as" msgstr "Salvar o projeto atual como" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importar 3MF/STL/STEP/SVG/OBJ/AMF" @@ -6671,7 +6671,7 @@ msgid "Failed to fetch model information from printer." msgstr "Falha ao obter informação do modelo da impressora." msgid "Failed to parse model information." -msgstr "Falha ao analisar a informação do modelo." +msgstr "Falha ao processar a informação do modelo." msgid "The .gcode.3mf file contains no G-code data. Please slice it with Orca Slicer and export a new .gcode.3mf file." msgstr "O arquivo .gcode.3mf não contém dados de G-code. Por favor, fatie com Orca Slicer e exporte um novo arquivo .gcode.3mf." @@ -7941,6 +7941,12 @@ msgstr "Por favor, confirme se o G-code dentro dessas predefinições é seguro msgid "Customized Preset" msgstr "Predefinição Personalizada" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Os nomes dos componentes dentro do arquivo STEP não estão no formato UTF-8!" @@ -8345,6 +8351,17 @@ msgstr "Salvar arquivo fatiado como:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "O arquivo %s foi enviado para o espaço de armazenamento da impressora e pode ser visualizado na impressora." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Publicar" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "O tipo de bico não está configurado. Configure o bico e tente novamente." @@ -8549,8 +8566,6 @@ msgstr "Você deseja continuar?" msgid "Language selection" msgstr "Seleção de idioma" -msgid "Switching application language while some presets are modified." -msgstr "Alternando idioma do aplicativo enquanto algumas predefinições são modificadas." msgid "Asia-Pacific" msgstr "Ásia-Pacífico" @@ -8618,7 +8633,7 @@ msgstr "" "\n" "Deseja baixar e instalar esta versão agora?\n" "\n" -"Observação: o aplicativo pode precisar ser reiniciado após a instalação." +"Nota: o aplicativo pode precisar ser reiniciado após a instalação." msgid "Download Network Plug-in" msgstr "Baixar Plug-in de Rede" @@ -9116,7 +9131,7 @@ msgid "" "Note: When Stealth Mode is enabled, your user profiles will not be backed up to Orca Cloud." msgstr "" "Isso desativa todos os recursos da nuvem, incluindo a sincronização de perfis do Orca Cloud. Usuários que preferem trabalhar totalmente offline podem ativar esta opção.\n" -"Observação: quando o Modo Furtivo está ativado, seus perfis de usuário não serão copiados para o Orca Cloud." +"Nota: quando o Modo Furtivo está ativado, seus perfis de usuário não serão copiados para o Orca Cloud." msgid "Hide login side panel" msgstr "Ocultar painel lateral de autenticação" @@ -9478,9 +9493,6 @@ msgstr "Ir para a página web de publicação de modelos" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Nota: A preparação pode levar vários minutos. Por favor, seja paciente." -msgid "Publish" -msgstr "Publicar" - msgid "Publish was canceled" msgstr "Publicação cancelada" @@ -9496,6 +9508,64 @@ msgstr "Enviando dados" msgid "Jump to webpage" msgstr "Ir para a página web" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Salvar %s como" @@ -9701,7 +9771,6 @@ msgstr "A impressora falhou ao gerar a tabela de mapeamento automático do bico msgid "The current nozzle mapping may produce an extra %0.2f g of waste." msgstr "O mapeamento de bicos atual pode gerar um desperdício adicional de %0.2f g." -# AI Translated #, c-format, boost-format msgid "Recommended filament arrangement saves %s->" msgstr "A disposição de filamento recomendada economiza %s->" @@ -9760,7 +9829,6 @@ msgstr "Este processo determina os valores de fluxo dinâmico para melhorar a qu msgid "Internal" msgstr "Interno" -# AI Translated #, c-format, boost-format msgid "%s space less than 20MB. Timelapse may not save properly. You can turn it off or" msgstr "%s com espaço inferior a 20MB. O timelapse pode não ser salvo corretamente. Você pode desativá-lo ou" @@ -9768,15 +9836,12 @@ msgstr "%s com espaço inferior a 20MB. O timelapse pode não ser salvo corretam msgid "Clean up files" msgstr "Limpar arquivos" -# AI Translated msgid "Low internal storage. This timelapse will overwrite the oldest video files." msgstr "Armazenamento interno baixo. Este timelapse substituirá os arquivos de vídeo mais antigos." -# AI Translated msgid "Low external storage. This timelapse will overwrite the oldest video files." msgstr "Armazenamento externo baixo. Este timelapse substituirá os arquivos de vídeo mais antigos." -# AI Translated msgid "Insufficient external storage for time-lapse photography. Connect to computer to delete files, or use a larger memory card." msgstr "Armazenamento externo insuficiente para fotografia time-lapse. Conecte ao computador para excluir arquivos ou use um cartão de memória maior." @@ -9812,7 +9877,6 @@ msgstr "Atualizando informações dos hotends (%d/%d)." msgid "There are not enough available hotends currently." msgstr "Não há hotends disponíveis em quantidade suficiente no momento." -# AI Translated msgid "Please complete the hotend rack setup and try again." msgstr "Por favor, conclua a configuração do rack de hotend e tente novamente." @@ -9829,11 +9893,9 @@ msgstr "As informações reportadas sobre o hotend podem não ser confiáveis." msgid "The printer has no nozzle matching the slicing file (%s)." msgstr "A impressora não possui um bico compatível com o arquivo de fatiamento (%s)." -# AI Translated msgid "Please install a matching nozzle in the hotend rack, or set the corresponding printer preset while slicing." msgstr "Por favor, instale um bico compatível no rack de hotend, ou defina a predefinição de impressora correspondente ao fatiar." -# AI Translated msgid "The toolhead and hotend rack are full. Please remove at least one hotend before printing." msgstr "A cabeça da ferramenta e o rack de hotend estão cheios. Por favor, remova pelo menos um hotend antes de imprimir." @@ -9859,24 +9921,19 @@ msgstr "ambas extrusoras" msgid "The hardness of current material (%s) exceeds the hardness of %s(%s). Please verify the nozzle or material settings and try again." msgstr "A dureza do material atual (%s) excede a dureza de %s(%s). Verifique as configurações do bico ou do material e tente novamente." -# AI Translated msgid "Your current firmware version cannot start this print job. Please update to the latest version and try again." msgstr "Sua versão atual do firmware não pode iniciar este trabalho de impressão. Atualize para a versão mais recente e tente novamente." -# AI Translated #, c-format, boost-format msgid "The hardness of current material (%s) exceeds the hardness of %s(%s). It may cause nozzle wear, leading to material leakage and unstable flow. Please exercise caution when using it." msgstr "A dureza do material atual (%s) excede a dureza de %s(%s). Isso pode causar desgaste do bico, levando a vazamento de material e fluxo instável. Tenha cuidado ao usá-lo." -# AI Translated msgid "Some filaments may switch between extruders during printing. Manual K-value calibration cannot be applied throughout the entire print, which may affect print quality. Enabling Flow Dynamics Calibration is recommended." msgstr "Alguns filamentos podem alternar entre extrusoras durante a impressão. A calibração manual do valor K não pode ser aplicada durante toda a impressão, o que pode afetar a qualidade da impressão. Recomenda-se ativar a Calibração de Dinâmica de Fluxo." -# AI Translated msgid "There is stringing-prone filament in this file. For best print quality, we recommend switching nozzle clumping detection to Auto mode." -msgstr "Há filamento propenso a fiapos neste arquivo. Para a melhor qualidade de impressão, recomendamos alternar a detecção de aglomeração no bico para o modo Automático." +msgstr "Há filamento propenso a criar fios neste arquivo. Para a melhor qualidade de impressão, recomendamos alternar a detecção de aglomeração no bico para o modo Automático." -# AI Translated msgid "If 'Dynamic Flow Calibration' is set to Auto/On, the system will use the manual calibration value or the default value and skip the flow calibration process. You can perform a manual flow calibration for TPU filament on the 'Calibration' page." msgstr "Se a 'Calibração de Fluxo Dinâmico' estiver definida como Automático/Ativado, o sistema usará o valor de calibração manual ou o valor padrão e ignorará o processo de calibração de fluxo. Você pode realizar uma calibração de fluxo manual para filamento TPU na página 'Calibração'." @@ -9983,7 +10040,6 @@ msgstr "Desative a calibração de fluxo dinâmico para habilitar o valor de flu msgid "This printer does not support printing all plates." msgstr "Esta impressora não suporta a imprimir todas as placas." -# AI Translated #, c-format, boost-format msgid "The current firmware supports a maximum of %s materials. You can either reduce the number of materials to %s or fewer on the Preparation Page, or try updating the firmware. If you are still restricted after the update, please wait for subsequent firmware support." msgstr "O firmware atual suporta no máximo %s materiais. Você pode reduzir o número de materiais para %s ou menos na Página de Preparação, ou tentar atualizar o firmware. Se ainda estiver restrito após a atualização, aguarde o suporte de firmware subsequente." @@ -9991,11 +10047,9 @@ msgstr "O firmware atual suporta no máximo %s materiais. Você pode reduzir o n msgid "The type of external filament is unknown or does not match with the filament type in the slicing file. Please make sure you have installed the correct filament in the external spool." msgstr "O tipo de filamento externo é desconhecido ou não corresponde ao tipo de filamento no arquivo de fatiamento. Certifique-se de ter instalado o filamento correto no carretel externo." -# AI Translated msgid "TPU 90A/TPU 85A are too soft. It is recommended to perform manual flow calibration on the 'Calibration' page. If 'Dynamic Flow Calibration' is set to auto/on, the system will use the previous calibration value and skip the flow calibration process." msgstr "TPU 90A/TPU 85A são muito macios. Recomenda-se realizar a calibração de fluxo manual na página 'Calibração'. Se a 'Calibração de Fluxo Dinâmico' estiver definida como automático/ativado, o sistema usará o valor de calibração anterior e ignorará o processo de calibração de fluxo." -# AI Translated msgid "The filament in the AMS may be insufficient for this print. Please refill or replace it." msgstr "O filamento no AMS pode ser insuficiente para esta impressão. Por favor, reabasteça ou substitua-o." @@ -10072,7 +10126,7 @@ msgid "Failed to post ticket to server" msgstr "Falha ao enviar o ticket para o servidor" msgid "Failed to parse login report reason" -msgstr "Falha ao analisar o motivo do relatório de login" +msgstr "Falha ao processar o motivo do relatório de login" msgid "Receive login report timeout" msgstr "Limite de tempo excedido ao receber o relatório de login" @@ -10168,11 +10222,9 @@ msgstr "Excluir esta predefinição" msgid "Search in preset" msgstr "Pesquisar nas predefinições" -# AI Translated msgid "Synchronization of different extruder drives or nozzle volume types is not supported." msgstr "A sincronização de diferentes acionamentos de extrusora ou tipos de volume do bico não é suportada." -# AI Translated msgid "Synchronize the modification of parameters to the corresponding parameters of another extruder." msgstr "Sincroniza a modificação de parâmetros com os parâmetros correspondentes de outra extrusora." @@ -10242,7 +10294,7 @@ msgid "Are you sure you want to enable this option?" msgstr "Tem certeza de que deseja habilitar esta opção?" msgid "Infill patterns are typically designed to handle rotation automatically to ensure proper printing and achieve their intended effects (e.g., Gyroid, Cubic). Rotating the current sparse infill pattern may lead to insufficient support. Please proceed with caution and thoroughly check for any potential printing issues. Are you sure you want to enable this option?" -msgstr "Padrões de preenchimento são projetados para lidar com a rotação automaticamente para garantir a impressão adequada e atingir os efeitos pretendidos (Ex. Giroide, Cúbico). Girar o padrão de preenchimento esparso atual pode causar suporte insuficiente. Prossiga com cautela e verifique cuidadosamente se há possíveis problemas de impressão. Tem certeza de que deseja habilitar esta opção?" +msgstr "Padrões de preenchimento são projetados para lidar com a rotação automaticamente para garantir a impressão adequada e atingir os efeitos pretendidos (ex.: Giroide, Cúbico). Girar o padrão de preenchimento esparso atual pode causar suporte insuficiente. Prossiga com cautela e verifique cuidadosamente se há possíveis problemas de impressão. Tem certeza de que deseja habilitar esta opção?" msgid "Experimental feature: Retracting and cutting off the filament at a greater distance during filament changes to minimize flush. Although it can notably reduce flush, it may also elevate the risk of nozzle clogs or other printing complications." msgstr "Funcionalidade experimental: Retrair e cortar o filamento a uma distância maior durante mudanças de filamento para minimizar a purga. Embora possa reduzir notavelmente a purga, ele também pode elevar o risco de bolhas no bico ou outras complicações de impressão." @@ -10405,7 +10457,6 @@ msgstr "G-code de mudança de tipo de extrusão" msgid "Post-processing Scripts" msgstr "Scripts de pós-processamento" -# AI Translated msgid "Slicing Pipeline Plugin" msgstr "Plugin de Pipeline de Fatiamento" @@ -10459,9 +10510,8 @@ msgstr "Temperatura da câmara de impressão" msgid "Chamber temperature" msgstr "Temperatura da câmara" -# AI Translated msgid "Target chamber temperature, and the minimal chamber temperature at which printing should start" -msgstr "Temperatura da câmara alvo, e a temperatura mínima da câmara na qual a impressão deve começar" +msgstr "Temperatura alvo da câmara, e a temperatura mínima da câmara na qual a impressão deve começar" msgid "Target" msgstr "Alvo" @@ -10699,7 +10749,6 @@ msgstr "Limites de altura da camada" msgid "Z-Hop" msgstr "Z-Hop" -# AI Translated msgid "" "The Retract before wipe option could be only 100% when using the Firmware Retraction mode.\n" "\n" @@ -11007,7 +11056,6 @@ msgstr "Se ativo, este diálogo pode ser usado para transferir valores seleciona msgid "One of the presets does not exist" msgstr "Uma das predefinições não existe" -# AI Translated msgid "Compared presets has different printer technology" msgstr "As predefinições comparadas têm tecnologia de impressora diferente" @@ -11342,9 +11390,8 @@ msgstr "Entrar" msgid "Login failed. Please try again." msgstr "Falha no login. Tente novamente." -# AI Translated msgid "parse json failed" -msgstr "falha ao analisar o json" +msgstr "falha ao processar o json" msgid "[Action Required] " msgstr "[Ação Necessária] " @@ -11535,7 +11582,6 @@ msgctxt "Keyboard Shortcut" msgid "Space" msgstr "Espaço" -# AI Translated msgid "Open actions speed dial" msgstr "Abrir menu rápido de ações" @@ -11617,11 +11663,9 @@ msgstr "informações de atualização da versão %s:" msgid "Network plug-in update" msgstr "Atualização do plug-in de rede" -# AI Translated msgid "Click OK to update the Network plug-in now. If a file is in use, the update will be applied the next time Orca Slicer launches." msgstr "Clique em OK para atualizar o plug-in de Rede agora. Se um arquivo estiver em uso, a atualização será aplicada na próxima vez que o Orca Slicer for iniciado." -# AI Translated msgid "A new Network plug-in is available. Do you want to install it?" msgstr "Um novo plug-in de Rede está disponível. Deseja instalá-lo?" @@ -11681,7 +11725,6 @@ msgstr "Nome da impressora" msgid "Where to find your printer's IP and Access Code?" msgstr "Onde encontrar o IP e o Código de Acesso da sua impressora?" -# AI Translated msgid "How to trouble shooting" msgstr "Como solucionar problemas" @@ -11829,11 +11872,9 @@ msgstr "Objeto: %1%" msgid "Parts of the object at these heights may be too thin or the object may have a faulty mesh." msgstr "Partes do objeto nessas alturas podem ser muito finas, ou o objeto pode ter uma malha com falhas." -# AI Translated msgid "Process change extrusion role G-code" msgstr "G-code de mudança de tipo de extrusão do processo" -# AI Translated msgid "Filament change extrusion role G-code" msgstr "G-code de mudança de tipo de extrusão do filamento" @@ -12008,7 +12049,6 @@ msgstr " está muito perto de uma área de exclusão, e colisões vão ocorrer.\ msgid " is too close to clumping detection area, and collisions will be caused.\n" msgstr " está muito perto da área de detecção de aglomeração, e ocorrerão colisões.\n" -# AI Translated msgid " is partially outside the printable area, and it cannot be printed.\n" msgstr " está parcialmente fora da área imprimível, e não pode ser impresso.\n" @@ -12024,7 +12064,6 @@ msgstr "Se ainda assim desejar imprimir, você pode ativar a opção em Preferê msgid "No extrusions under current settings." msgstr "Nenhuma extrusão com as configurações atuais." -# AI Translated msgid "A gradient mixed filament is used, but 'Mixed color sublayer' is disabled. The gradient will not be printed." msgstr "Um filamento misto com gradiente está em uso, mas 'Subcamada de cor mista' está desativado. O gradiente não será impresso." @@ -12064,7 +12103,6 @@ msgstr "Você pode querer reduzir o tamanho do seu modelo ou alterar as configur msgid "Variable layer height is not supported with Organic supports." msgstr "A altura de camada variável não é suportada com suportes Orgânicos." -# AI Translated msgid "The wipe tower filament cannot be a mixed filament." msgstr "O filamento da torre de purga não pode ser um filamento misto." @@ -12701,7 +12739,6 @@ msgstr "" msgid "Internal bridge flow ratio" msgstr "Taxa de fluxo em ponte interna" -# AI Translated msgid "" "This value governs the thickness of the internal bridge layer. This is the first layer over sparse infill so increasing it may increase strength and upper layer quality.\n" "Values above 1.0: Increase the amount of material while maintaining line spacing. This can improve line contact and strength.\n" @@ -13091,11 +13128,9 @@ msgstr "" "A geometria será decimada antes de detectar ângulos agudos. Este parâmetro indica o comprimento mínimo da divergência para a decimação.\n" "0 para desativar." -# AI Translated msgid "Brim ears outer only" -msgstr "Orelhas da borda apenas externas" +msgstr "Apenas orelhas da borda externas" -# AI Translated msgid "Generate mouse ears only on the outer contour of the model, excluding holes and enclosed sections." msgstr "Gera orelhas de rato apenas no contorno externo do modelo, excluindo furos e seções fechadas." @@ -13123,7 +13158,6 @@ msgstr "Por objeto" msgid "Intra-layer order" msgstr "Ordem intra-camada" -# AI Translated msgid "" "Order in which object instances are visited within a single layer, which controls how much travel is spent moving between them.\n" "\n" @@ -13181,7 +13215,7 @@ msgid "mm/s² or %" msgstr "mm/s² ou %" msgid "Acceleration of bridges. If the value is expressed as a percentage (e.g. 50%), it will be calculated based on the outer wall acceleration." -msgstr "Aceleração das pontes. Se o valor for expresso como uma porcentagem (por exemplo, 50%), será calculado com base na aceleração da parede externa." +msgstr "Aceleração das pontes. Se o valor for expresso como uma porcentagem (ex.: 50%), será calculado com base na aceleração da parede externa." msgid "Default filament profile" msgstr "Perfil de filamento padrão" @@ -13761,7 +13795,6 @@ msgstr "Tempo da camada" msgid "The part cooling fan will be enabled for layers where the estimated time is shorter than this value. Fan speed is interpolated between the minimum and maximum fan speeds according to layer printing time." msgstr "A ventoinha de resfriamento de peças será ativado para camadas cujo tempo estimado seja mais curto que esse valor. A velocidade da ventoinha é interpolada entre as velocidades mínima e máxima da ventoinha de acordo com o tempo de impressão da camada." -# AI Translated msgctxt "second" msgid "s" msgstr "s" @@ -14068,61 +14101,47 @@ msgstr "Material de suporte" msgid "Support material is commonly used to print supports and support interfaces." msgstr "O material de suporte é comumente usado para imprimir suportes e interfaces de suporte." -# AI Translated msgid "Is mixed filament" msgstr "É filamento misto" -# AI Translated msgid "Whether this filament slot is a mixed filament composed of multiple physical filaments" msgstr "Define se este slot de filamento é um filamento misto composto por vários filamentos físicos" -# AI Translated msgid "Mixed filament components" msgstr "Componentes do filamento misto" -# AI Translated msgid "Comma-separated 1-based indices of component filaments, e.g. \"1,3\"" msgstr "Índices (começando em 1) dos filamentos componentes, separados por vírgulas; ex.: \"1,3\"" -# AI Translated msgid "Mixed filament sublayer ratios" msgstr "Proporções de subcamada do filamento misto" -# AI Translated msgid "Comma-separated ratio values summing to 1.0, e.g. \"0.7,0.3\"" msgstr "Valores de proporção separados por vírgulas cuja soma seja 1.0; ex.: \"0.7,0.3\"" -# AI Translated msgid "Mixed filament gradient" msgstr "Gradiente do filamento misto" -# AI Translated msgid "Enable Z-direction gradient mode for mixed filament sub-layers. When enabled, the sub-layer ratios vary linearly across layers." -msgstr "Ativa o modo de gradiente na direção Z para as subcamadas do filamento misto. Quando ativado, as proporções das subcamadas variam linearmente ao longo das camadas." +msgstr "Ativa o modo de gradiente na direção Z para as subcamadas do filamento misto. Quando ativo, as proporções das subcamadas variam linearmente ao longo das camadas." -# AI Translated msgid "Mixed filament gradient range" msgstr "Faixa do gradiente do filamento misto" -# AI Translated msgid "Start and end ratios for the first component in gradient mode. Comma-separated pair, e.g. \"0.10,0.90\" means 10% to 90%." msgstr "Proporções inicial e final do primeiro componente no modo de gradiente. Par separado por vírgula; ex.: \"0.10,0.90\" significa de 10% a 90%." -# AI Translated msgid "Mixed filament gradient curve" msgstr "Curva do gradiente do filamento misto" -# AI Translated msgid "Optional Photoshop-style custom curve mapping Z progress to the first component ratio. Encoded as pipe-separated control points, either \"x,y\" (legacy) or \"x,y,m_in,m_out\" when a tangent override is needed (empty token or \"nan\" means use PCHIP default). x in [0,1]; y is clamped to the configured ratio range, e.g. \"0,0.15|0.5,0.50|1,0.85\". When empty, the linear gradient_range is used instead." -msgstr "Curva personalizada opcional, no estilo do Photoshop, que mapeia o progresso em Z para a proporção do primeiro componente. Codificada como pontos de controle separados por barras verticais, no formato \"x,y\" (legado) ou \"x,y,m_in,m_out\" quando é necessário substituir a tangente (um valor vazio ou \"nan\" usa o padrão PCHIP). x está em [0,1]; y é limitado à faixa de proporção configurada; ex.: \"0,0.15|0.5,0.50|1,0.85\". Quando vazio, o gradient_range linear é usado." +msgstr "Curva personalizada opcional no estilo do Photoshop, mapeando o progresso em Z para a proporção do primeiro componente. Codificada como pontos de controle separados por barras verticais, no formato \"x,y\" (legado) ou \"x,y,m_in,m_out\" quando é necessário substituir a tangente (um valor vazio ou \"nan\" usa o padrão PCHIP). X em [0,1]; Y é limitado à faixa de proporção configurada; ex.: \"0,0.15|0.5,0.50|1,0.85\". Quando vazio, o gradient_range linear é usado no lugar." -# AI Translated msgid "Mixed filament per-part gradient" msgstr "Gradiente por peça do filamento misto" -# AI Translated msgid "When gradient mode is enabled, apply the gradient to each part of an assembly independently rather than treating the whole assembly as one Z range." -msgstr "Quando o modo de gradiente está ativado, aplica o gradiente a cada peça de uma montagem de forma independente, em vez de tratar toda a montagem como uma única faixa Z." +msgstr "Quando o modo de gradiente está ativado, aplica o gradiente a cada peça de uma montagem de forma independente em vez de tratar toda a montagem como uma única faixa Z." msgid "Filament printable" msgstr "Filamento imprimível" @@ -14213,7 +14232,7 @@ msgid "Insert solid layers" msgstr "Inserir camadas sólidas" msgid "Insert solid infill at specific layers. Use N to insert every Nth layer, N#K to insert K consecutive solid layers every N layers (K is optional, e.g. '5#' equals '5#1'), or a comma-separated list (e.g. 1,7,9) to insert at explicit layers. Layers are 1-based." -msgstr "Insere preenchimento sólido em camadas específicas. Use N para inserir a cada enésima camada, N#K para inserir K camadas sólidas consecutivas a cada enésima camada (K é opcional, ou seja, '5#' é igual a '5#1'), ou uma lista separada por vírgulas (Ex. 1,7,9) para inserir em camadas esplícitas. Camadas são baseadas em 1." +msgstr "Insere preenchimento sólido em camadas específicas. Use N para inserir a cada enésima camada, N#K para inserir K camadas sólidas consecutivas a cada enésima camada (K é opcional, ex.: '5#' é igual a '5#1'), ou uma lista separada por vírgulas (Ex. 1,7,9) para inserir em camadas esplícitas. Camadas são baseadas em 1." msgid "Fill Multiline" msgstr "Multilinhas de Preenchimento" @@ -14291,11 +14310,9 @@ msgstr "TPMS-FK" msgid "Gyroid" msgstr "Giroide" -# AI Translated msgid "Sparse infill smooth factor" msgstr "Fator de suavização do preenchimento esparso" -# AI Translated msgid "Controls how strongly sparse infill corners are rounded. 0% keeps the original sharp path, while 100% produces the largest possible curves between adjacent infill lines." msgstr "Controla o quanto os cantos do preenchimento esparso são arredondados. 0% mantém o trajeto original com cantos vivos, enquanto 100% produz as maiores curvas possíveis entre linhas de preenchimento adjacentes." @@ -14309,10 +14326,10 @@ msgid "Acceleration of inner walls." msgstr "Aceleração das paredes internas." msgid "Acceleration of sparse infill. If the value is expressed as a percentage (e.g. 100%), it will be calculated based on the default acceleration." -msgstr "Aceleração do preenchimento esparso. Se o valor for expresso como uma porcentagem (por exemplo, 100%), será calculado com base na aceleração padrão." +msgstr "Aceleração do preenchimento esparso. Se o valor for expresso como uma porcentagem (ex.: 100%), será calculado com base na aceleração padrão." msgid "Acceleration of internal solid infill. If the value is expressed as a percentage (e.g. 100%), it will be calculated based on the default acceleration." -msgstr "Aceleração do preenchimento sólido interno. Se o valor for expresso como uma porcentagem (por exemplo, 100%), será calculado com base na aceleração padrão." +msgstr "Aceleração do preenchimento sólido interno. Se o valor for expresso como uma porcentagem (ex.: 100%), será calculado com base na aceleração padrão." msgid "This is the printing acceleration for the first layer. Using limited acceleration can improve build plate adhesion." msgstr "Esta é a aceleração para a primeira camada. Usar aceleração limitada melhorar a adesão à placa de impressão." @@ -14416,7 +14433,7 @@ msgid "" "Only available when \"No cooling for the first\" is 0.\n" "Set to -1 to disable it." msgstr "" -"Define uma velocidade exata da ventoinha para a primeira camada, substituindo todas as outras configurações de resfriamento. Útil para proteger peças impressas em 3D da cabeça da ferramenta (por exemplo, dutos ABS/ASA no estilo Voron) de uma mesa aquecida. Uma pequena quantidade de fluxo de ar resfria os dutos, sem usar o resfriamento total que pode, em certas condições, prejudicar a aderência da primeira camada.\n" +"Define uma velocidade exata da ventoinha para a primeira camada, substituindo todas as outras configurações de resfriamento. Útil para proteger peças impressas em 3D da cabeça da ferramenta (ex.: dutos ABS/ASA no estilo Voron) de uma mesa aquecida. Uma pequena quantidade de fluxo de ar resfria os dutos, sem usar o resfriamento total que pode, em certas condições, prejudicar a aderência da primeira camada.\n" "A partir da segunda camada, o resfriamento normal é retomado.\n" "Se \"Velocidade total da ventoinha na camada\" também estiver definida, a ventoinha aumenta suavemente deste valor na primeira camada até o seu alvo na camada escolhida.\n" "Disponível apenas quando \"Sem resfriamento nas primeiras\" é 0.\n" @@ -14787,7 +14804,7 @@ msgid "" "Set to 0 to deactivate." msgstr "" "Algumas ventoinhas de resfriamento de componentes não conseguem iniciar a rotação quando comandadas abaixo de um determinado ciclo de trabalho PWM. Quando definido acima de 0, qualquer comando de ventoinha de resfriamento de componentes diferente de zero será elevado para pelo menos essa porcentagem, para que a ventoinha inicie de forma confiável. Um comando de ventoinha de 0 (ventoinha desligada) é sempre atendido exatamente. Essa limitação é aplicada após cada outro cálculo da ventoinha (rampa da primeira camada, interpolação do tempo da camada, substituições de saliência/ponte/interface de suporte/alisamento), para que o dimensionamento ainda opere dentro do intervalo [este valor, 100%].\n" -"Se o seu firmware já desativa a ventoinha abaixo de um limite (por exemplo, [fan] off_below: 0.10 do Klipper desliga a ventoinha sempre que o ciclo de trabalho comandado for inferior a 10%), esta opção e o limite do firmware devem idealmente ser definidos com o mesmo valor. A correspondência entre eles (por exemplo, off_below: 0.10 no Klipper e 10% aqui) garante que o fatiador nunca emita um valor diferente de zero que o firmware emitiria a velocidade cai silenciosamente e a ventoinha nunca recebe um valor abaixo daquele que você sabe que ela pode realmente atingir.\n" +"Se o seu firmware já desativa a ventoinha abaixo de um limite (por exemplo, [fan] off_below: 0.10 do Klipper desliga a ventoinha sempre que o ciclo de trabalho comandado for inferior a 10%), esta opção e o limite do firmware devem idealmente ser definidos com o mesmo valor. A correspondência entre eles (ex.: off_below: 0.10 no Klipper e 10% aqui) garante que o fatiador nunca emita um valor diferente de zero que o firmware emitiria a velocidade cai silenciosamente e a ventoinha nunca recebe um valor abaixo daquele que você sabe que ela pode realmente atingir.\n" "Defina como 0 para desativar." msgid "Time cost" @@ -14834,13 +14851,11 @@ msgstr "Com que tipo de G-code a impressora é compatível." msgid "Klipper" msgstr "Klipper" -# AI Translated msgid "Skip G-code config block" msgstr "Omitir o bloco de configuração do G-code" -# AI Translated msgid "Do not write the CONFIG_BLOCK (slicer configuration key/value pairs) into the G-code file. This can help with printers whose firmware crashes when parsing these comment lines (e.g. Anycubic go-klipper). Note: the G-code file will no longer contain slicer settings, so importing it back into OrcaSlicer will not restore the configuration." -msgstr "Não grava o CONFIG_BLOCK (os pares chave/valor da configuração do fatiador) no arquivo G-code. Isso pode ajudar com impressoras cujo firmware trava ao interpretar essas linhas de comentário (por exemplo, Anycubic go-klipper). Observação: o arquivo G-code não conterá mais as configurações do fatiador, então importá-lo de volta no OrcaSlicer não restaurará a configuração." +msgstr "Não grava o CONFIG_BLOCK (os pares chave/valor da configuração do fatiador) no arquivo G-code. Isso pode ajudar com impressoras cujo firmware trava ao interpretar essas linhas de comentário (ex.: Anycubic go-klipper). Nota: o arquivo G-code não conterá mais as configurações do fatiador, então importá-lo de volta no OrcaSlicer não restaurará a configuração." msgid "Pellet Modded Printer" msgstr "Impressora Modificada para Pellets" @@ -14888,13 +14903,13 @@ msgid "Sparse infill rotation template" msgstr "Gabarito de rotação de preenchimento esparso" msgid "Rotate the sparse infill direction per layer using a template of angles. Enter comma-separated degrees (e.g., '0,30,60,90'). Angles are applied in order by layer and repeat when the list ends. Advanced syntax is supported: '+5' rotates +5° every layer; '+5#5' rotates +5° every 5 layers. See the Wiki for details. When a template is set, the standard infill direction setting is ignored. Note: some infill patterns (e.g., Gyroid) control rotation themselves; use with care." -msgstr "Gira a direção do preenchimento esparso por camada usando um gabarito de ângulos. Insira graus separados por vírgula (por exemplo, '0, 30, 60, 90'). Os ângulos são aplicados em ordem por camada e repetidos quando a lista termina. Sintaxe avançada suportada: '+5' gira +5° a cada camada; '+5#5' gira +5° a cada 5 camadas. Consulte a Wiki para obter detalhes. Quando um modelo é definido, a configuração padrão de direção do preenchimento é ignorada. Observação: alguns padrões de preenchimento (por exemplo, Giróide) tem seu próprio controle de rotação, use com cuidado." +msgstr "Gira a direção do preenchimento esparso por camada usando um gabarito de ângulos. Insira graus separados por vírgula (ex.: '0,30,60,90'). Os ângulos são aplicados em ordem por camada e repetidos quando a lista termina. Sintaxe avançada suportada: '+5' gira +5° a cada camada; '+5#5' gira +5° a cada 5 camadas. Consulte a Wiki para obter detalhes. Quando um modelo é definido, a configuração padrão de direção do preenchimento é ignorada. Nota: alguns padrões de preenchimento (ex.: Giróide) tem seu próprio controle de rotação, use com cuidado." msgid "Solid infill rotation template" msgstr "Gabarito de rotação de preenchimento sólido" msgid "This parameter adds a rotation of solid infill direction to each layer according to the specified template. The template is a comma-separated list of angles in degrees, e.g. '0,90'. The first angle is applied to the first layer, the second angle to the second layer, and so on. If there are more layers than angles, the angles will be repeated. Note that not all solid infill patterns support rotation." -msgstr "Este parâmetro adiciona uma rotação da direção do preenchimento sólido a cada camada, de acordo com o gabarito especificado. O gabarito é uma lista de ângulos em graus separados por vírgulas, por exemplo, '0,90'. O primeiro ângulo é aplicado à primeira camada, o segundo ângulo à segunda camada e assim por diante. Se houver mais camadas do que ângulos, os ângulos serão repetidos. Observe que nem todos os padrões de preenchimento sólido suportam rotação." +msgstr "Este parâmetro adiciona uma rotação da direção do preenchimento sólido a cada camada, de acordo com o gabarito especificado. O gabarito é uma lista de ângulos em graus separados por vírgulas, como '0,90'. O primeiro ângulo é aplicado à primeira camada, o segundo ângulo à segunda camada e assim por diante. Se houver mais camadas do que ângulos, os ângulos serão repetidos. Observe que nem todos os padrões de preenchimento sólido suportam rotação." msgid "Skeleton infill density" msgstr "Densidade de preenchimento de esqueleto" @@ -15360,7 +15375,6 @@ msgstr "Força máxima do eixo Y" msgid "The allowed maximum output force of Y axis" msgstr "A força máxima de saída permitida do eixo Y" -# AI Translated msgctxt "Newton" msgid "N" msgstr "N" @@ -15371,7 +15385,6 @@ msgstr "Massa da mesa do eixo Y" msgid "The machine bed mass load of Y axis" msgstr "A carga de massa da mesa do equipamento no eixo Y" -# AI Translated msgctxt "gram" msgid "g" msgstr "g" @@ -15896,7 +15909,7 @@ msgid "Z-hop height" msgstr "Altura de Z-hop" msgid "Whenever there is a retraction, the nozzle is lifted a little to create clearance between the nozzle and the print. This prevents the nozzle from hitting the print when traveling more. Using spiral lines to lift Z can prevent stringing." -msgstr "Sempre que há uma retração, o bico é levantado um pouco para criar folga entre o bico e a impressão. Isso evita que o bico atinja a impressão ao se mover. Usar linhas em espiral para levantar Z pode evitar stringing." +msgstr "Sempre que há uma retração, o bico é levantado um pouco para criar folga entre o bico e a impressão. Isso evita que o bico atinja a impressão ao se mover. Usar linhas em espiral para levantar Z pode evitar criação de fios." msgid "Z-hop lower boundary" msgstr "Limite inferior do Z-hop" @@ -15977,7 +15990,7 @@ msgid "Has filament switcher" msgstr "Tem trocador de filamentos" msgid "Printer has a filament switcher hardware (e.g., AMS)." -msgstr "A impressora tem um sistema de troca de filamentos (Ex.: AMS)." +msgstr "A impressora tem um sistema de troca de filamentos (ex.: AMS)." msgid "Extra length on restart" msgstr "Comprimento extra na retração" @@ -16094,7 +16107,7 @@ msgid "Scarf joint speed" msgstr "Velocidade da costura em bisel" msgid "This option sets the printing speed for scarf joints. It is recommended to print scarf joints at a slow speed (less than 100 mm/s). It's also advisable to enable 'Extrusion rate smoothing' if the set speed varies significantly from the speed of the outer or inner walls. If the speed specified here is higher than the speed of the outer or inner walls, the printer will default to the slower of the two speeds. When specified as a percentage (e.g., 80%), the speed is calculated based on the respective outer or inner wall speed. The default value is set to 100%." -msgstr "Esta opção define a velocidade de impressão para as costuras em bisel. É recomendável imprimir as costuras em bisel em uma velocidade baixa (menor que 100 mm/s). Também é aconselhável habilitar 'Suavização da taxa de extrusão' se a velocidade definida variar significativamente da velocidade das paredes externas ou internas. Se a velocidade especificada aqui for maior que a velocidade das paredes externas ou internas, a impressora utilizará a mais lenta das duas velocidades. Quando especificado como uma porcentagem (por exemplo, 80%), a velocidade é calculada com base na velocidade do perímetro externo ou interna respectiva. O valor padrão é definido como 100%." +msgstr "Esta opção define a velocidade de impressão para as costuras em bisel. É recomendável imprimir as costuras em bisel em uma velocidade baixa (menor que 100 mm/s). Também é aconselhável habilitar 'Suavização da taxa de extrusão' se a velocidade definida variar significativamente da velocidade das paredes externas ou internas. Se a velocidade especificada aqui for maior que a velocidade das paredes externas ou internas, a impressora utilizará a mais lenta das duas velocidades. Quando especificado como uma porcentagem (ex.: 80%), a velocidade é calculada com base na velocidade do perímetro externo ou interna respectiva. O valor padrão é definido como 100%." msgid "Scarf joint flow ratio" msgstr "Taxa de fluxo da costura em bisel" @@ -16164,7 +16177,7 @@ msgid "Wipe speed" msgstr "Velocidade de limpeza" msgid "The wipe speed is determined by the speed setting specified in this configuration. If the value is expressed as a percentage (e.g. 80%), it will be calculated based on the travel speed setting above. The default value for this parameter is 80%." -msgstr "A velocidade de limpeza é determinada pela velocidade especificada nesta configuração. Se o valor for expresso como uma porcentagem (por exemplo, 80%), será calculado com base na configuração de velocidade de deslocamento acima. O valor padrão para este parâmetro é 80%." +msgstr "A velocidade de limpeza é determinada pela velocidade especificada nesta configuração. Se o valor for expresso como uma porcentagem (ex.: 80%), será calculado com base na configuração de velocidade de deslocamento acima. O valor padrão para este parâmetro é 80%." msgid "Skirt distance" msgstr "Distância da saia" @@ -16202,7 +16215,7 @@ msgstr "" "Um escudo de ar é útil para proteger uma impressão ABS ou ASA de deformações e desprendimento da mesa de impressão devido à corrente de ar. Geralmente, ele é necessário apenas com impressoras de estrutura aberta, ou seja, sem um gabinete.\n" "\n" "Habilitado = a saia é tão alta quanto o objeto impresso mais alto. Caso contrário, 'Altura da saia' é usada.\n" -"Observação: com o escudo de ar ativo, a saia será impressa na distância da saia do objeto. Portanto, se as bordas estiverem ativas, ela pode se cruzar com elas. Para evitar isso, aumente o valor da distância da saia.\n" +"Nota: com o escudo de ar ativo, a saia será impressa na distância da saia do objeto. Portanto, se as bordas estiverem ativas, ela pode se cruzar com elas. Para evitar isso, aumente o valor da distância da saia.\n" msgid "Enabled" msgstr "Ativado" @@ -16343,10 +16356,10 @@ msgid "Preheat steps" msgstr "Passos de pré-aquecimento" msgid "Insert multiple preheat commands (e.g. M104.1). Only useful for Prusa XL. For other printers, please set it to 1." -msgstr "Insire múltiplos comandos de pré-aquecimento (por exemplo, M104.1). Útil apenas para Prusa XL. Para outras impressoras, defina como 1." +msgstr "Insire múltiplos comandos de pré-aquecimento (ex.: M104.1). Útil apenas para Prusa XL. Para outras impressoras, defina como 1." msgid "G-code written at the very top of the output file, before any other content. Useful for adding metadata that printer firmware reads from the first lines of the file (e.g. estimated print time, filament usage). Supports placeholders like {print_time_sec} and {used_filament_length}." -msgstr "Código G escrito no início do arquivo de saída, antes de qualquer outro conteúdo. Útil para adicionar metadados que o firmware da impressora lê das primeiras linhas do arquivo (por exemplo, tempo estimado de impressão, consumo de filamento). Suporta marcadores como {print_time_sec} e {used_filament_length}." +msgstr "Código G escrito no início do arquivo de saída, antes de qualquer outro conteúdo. Útil para adicionar metadados que o firmware da impressora lê das primeiras linhas do arquivo (ex.: tempo estimado de impressão, consumo de filamento). Suporta marcadores como {print_time_sec} e {used_filament_length}." msgid "Start G-code" msgstr "G-code Inicial" @@ -16367,7 +16380,7 @@ msgid "Manual Filament Change" msgstr "Troca Manual de Filamento" msgid "Enable this option to omit the custom Change filament G-code only at the beginning of the print. The tool change command (e.g., T0) will be skipped throughout the entire print. This is useful for manual multi-material printing, where we use M600/PAUSE to trigger the manual filament change action." -msgstr "Ative esta opção para omitir o G-code de troca de filamento personalizado apenas no início da impressão. O comando de troca de ferramenta (por exemplo, T0) será ignorado durante toda a impressão. Isso é útil para impressão manual de vários materiais, onde usamos M600/PAUSE para acionar a ação de troca manual de filamento." +msgstr "Ative esta opção para omitir o G-code de troca de filamento personalizado apenas no início da impressão. O comando de troca de ferramenta (ex.: T0) será ignorado durante toda a impressão. Isso é útil para impressão manual de vários materiais, onde usamos M600/PAUSE para acionar a ação de troca manual de filamento." msgid "Wipe tower type" msgstr "Tipo de torre de purga" @@ -16923,11 +16936,9 @@ msgstr "" "\n" "Definir um valor na configuração de quantidade de retração antes da limpeza abaixo executará qualquer retração em excesso antes da limpeza, caso contrário, será realizada após." -# AI Translated msgid "Mixed color sublayer" msgstr "Subcamada de cor mista" -# AI Translated msgid "Enable mixed color sublayer splitting. When enabled, layers containing mixed color filaments will be split into sub-layers to achieve color mixing effects." msgstr "Ativa a divisão em subcamadas de cor mista. Quando ativado, as camadas que contêm filamentos de cor mista são divididas em subcamadas para obter efeitos de mistura de cores." @@ -17987,9 +17998,8 @@ msgstr "A geração da malha do arquivo do modelo falhou ou não há forma váli msgid "The supplied file couldn't be read because it's empty." msgstr "O arquivo fornecido não pôde ser lido porque está vazio." -# AI Translated msgid "The file format is incompatible and cannot be parsed." -msgstr "O formato do arquivo é incompatível e não pode ser lido." +msgstr "O formato do arquivo é incompatível e não pode ser processado." msgid "Unknown file format: input file must have .stl, .obj, or .amf(.xml) extension." msgstr "Formato de arquivo desconhecido: o arquivo de entrada deve ter extensão .stl, .obj, .amf(.xml)." @@ -17998,10 +18008,10 @@ msgid "Unknown file format: input file must have .3mf or .zip.amf extension." msgstr "Formato de arquivo desconhecido: o arquivo de entrada deve ter extensão .3mf ou .zip.amf." msgid "load_obj: failed to parse" -msgstr "load_obj: falha ao analisar" +msgstr "load_obj: falha ao processar" msgid "load mtl in obj: failed to parse" -msgstr "carregar mtl em obj: falha ao analisar" +msgstr "carregar mtl em obj: falha ao processsar" msgid "The file contains polygons with more than 4 vertices." msgstr "O arquivo contém polígonos com mais de 4 vértices." @@ -19115,7 +19125,7 @@ msgid "Serial" msgstr "Série" msgid "e.g. Basic, Matte, Silk, Marble" -msgstr "por exemplo, Básico, Fosco, Seda, Mármore" +msgstr "Ex.: Básico, Fosco, Seda, Mármore" msgid "Filament Preset" msgstr "Predefinição de Filamento" @@ -19796,7 +19806,7 @@ msgid "Error. Can't get API token for authorization" msgstr "Erro. ​​Não foi possível obter o token de API para autorização" msgid "Could not parse server response." -msgstr "Não foi possível decifrar a resposta do servidor." +msgstr "Não foi possível processar a resposta do servidor." msgid "Error saving session to file" msgstr "Erro salvando sessão para arquivo" @@ -19864,7 +19874,7 @@ msgstr "O host respondeu, mas não parece ser o Moonraker (falta o result.klippy #, c-format, boost-format msgid "Could not parse Moonraker server response: %s" -msgstr "Não foi possível analisar a resposta do servidor Moonraker: %s" +msgstr "Não foi possível processar a resposta do servidor Moonraker: %s" msgid "Connection to OctoPrint is working correctly." msgstr "A conexão com o OctoPrint funciona corretamente." @@ -20238,7 +20248,6 @@ msgstr "Removido" msgid "Enable smart filament assign: Assign one filament to multiple nozzles to maximize savings" msgstr "Ativar atribuição inteligente de filamento: Atribui um filamento a vários bicos para maximizar a economia" -# AI Translated msgid "File Saving" msgstr "Salvamento de Arquivo" @@ -20425,7 +20434,7 @@ msgid "Connection timed out. Please check if the printer and computer network ar msgstr "Limite de tempo de conexão esgotado. Verifique se a impressora e a rede do computador estão funcionando corretamente e confirme se estão na mesma rede." msgid "The Hostname/IP/URL could not be parsed, please check it and try again." -msgstr "Não foi possível decifrar o Hostname/IP/URL; verifique-o e tente novamente." +msgstr "Não foi possível processar o Hostname/IP/URL; verifique-o e tente novamente." msgid "File/data transfer interrupted. Please check the printer and network, then try it again." msgstr "Transferência de arquivo/dados interrompida. Verifique a impressora e a rede e tente novamente." @@ -20522,6 +20531,15 @@ msgstr "Esta ação não pode ser desfeita. Continuar?" msgid "Skipping objects." msgstr "Ignorando objetos." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Selecionar Filamento" @@ -21199,15 +21217,6 @@ msgstr "" #~ msgid "Your system is missing H.264 codecs for GStreamer, which are required to play video. (Try installing the gstreamer1.0-plugins-bad or gstreamer1.0-libav packages, then restart Orca Slicer?)" #~ msgstr "Seu sistema não possui codecs H.264 para o GStreamer, que são necessários para reproduzir vídeos. (Tente instalar os pacotes gstreamer1.0-plugins-bad ou gstreamer1.0-libav e depois reinicie o OrcaSlicer?)" -#~ msgid "N" -#~ msgstr "N" - -#~ msgid "g" -#~ msgstr "g" - -#~ msgid "Fila Saving" -#~ msgstr "Econo Filamento" - #~ msgid "" #~ "Layer height is too small.\n" #~ "It will set to min_layer_height\n" @@ -21535,7 +21544,7 @@ msgstr "" #~ "Pontes externas de menor densidade podem ajudar a melhorar a confiabilidade, pois há mais espaço para o ar circular ao redor da ponte extrudada, melhorando sua velocidade de resfriamento. O mínimo é 10%.\n" #~ "\n" #~ "Densidades mais altas podem produzir superfícies de ponte mais lisas, pois as linhas sobrepostas fornecem suporte adicional durante a impressão. O máximo é 120%.\n" -#~ "Observação: Densidade de ponte muito alta pode causar deformação ou sobrextrusão." +#~ "Nota: Densidade de ponte muito alta pode causar deformação ou sobrextrusão." #~ msgid "" #~ "Controls the density (spacing) of internal bridge lines. 100% means solid bridge. Default is 100%.\n" @@ -21580,7 +21589,7 @@ msgstr "" #~ "\n" #~ "Geralmente, é recomendável ter esta opção ativada, a menos que o resfriamento da impressora seja potente o suficiente ou a velocidade de impressão lenta o suficiente para que a curvatura do perímetro não aconteça. Se estiver imprimindo com uma alta velocidade de perímetro externo, este parâmetro pode introduzir pequenos artefatos ao desacelerar devido à grande variação nas velocidades de impressão. Se você notar artefatos, certifique-se de que seu pressure advance esteja ajustado corretamente.\n" #~ "\n" -#~ "Observação: quando esta opção estiver habilitada, os perímetros de saliência são tratados como saliências, o que significa que a velocidade de saliência é aplicada mesmo se o perímetro de saliência for parte de uma ponte. Por exemplo, quando os perímetros estiverem 100% salientes, sem nenhuma parede apoiando-os por baixo, a velocidade de saliência de 100% será aplicada." +#~ "Nota: quando esta opção estiver habilitada, os perímetros de saliência são tratados como saliências, o que significa que a velocidade de saliência é aplicada mesmo se o perímetro de saliência for parte de uma ponte. Por exemplo, quando os perímetros estiverem 100% salientes, sem nenhuma parede apoiando-os por baixo, a velocidade de saliência de 100% será aplicada." #~ msgid "If enabled, bridges are more reliable, can bridge longer distances, but may look worse. If disabled, bridges look better but are reliable just for shorter bridged distances." #~ msgstr "Se ativado, as pontes são mais confiáveis, podem cobrir distâncias maiores, mas podem parecer piores. Se desativado, as pontes ficam melhores, mas são confiáveis apenas para distâncias de ponte mais curtas." @@ -22500,7 +22509,7 @@ msgstr "" #~ msgstr "Contagem máxima de projetos recentes" #~ msgid "This parameter adds a rotation of sparse infill direction to each layer according to the specified template. The template is a comma-separated list of angles in degrees, e.g. '0,90'. The first angle is applied to the first layer, the second angle to the second layer, and so on. If there are more layers than angles, the angles will be repeated. Note that not all sparse infill patterns support rotation." -#~ msgstr "Este parâmetro adiciona uma rotação na direção do preenchimento esparso a cada camada, de acordo com o gabarito especificado. O gabarito é uma lista de ângulos em graus separados por vírgulas, por exemplo, '0,90'. O primeiro ângulo é aplicado à primeira camada, o segundo ângulo à segunda camada e assim por diante. Se houver mais camadas do que ângulos, os ângulos serão repetidos. Observe que nem todos os padrões de preenchimento esparso suportam rotação." +#~ msgstr "Este parâmetro adiciona uma rotação na direção do preenchimento esparso a cada camada, de acordo com o gabarito especificado. O gabarito é uma lista de ângulos em graus separados por vírgulas, como '0,90'. O primeiro ângulo é aplicado à primeira camada, o segundo ângulo à segunda camada e assim por diante. Se houver mais camadas do que ângulos, os ângulos serão repetidos. Observe que nem todos os padrões de preenchimento esparso suportam rotação." #~ msgid "Set Position" #~ msgstr "Definir Posição" diff --git a/localization/i18n/ru/OrcaSlicer_ru.po b/localization/i18n/ru/OrcaSlicer_ru.po index 112fbc2834..972f853df4 100644 --- a/localization/i18n/ru/OrcaSlicer_ru.po +++ b/localization/i18n/ru/OrcaSlicer_ru.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: OrcaSlicer V2.5.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2026-02-25 13:38+0300\n" "Last-Translator: Felix14_v2\n" "Language-Team: Felix14_v2 (ДС/ТГ: @felix14_v2, почта: aleks111001@list.ru), Andylg \n" @@ -2431,8 +2431,6 @@ msgstr "Доступно обновление. Проверьте меню па msgid "%s has been removed." msgstr "%s был удалён." -msgid "Switching application language" -msgstr "Изменение языка приложения" msgid "Select the language" msgstr "Выбор языка" @@ -4891,6 +4889,11 @@ msgid "" "No - Cancel enabling spiral mode" msgstr "Использовать эти настройки и режим вазы?" +# Не знаю, как и почему, но это, похоже, исправляет "вопросики" вместо +# символов +msgid "N/A" +msgstr "–" + msgid "Printing" msgstr "Печать" @@ -5332,13 +5335,9 @@ msgstr "Значение %s выходит за пределы допустим #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"Имелось ввиду %s%%? (введено %s %s)\n" -"Да – изменить на %s%%\n" -"Нет – оставить %s %s." +"Имелось ввиду %s%% или %s %s?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5360,11 +5359,6 @@ msgstr "Недопустимый шаблон. Используйте N, N#K и msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Недопустимый формат. Ожидаемый векторный формат: \"%1%\"" -# Не знаю, как и почему, но это, похоже, исправляет "вопросики" вместо -# символов -msgid "N/A" -msgstr "–" - msgid "System agents" msgstr "Системные агенты" @@ -6378,6 +6372,12 @@ msgstr "Сохранить проект как" msgid "Save current project as" msgstr "Сохранить текущий проект как" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Импорт 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8212,6 +8212,12 @@ msgstr "Во избежание повреждения принтера убед msgid "Customized Preset" msgstr "Пользовательский профиль" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Имена компонентов внутри файла STEP не в формате UTF-8." @@ -8624,6 +8630,17 @@ msgstr "Сохранить нарезанный файл как:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Файл %s отправлен в память принтера и может быть просмотрен на нём." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Опубликовать" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Не указан тип сопла. Укажите его и попробуйте ещё раз." @@ -8829,8 +8846,6 @@ msgstr "Хотите продолжить?" msgid "Language selection" msgstr "Выбор языка" -msgid "Switching application language while some presets are modified." -msgstr "Смена языка приложения при изменении некоторых профилей." msgid "Asia-Pacific" msgstr "Азиатско-Тихоокеанский" @@ -9770,9 +9785,6 @@ msgstr "Перейти на веб-страницу публикации мод msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Примечание: подготовка может занять несколько минут. Пожалуйста, наберитесь терпения." -msgid "Publish" -msgstr "Опубликовать" - msgid "Publish was canceled" msgstr "Публикация была отменена" @@ -9788,6 +9800,64 @@ msgstr "Отправка данных" msgid "Jump to webpage" msgstr "Перейти на страницу" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Сохранить %s как" @@ -21541,6 +21611,15 @@ msgstr "Это действие необратимо. Продолжить?" msgid "Skipping objects." msgstr "Исключение объектов." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Выбрать материал" diff --git a/localization/i18n/sv/OrcaSlicer_sv.po b/localization/i18n/sv/OrcaSlicer_sv.po index 7ec20d136d..472b105744 100644 --- a/localization/i18n/sv/OrcaSlicer_sv.po +++ b/localization/i18n/sv/OrcaSlicer_sv.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "Language: sv\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -2767,8 +2767,6 @@ msgstr "Det finns en uppdatering tillgänglig. Öppna dialogrutan för förinst msgid "%s has been removed." msgstr "%s har tagits bort." -msgid "Switching application language" -msgstr "Byt applikationsspråk" msgid "Select the language" msgstr "Välj språk" @@ -5387,6 +5385,10 @@ msgstr "" "JA -Ändra dessa inställningar och möjliggör Spiral läge automatiskt\n" "NEJ -Avbryt Spiral läge denna gång" +# AI Translated +msgid "N/A" +msgstr "Ej tillämpligt" + msgid "Printing" msgstr "Utskrift pågår" @@ -5903,13 +5905,9 @@ msgstr "Värdet %s ligger utanför intervallet. Giltigt intervall är från %d t #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"Det är %s%% eller %s %s?\n" -"JA för %s%%, \n" -"NEJ för %s %s." +"Det är %s%% eller %s %s?" # AI Translated #, boost-format @@ -5936,10 +5934,6 @@ msgstr "Ogiltigt mönster. Använd N, N#K eller en kommaseparerad lista med valf msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Ogiltligt format. Förväntat vector format: \"%1%\"" -# AI Translated -msgid "N/A" -msgstr "Ej tillämpligt" - # AI Translated msgid "System agents" msgstr "Systemagenter" @@ -6972,6 +6966,12 @@ msgstr "Spara Projekt som" msgid "Save current project as" msgstr "Spara nuvarande projekt som" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Importera 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8996,6 +8996,12 @@ msgstr "Bekräfta att G-koderna i dessa inställningar är säkra för att förh msgid "Customized Preset" msgstr "Anpassad inställning" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Komponent namnet i STEP filen är inte UTF-8 format!" @@ -9432,6 +9438,17 @@ msgstr "Spara beredningen som:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Filen %s har skickats till skrivarens lagringsutrymme och kan visas på skrivaren." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Publicera" + # AI Translated msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Nozzeltypen är inte angiven. Ange nozzeln och försök igen." @@ -9671,8 +9688,6 @@ msgstr "Fortsätta?" msgid "Language selection" msgstr "Språkval" -msgid "Switching application language while some presets are modified." -msgstr "Byter språk medans inställningarna ändras." msgid "Asia-Pacific" msgstr "Asien-Stillahavsområdet" @@ -10740,9 +10755,6 @@ msgstr "Växla till modell publicerings hemsidan" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Notera: Förberedelserna kan ta flera minuter. Vänligen vänta." -msgid "Publish" -msgstr "Publicera" - msgid "Publish was canceled" msgstr "Publiceringen avbröts" @@ -10759,6 +10771,64 @@ msgstr "Laddar upp data" msgid "Jump to webpage" msgstr "Växla till hemsidan" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Spara %s som" @@ -23513,6 +23583,15 @@ msgstr "Åtgärden kan inte ångras. Vill du fortsätta?" msgid "Skipping objects." msgstr "Hoppar över objekt." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Välj filament" diff --git a/localization/i18n/th/OrcaSlicer_th.po b/localization/i18n/th/OrcaSlicer_th.po index 16dcb17ab1..2865914d23 100644 --- a/localization/i18n/th/OrcaSlicer_th.po +++ b/localization/i18n/th/OrcaSlicer_th.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2026-06-19 13:40+0700\n" "Last-Translator: Icezaza\n" "Language-Team: Thai\n" @@ -2456,8 +2456,6 @@ msgstr "มีอัปเดตพร้อมใช้งาน เปิด msgid "%s has been removed." msgstr "ลบ %s แล้ว" -msgid "Switching application language" -msgstr "การเปลี่ยนภาษาของแอปพลิเคชัน" msgid "Select the language" msgstr "เลือกภาษา" @@ -4887,6 +4885,9 @@ msgstr "" "ใช่ - เปลี่ยนการตั้งค่าเหล่านี้และเปิดใช้งานโหมดเกลียวโดยอัตโนมัติ\n" "ไม่ - เลิกใช้โหมดเกลียวในครั้งนี้" +msgid "N/A" +msgstr "ไม่มี" + msgid "Printing" msgstr "กำลังพิมพ์" @@ -5318,13 +5319,9 @@ msgstr "ค่า %s อยู่นอกช่วง ช่วงที่ถ #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"มันคือ %s%% หรือ %s %s?\n" -"ใช่สำหรับ %s%% \n" -"ไม่ สำหรับ %s %s" +"มันคือ %s%% หรือ %s %s?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5346,9 +5343,6 @@ msgstr "รูปแบบไม่ถูกต้อง ใช้ N, N#K หร msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "รูปแบบไม่ถูกต้อง รูปแบบเวกเตอร์ที่ต้องการ: \"%1%\"" -msgid "N/A" -msgstr "ไม่มี" - # AI Translated msgid "System agents" msgstr "เอเจนต์ระบบ" @@ -6304,6 +6298,12 @@ msgstr "บันทึกโปรเจกต์เป็น" msgid "Save current project as" msgstr "บันทึกโครงการปัจจุบันเป็น" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "นำเข้า 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8118,6 +8118,12 @@ msgstr "โปรดยืนยันว่ารหัส G ภายในค msgid "Customized Preset" msgstr "ค่าที่ตั้งไว้ล่วงหน้าที่กำหนดเอง" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "ชื่อของส่วนประกอบภายในไฟล์ STEP ไม่ใช่รูปแบบ UTF-8!" @@ -8525,6 +8531,17 @@ msgstr "บันทึกไฟล์ที่สไลซ์เป็น:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "ไฟล์ %s ถูกส่งไปยังพื้นที่เก็บข้อมูลของเครื่องพิมพ์แล้ว และสามารถดูได้บนเครื่องพิมพ์" +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "เผยแพร่" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "ไม่ได้ตั้งค่าประเภทหัวฉีด โปรดตั้งหัวฉีดแล้วลองอีกครั้ง" @@ -8735,8 +8752,6 @@ msgstr "ต้องการดำเนินการต่อหรือไ msgid "Language selection" msgstr "การเลือกภาษา" -msgid "Switching application language while some presets are modified." -msgstr "การสลับภาษาของแอปพลิเคชันในขณะที่มีการแก้ไขค่าที่ตั้งไว้บางส่วน" msgid "Asia-Pacific" msgstr "เอเชียแปซิฟิก" @@ -9667,9 +9682,6 @@ msgstr "ข้ามไปที่โมเดลเผยแพร่หน้ msgid "Note: The preparation may take several minutes. Please be patient." msgstr "หมายเหตุ: การเตรียมการอาจใช้เวลาหลายนาที กรุณาอดทน." -msgid "Publish" -msgstr "เผยแพร่" - msgid "Publish was canceled" msgstr "การเผยแพร่ถูกยกเลิก" @@ -9685,6 +9697,64 @@ msgstr "กำลังอัพโหลดข้อมูล" msgid "Jump to webpage" msgstr "ข้ามไปที่หน้าเว็บ" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "บันทึก %s เป็น" @@ -20891,6 +20961,15 @@ msgstr "การทำงานนี้ไม่สามารถย้อน msgid "Skipping objects." msgstr "ข้ามวัตถุ" +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "เลือกเส้นพลาสติก" diff --git a/localization/i18n/tr/OrcaSlicer_tr.po b/localization/i18n/tr/OrcaSlicer_tr.po index 0e51b7ca9c..a5c11f5300 100644 --- a/localization/i18n/tr/OrcaSlicer_tr.po +++ b/localization/i18n/tr/OrcaSlicer_tr.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2026-08-21 23:18+0300\n" "Last-Translator: GlauTech\n" "Language-Team: \n" @@ -2480,8 +2480,6 @@ msgstr "Kullanılabilir bir güncelleme var. Güncellemek için ön ayar paketi msgid "%s has been removed." msgstr "%s kaldırıldı." -msgid "Switching application language" -msgstr "Uygulama dilini değiştirme" msgid "Select the language" msgstr "Dili seçin" @@ -4949,6 +4947,10 @@ msgstr "" "Evet - Bu ayarları değiştirin ve spiral modunu otomatik olarak etkinleştirin\n" "Hayır - Bu sefer spiral modunu kullanmaktan vazgeçin" +# AI Translated +msgid "N/A" +msgstr "Yok" + msgid "Printing" msgstr "Baskı" @@ -5380,13 +5382,9 @@ msgstr "Değer %s aralık dışında. Geçerli aralık %d ile %d arasındadır." #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"%s%% mi yoksa %s %s mi?\n" -"%s%% için EVET,\n" -"%s %s için HAYIR." +"%s%% mi yoksa %s %s mi?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5408,10 +5406,6 @@ msgstr "Geçersiz kalıp. N, N#K veya giriş başına isteğe bağlı #K ile vir msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Geçersiz format. Beklenen vektör formatı: \"%1%\"" -# AI Translated -msgid "N/A" -msgstr "Yok" - # AI Translated msgid "System agents" msgstr "Sistem aracıları" @@ -6372,6 +6366,12 @@ msgstr "Projeyi farklı kaydet" msgid "Save current project as" msgstr "Mevcut projeyi farklı kaydet" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "3MF/STL/STEP/SVG/OBJ/AMF'yi içe aktar" @@ -8215,6 +8215,12 @@ msgstr "Lütfen bu ön ayarlar içindeki G-code'larının makineye herhangi bir msgid "Customized Preset" msgstr "Özel Ayar" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Step dosyasındaki bileşenlerin adı UTF-8 formatında değil!" @@ -8624,6 +8630,17 @@ msgstr "Dilimlenmiş dosyayı şu şekilde kaydedin:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "%s dosyası yazıcının depolama alanına gönderildi ve yazıcıda görüntülenebiliyor." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Yayınla" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Nozul tipi ayarlanmamış. Lütfen nozulu ayarlayın ve tekrar deneyin." @@ -8837,8 +8854,6 @@ msgstr "Devam etmek istiyor musun?" msgid "Language selection" msgstr "Dil seçimi" -msgid "Switching application language while some presets are modified." -msgstr "Bazı ön ayarlar değiştirilirken uygulama dilinin değiştirilmesi." msgid "Asia-Pacific" msgstr "Asya Pasifik" @@ -9811,9 +9826,6 @@ msgstr "Model yayınlama web sayfasına git" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Not: Hazırlık birkaç dakika sürebilir. Lütfen sabırlı olun." -msgid "Publish" -msgstr "Yayınla" - msgid "Publish was canceled" msgstr "Yayınlama iptal edildi" @@ -9829,6 +9841,64 @@ msgstr "Veriler yükleniyor" msgid "Jump to webpage" msgstr "Web sayfasına atla" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "%s'yi farklı kaydet" @@ -21318,6 +21388,15 @@ msgstr "Bu eylem geri alınamaz. Devam etmek?" msgid "Skipping objects." msgstr "Nesneleri atlama." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Filament Seçin" diff --git a/localization/i18n/uk/OrcaSlicer_uk.po b/localization/i18n/uk/OrcaSlicer_uk.po index 3d23f7bbb1..c3ac201896 100644 --- a/localization/i18n/uk/OrcaSlicer_uk.po +++ b/localization/i18n/uk/OrcaSlicer_uk.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: orcaslicerua\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2026-07-17 16:25+0300\n" "Last-Translator: Andrij Mizyk \n" "Language-Team: Ukrainian\n" @@ -2424,8 +2424,6 @@ msgstr "Доступне оновлення. Відкрийте вікно на msgid "%s has been removed." msgstr "%s вилучено." -msgid "Switching application language" -msgstr "Зміна мови програми" msgid "Select the language" msgstr "Вибрати мову" @@ -4887,6 +4885,9 @@ msgstr "" "Так – змінити ці налаштування та автоматично включити режим спіральна ваза\n" "Ні - цього разу відмовитися від використання режиму спіральна ваза" +msgid "N/A" +msgstr "Н/Д" + msgid "Printing" msgstr "Друк" @@ -5329,13 +5330,9 @@ msgstr "Значення %s знаходиться за межами діапа #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"Це %s%% або %s %s?\n" -"ТАК для %s%%, \n" -"НІ для %s %s." +"Це %s%% або %s %s?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5358,9 +5355,6 @@ msgstr "Некоректний шаблон. Використовуйте N, N#K msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Невірний формат. Очікуваний векторний формат: \"%1%\"" -msgid "N/A" -msgstr "Н/Д" - # AI Translated msgid "System agents" msgstr "Системні агенти" @@ -6341,6 +6335,12 @@ msgstr "Зберегти проєкт як" msgid "Save current project as" msgstr "Зберегти поточний проєкт як" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Імпорт 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8223,6 +8223,12 @@ msgstr "Будь ласка, підтвердьте, що G-коди в цих msgid "Customized Preset" msgstr "Пристосований пресет" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + # AI Translated msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Назви компонентів усередині файлу STEP не у форматі UTF-8!" @@ -8635,6 +8641,17 @@ msgstr "Зберегти нарізаний файл як:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "Файл %s надіслано до памʼяті принтера та доступний для перегляду на принтері." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Публікувати" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Тип сопла не встановлений. Будь ласка, оберіть сопло та спробуйте ще раз." @@ -8851,8 +8868,6 @@ msgstr "Ви хочете продовжувати?" msgid "Language selection" msgstr "Вибір мови" -msgid "Switching application language while some presets are modified." -msgstr "Зміна мови програми при зміні деяких профілів." msgid "Asia-Pacific" msgstr "Азіатсько-Тихоокеанський регіон" @@ -9814,9 +9829,6 @@ msgstr "Перейти на веб-сторінку публікації мод msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Примітка. Підготовка може тривати кілька хвилин. Будь ласка, будьте терплячі." -msgid "Publish" -msgstr "Публікувати" - msgid "Publish was canceled" msgstr "Публікація скасована" @@ -9832,6 +9844,64 @@ msgstr "Відвантаження даних" msgid "Jump to webpage" msgstr "Перейти на вебсторінку" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Зберегти %s як" @@ -21495,6 +21565,15 @@ msgstr "Дію не можливо завершити. Продовжити?" msgid "Skipping objects." msgstr "Пропускання обʼєктів." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "Вибрати філамент" diff --git a/localization/i18n/vi/OrcaSlicer_vi.po b/localization/i18n/vi/OrcaSlicer_vi.po index 8ec3447568..4504b5c95c 100644 --- a/localization/i18n/vi/OrcaSlicer_vi.po +++ b/localization/i18n/vi/OrcaSlicer_vi.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2025-10-02 17:43+0700\n" "Last-Translator: \n" "Language-Team: hainguyen.ts13@gmail.com\n" @@ -2568,8 +2568,6 @@ msgstr "Có bản cập nhật khả dụng. Hãy mở hộp thoại gói cài msgid "%s has been removed." msgstr "%s đã bị xóa." -msgid "Switching application language" -msgstr "Đang chuyển ngôn ngữ ứng dụng" msgid "Select the language" msgstr "Chọn ngôn ngữ" @@ -5143,6 +5141,10 @@ msgstr "" "Yes - Thay đổi các cài đặt này và bật chế độ spiral tự động\n" "No - Từ bỏ dùng chế độ spiral lần này" +# AI Translated +msgid "N/A" +msgstr "N/A" + msgid "Printing" msgstr "Đang in" @@ -5633,13 +5635,9 @@ msgstr "Giá trị %s nằm ngoài phạm vi. Phạm vi hợp lệ từ %d đế #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"Là %s%% hay %s %s?\n" -"YES cho %s%%, \n" -"NO cho %s %s." +"Là %s%% hay %s %s?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5661,10 +5659,6 @@ msgstr "Mẫu không hợp lệ. Dùng N, N#K, hoặc danh sách phân cách d msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "Định dạng không hợp lệ. Mong đợi định dạng vector: \"%1%\"" -# AI Translated -msgid "N/A" -msgstr "N/A" - # AI Translated msgid "System agents" msgstr "Tác nhân hệ thống" @@ -6689,6 +6683,12 @@ msgstr "Lưu dự án thành" msgid "Save current project as" msgstr "Lưu dự án hiện tại thành" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "Nhập 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8629,6 +8629,12 @@ msgstr "Vui lòng xác nhận G-code trong các preset này an toàn để ngăn msgid "Customized Preset" msgstr "Preset tùy chỉnh" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "Tên của các thành phần bên trong file STEP không phải định dạng UTF-8!" @@ -9056,6 +9062,17 @@ msgstr "Lưu file đã slice dưới dạng:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "File %s đã được gửi đến không gian lưu trữ của máy in và có thể được xem trên máy in." +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "Xuất bản" + # AI Translated msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "Chưa đặt loại đầu phun. Vui lòng đặt đầu phun rồi thử lại." @@ -9286,8 +9303,6 @@ msgstr "Bạn có muốn tiếp tục?" msgid "Language selection" msgstr "Chọn ngôn ngữ" -msgid "Switching application language while some presets are modified." -msgstr "Đang chuyển đổi ngôn ngữ ứng dụng trong khi một số preset đã được chỉnh sửa." msgid "Asia-Pacific" msgstr "Châu Á-Thái Bình Dương" @@ -10326,9 +10341,6 @@ msgstr "Chuyển đến trang web xuất bản model" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "Lưu ý: Chuẩn bị có thể mất vài phút. Vui lòng kiên nhẫn." -msgid "Publish" -msgstr "Xuất bản" - msgid "Publish was canceled" msgstr "Xuất bản đã bị hủy" @@ -10345,6 +10357,64 @@ msgstr "Đang tải dữ liệu lên" msgid "Jump to webpage" msgstr "Chuyển đến trang web" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "Lưu %s dưới dạng" @@ -22165,6 +22235,15 @@ msgstr "Thao tác này không thể hoàn tác. Tiếp tục?" msgid "Skipping objects." msgstr "Đang bỏ qua các vật thể." +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + # AI Translated msgid "Select Filament" msgstr "Chọn filament" diff --git a/localization/i18n/zh_CN/OrcaSlicer_zh_CN.po b/localization/i18n/zh_CN/OrcaSlicer_zh_CN.po index 81cd5a402b..ff2e0fd940 100644 --- a/localization/i18n/zh_CN/OrcaSlicer_zh_CN.po +++ b/localization/i18n/zh_CN/OrcaSlicer_zh_CN.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Slic3rPE\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2026-06-11 12:37-0300\n" "Last-Translator: Handle \n" "Language-Team: \n" @@ -2361,8 +2361,6 @@ msgstr "有更新可用。打开预设包对话框进行更新。" msgid "%s has been removed." msgstr "%s 已被移除。" -msgid "Switching application language" -msgstr "切换应用程序语言" msgid "Select the language" msgstr "选择语言" @@ -4743,6 +4741,9 @@ msgstr "" "是 - 自动调整这些设置并开启旋转模式\n" "否 - 暂不使用旋转模式" +msgid "N/A" +msgstr "不适用" + msgid "Printing" msgstr "打印中" @@ -5174,13 +5175,9 @@ msgstr "值 %s 超出了范围,有效的范围是从 %d 到 %d 。" #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"%s%%还是%s %s?\n" -"是:%s%%\n" -"否:%s %s" +"%s%%还是%s %s?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5202,9 +5199,6 @@ msgstr "无效的模式。请使用 N、N#K 或逗号分隔的列表(每个条 msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "无效格式,应该是\"%1%\"这种数组格式" -msgid "N/A" -msgstr "不适用" - # AI Translated msgid "System agents" msgstr "系统代理" @@ -6159,6 +6153,12 @@ msgstr "项目另存为" msgid "Save current project as" msgstr "项目另存为" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "导入 3MF/STL/STEP/SVG/OBJ/AMF" @@ -7949,6 +7949,12 @@ msgstr "请确认这些预设中的G-codes是否安全,以防止对机器造 msgid "Customized Preset" msgstr "自定义的预设" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "STEP 文件中的部件名称不是 UTF-8 格式!" @@ -8354,6 +8360,17 @@ msgstr "切片文件另存为:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "文件%s已经发送到打印机的存储空间,可以在打印机上浏览。" +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "发布" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "未设置喷嘴类型。请设置喷嘴并重试。" @@ -8564,8 +8581,6 @@ msgstr "是否继续?" msgid "Language selection" msgstr "语言选择" -msgid "Switching application language while some presets are modified." -msgstr "在切换应用语言之前发现某些参数预设有更改。" msgid "Asia-Pacific" msgstr "亚太" @@ -9489,9 +9504,6 @@ msgstr "跳转到发布页面" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "提示:发布前需要一些准备时间,请耐心等待。" -msgid "Publish" -msgstr "发布" - msgid "Publish was canceled" msgstr "发布已取消" @@ -9507,6 +9519,64 @@ msgstr "正在上传数据" msgid "Jump to webpage" msgstr "跳转到网页" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "另存%s为" @@ -20639,6 +20709,15 @@ msgstr "此操作无法撤消。继续?" msgid "Skipping objects." msgstr "跳过对象。" +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "选择耗材" diff --git a/localization/i18n/zh_TW/OrcaSlicer_zh_TW.po b/localization/i18n/zh_TW/OrcaSlicer_zh_TW.po index ded90c8950..e0e468fccf 100644 --- a/localization/i18n/zh_TW/OrcaSlicer_zh_TW.po +++ b/localization/i18n/zh_TW/OrcaSlicer_zh_TW.po @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: Orca Slicer\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-02 09:39-0300\n" +"POT-Creation-Date: 2026-09-03 10:27+0800\n" "PO-Revision-Date: 2025-11-28 13:48-0600\n" "Last-Translator: tntchn <15895303+tntchn@users.noreply.github.com>\n" "Language-Team: \n" @@ -2425,8 +2425,6 @@ msgstr "有可用的更新。請開啟預設組合對話框進行更新。" msgid "%s has been removed." msgstr "%s 已移除。" -msgid "Switching application language" -msgstr "切換應用程式語言" msgid "Select the language" msgstr "選擇語言" @@ -4872,6 +4870,9 @@ msgstr "" "是 - 自動調整這些設定並啟用花瓶模式\n" "否 - 不使用花瓶模式" +msgid "N/A" +msgstr "不適用" + msgid "Printing" msgstr "列印中" @@ -5303,13 +5304,9 @@ msgstr "數值 %s 超出範圍。有效範圍是從 %d 到 %d。" #, c-format, boost-format msgid "" -"Is it %s%% or %s %s?\n" -"YES for %s%%, \n" -"NO for %s %s." +"Is it %s%% or %s %s?" msgstr "" -"是 %s%% 還是 %s %s?\n" -"選『是』代表 %s%%,\n" -"選『否』代表 %s %s。" +"是 %s%% 還是 %s %s?" #, boost-format msgid "Invalid input format. Expected vector of dimensions in the following format: \"%1%\"" @@ -5331,9 +5328,6 @@ msgstr "無效的格式。請使用 N、N#K 或逗號分隔的清單,每個項 msgid "Invalid format. Expected vector format: \"%1%\"" msgstr "無效格式,應該是「%1%」這種格式" -msgid "N/A" -msgstr "不適用" - # AI Translated msgid "System agents" msgstr "系統代理程式" @@ -6289,6 +6283,12 @@ msgstr "另存專案為" msgid "Save current project as" msgstr "將目前專案另存為" +msgid "Publish 3MF" +msgstr "" + +msgid "Export a 3MF file with the selected settings embedded" +msgstr "" + msgid "Import 3MF/STL/STEP/SVG/OBJ/AMF" msgstr "匯入 3MF/STL/STEP/SVG/OBJ/AMF" @@ -8110,6 +8110,12 @@ msgstr "請確認這些預設中的 G-code 是安全的,以防止對列印裝 msgid "Customized Preset" msgstr "自訂預設" +msgid "Some published settings could not be applied:" +msgstr "" + +msgid "Some filament slots were changed:" +msgstr "" + msgid "Component name(s) inside step file not in UTF-8 format!" msgstr "STEP 檔案內部元件的名稱不是 UTF-8 格式!" @@ -8520,6 +8526,17 @@ msgstr "切片檔案另存為:" msgid "The file %s has been sent to the printer's storage space and can be viewed on the printer." msgstr "檔案 %s 已經傳送到列印裝置的儲存空間,可以在列印裝置上瀏覽。" +msgid "Publish 3MF file as:" +msgstr "" + +msgid "" +"Failed to export the published 3MF file.\n" +"Please check whether the folder exists online or if other programs have the file open." +msgstr "" + +msgid "Publish" +msgstr "發布" + msgid "The nozzle type is not set. Please set the nozzle and try again." msgstr "噴嘴類型尚未設定。請設定噴嘴後再試一次。" @@ -8730,8 +8747,6 @@ msgstr "是否繼續?" msgid "Language selection" msgstr "語言選擇" -msgid "Switching application language while some presets are modified." -msgstr "在切換應用程式語言之前發現某些參數預設有更改。" msgid "Asia-Pacific" msgstr "亞太" @@ -9662,9 +9677,6 @@ msgstr "發布頁面" msgid "Note: The preparation may take several minutes. Please be patient." msgstr "提示:發布前需要一些準備時間,請耐心等待。" -msgid "Publish" -msgstr "發布" - msgid "Publish was canceled" msgstr "發布已取消" @@ -9680,6 +9692,64 @@ msgstr "正在上傳資料" msgid "Jump to webpage" msgstr "跳至網頁" +msgid "Material" +msgstr "" + +msgid "Mixed filament" +msgstr "" + +msgid "Some mixed filaments rely on filaments that will not be published:" +msgstr "" + +#, c-format, boost-format +msgid "Filament %d (mixed)" +msgstr "" + +msgid "needs" +msgstr "" + +msgid "not enabled" +msgstr "" + +msgid "material not published" +msgstr "" + +msgid "To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement." +msgstr "" + +msgid "Publish anyway" +msgstr "" + +msgid "Publish 3MF..." +msgstr "" + +msgid "Select which settings to be published in the 3MF file" +msgstr "" + +msgid "Publish 3MF Wiki" +msgstr "" + +msgid "Publish 3MF Video Guide" +msgstr "" + +msgid "Mixed filament - published as a whole when \"Enable\" above is selected" +msgstr "" + +msgid "Publish this mixed filament and enable + Full Publish its component filaments" +msgstr "" + +msgid "Publish this filament slot in the 3MF file" +msgstr "" + +msgid "Full Publish" +msgstr "" + +msgid "Embed the entire filament of this slot in the 3MF file" +msgstr "" + +msgid "Filter non-selected" +msgstr "" + #, c-format, boost-format msgid "Save %s as" msgstr "另存 %s 為" @@ -20832,6 +20902,15 @@ msgstr "此操作無法撤消。繼續?" msgid "Skipping objects." msgstr "跳過物件。" +msgid "Material Ratio" +msgstr "" + +msgid "Model Height" +msgstr "" + +msgid "Ratio" +msgstr "" + msgid "Select Filament" msgstr "選擇線材" diff --git a/resources/images/menu_publish.svg b/resources/images/menu_publish.svg new file mode 100644 index 0000000000..e444623e33 --- /dev/null +++ b/resources/images/menu_publish.svg @@ -0,0 +1 @@ + diff --git a/resources/images/param_add.svg b/resources/images/param_add.svg index 71ea5092af..b00140b68a 100644 --- a/resources/images/param_add.svg +++ b/resources/images/param_add.svg @@ -1,8 +1,4 @@ - - - - Layer 1 - - - - + + + + \ No newline at end of file diff --git a/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra S1 Max 0.25 nozzle.json b/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra S1 Max 0.25 nozzle.json index 7fb1d1f66c..4852093cda 100644 --- a/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra S1 Max 0.25 nozzle.json +++ b/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra S1 Max 0.25 nozzle.json @@ -324,12 +324,6 @@ "slow_down_min_speed": [ "20" ], - "supertack_plate_temp": [ - "35" - ], - "supertack_plate_temp_initial_layer": [ - "35" - ], "support_material_interface_fan_speed": [ "-1" ], diff --git a/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra S1 Max 0.4 nozzle.json b/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra S1 Max 0.4 nozzle.json index 64ec8774b0..16d4b54568 100644 --- a/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra S1 Max 0.4 nozzle.json +++ b/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra S1 Max 0.4 nozzle.json @@ -318,12 +318,6 @@ "slow_down_min_speed": [ "20" ], - "supertack_plate_temp": [ - "35" - ], - "supertack_plate_temp_initial_layer": [ - "35" - ], "support_material_interface_fan_speed": [ "-1" ], diff --git a/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra S1 Max 0.6 nozzle.json b/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra S1 Max 0.6 nozzle.json index ea95c2b1c5..ebc7e8e514 100644 --- a/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra S1 Max 0.6 nozzle.json +++ b/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra S1 Max 0.6 nozzle.json @@ -324,12 +324,6 @@ "slow_down_min_speed": [ "20" ], - "supertack_plate_temp": [ - "35" - ], - "supertack_plate_temp_initial_layer": [ - "35" - ], "support_material_interface_fan_speed": [ "-1" ], diff --git a/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra S1 Max 0.8 nozzle.json b/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra S1 Max 0.8 nozzle.json index a2cd9ecc5e..978c51007e 100644 --- a/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra S1 Max 0.8 nozzle.json +++ b/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra S1 Max 0.8 nozzle.json @@ -324,12 +324,6 @@ "slow_down_min_speed": [ "20" ], - "supertack_plate_temp": [ - "35" - ], - "supertack_plate_temp_initial_layer": [ - "35" - ], "support_material_interface_fan_speed": [ "-1" ], diff --git a/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra X 0.4 nozzle.json b/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra X 0.4 nozzle.json index b0c543cebf..e2c6e84c1b 100644 --- a/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra X 0.4 nozzle.json +++ b/resources/profiles/Anycubic/filament/Anycubic PETG @Anycubic Kobra X 0.4 nozzle.json @@ -309,12 +309,6 @@ "slow_down_min_speed": [ "20" ], - "supertack_plate_temp": [ - "35" - ], - "supertack_plate_temp_initial_layer": [ - "35" - ], "support_material_interface_fan_speed": [ "-1" ], diff --git a/resources/profiles/Anycubic/filament/Anycubic PETG-CF @Anycubic Kobra S1 Max 0.4 nozzle.json b/resources/profiles/Anycubic/filament/Anycubic PETG-CF @Anycubic Kobra S1 Max 0.4 nozzle.json index bbe7575b49..0774881a2b 100644 --- a/resources/profiles/Anycubic/filament/Anycubic PETG-CF @Anycubic Kobra S1 Max 0.4 nozzle.json +++ b/resources/profiles/Anycubic/filament/Anycubic PETG-CF @Anycubic Kobra S1 Max 0.4 nozzle.json @@ -318,12 +318,6 @@ "slow_down_min_speed": [ "20" ], - "supertack_plate_temp": [ - "35" - ], - "supertack_plate_temp_initial_layer": [ - "35" - ], "support_material_interface_fan_speed": [ "-1" ], diff --git a/resources/profiles/Anycubic/filament/Anycubic PETG-CF @Anycubic Kobra S1 Max 0.6 nozzle.json b/resources/profiles/Anycubic/filament/Anycubic PETG-CF @Anycubic Kobra S1 Max 0.6 nozzle.json index d78fcc1d27..e3a405e192 100644 --- a/resources/profiles/Anycubic/filament/Anycubic PETG-CF @Anycubic Kobra S1 Max 0.6 nozzle.json +++ b/resources/profiles/Anycubic/filament/Anycubic PETG-CF @Anycubic Kobra S1 Max 0.6 nozzle.json @@ -324,12 +324,6 @@ "slow_down_min_speed": [ "20" ], - "supertack_plate_temp": [ - "35" - ], - "supertack_plate_temp_initial_layer": [ - "35" - ], "support_material_interface_fan_speed": [ "-1" ], diff --git a/resources/profiles/Anycubic/filament/Anycubic PETG-CF @Anycubic Kobra S1 Max 0.8 nozzle.json b/resources/profiles/Anycubic/filament/Anycubic PETG-CF @Anycubic Kobra S1 Max 0.8 nozzle.json index cc3e8a15fc..e28e2d859e 100644 --- a/resources/profiles/Anycubic/filament/Anycubic PETG-CF @Anycubic Kobra S1 Max 0.8 nozzle.json +++ b/resources/profiles/Anycubic/filament/Anycubic PETG-CF @Anycubic Kobra S1 Max 0.8 nozzle.json @@ -324,12 +324,6 @@ "slow_down_min_speed": [ "10" ], - "supertack_plate_temp": [ - "35" - ], - "supertack_plate_temp_initial_layer": [ - "35" - ], "support_material_interface_fan_speed": [ "-1" ], diff --git a/resources/profiles/Anycubic/filament/Generic PETG @Anycubic Kobra S1 Max 0.4 nozzle.json b/resources/profiles/Anycubic/filament/Generic PETG @Anycubic Kobra S1 Max 0.4 nozzle.json index e025d0587c..b44b6ecc11 100644 --- a/resources/profiles/Anycubic/filament/Generic PETG @Anycubic Kobra S1 Max 0.4 nozzle.json +++ b/resources/profiles/Anycubic/filament/Generic PETG @Anycubic Kobra S1 Max 0.4 nozzle.json @@ -318,12 +318,6 @@ "slow_down_min_speed": [ "20" ], - "supertack_plate_temp": [ - "35" - ], - "supertack_plate_temp_initial_layer": [ - "35" - ], "support_material_interface_fan_speed": [ "-1" ], diff --git a/resources/profiles/Anycubic/filament/Generic PETG @Anycubic Kobra X 0.4 nozzle.json b/resources/profiles/Anycubic/filament/Generic PETG @Anycubic Kobra X 0.4 nozzle.json index 78925acad9..0f238e5a5b 100644 --- a/resources/profiles/Anycubic/filament/Generic PETG @Anycubic Kobra X 0.4 nozzle.json +++ b/resources/profiles/Anycubic/filament/Generic PETG @Anycubic Kobra X 0.4 nozzle.json @@ -309,12 +309,6 @@ "slow_down_min_speed": [ "20" ], - "supertack_plate_temp": [ - "35" - ], - "supertack_plate_temp_initial_layer": [ - "35" - ], "support_material_interface_fan_speed": [ "-1" ], diff --git a/resources/profiles/Anycubic/filament/fdm_filament_pet.json b/resources/profiles/Anycubic/filament/fdm_filament_pet.json index 62af7c89ed..56671ade0d 100644 --- a/resources/profiles/Anycubic/filament/fdm_filament_pet.json +++ b/resources/profiles/Anycubic/filament/fdm_filament_pet.json @@ -25,6 +25,12 @@ "hot_plate_temp_initial_layer": [ "80" ], + "supertack_plate_temp": [ + "60" + ], + "supertack_plate_temp_initial_layer": [ + "60" + ], "slow_down_for_layer_cooling": [ "1" ], diff --git a/resources/profiles/Flashforge.json b/resources/profiles/Flashforge.json index f3923c435a..f348a4f329 100644 --- a/resources/profiles/Flashforge.json +++ b/resources/profiles/Flashforge.json @@ -1,7 +1,7 @@ { "name": "Flashforge", "url": "", - "version": "02.04.00.05", + "version": "02.04.00.06", "force_update": "0", "description": "Flashforge configurations", "machine_model_list": [ @@ -439,6 +439,22 @@ "name": "0.14mm Standard @FF AD5X 0.25 nozzle", "sub_path": "process/0.14mm Standard @FF AD5X 0.25 nozzle.json" }, + { + "name": "0.08mm Standard @FF C5 0.25 nozzle", + "sub_path": "process/0.08mm Standard @FF C5 0.25 nozzle.json" + }, + { + "name": "0.10mm Standard @FF C5 0.25 nozzle", + "sub_path": "process/0.10mm Standard @FF C5 0.25 nozzle.json" + }, + { + "name": "0.12mm Standard @FF C5 0.25 nozzle", + "sub_path": "process/0.12mm Standard @FF C5 0.25 nozzle.json" + }, + { + "name": "0.14mm Standard @FF C5 0.25 nozzle", + "sub_path": "process/0.14mm Standard @FF C5 0.25 nozzle.json" + }, { "name": "0.14mm Standard @Flashforge AD5M 0.25 Nozzle", "sub_path": "process/0.14mm Standard @Flashforge AD5M 0.25 Nozzle.json" @@ -2903,6 +2919,10 @@ "name": "Flashforge AD5X 0.4 nozzle", "sub_path": "machine/Flashforge AD5X 0.4 nozzle.json" }, + { + "name": "Flashforge Creator 5 0.25 nozzle", + "sub_path": "machine/Flashforge Creator 5 0.25 nozzle.json" + }, { "name": "Flashforge Creator 5 0.4 nozzle", "sub_path": "machine/Flashforge Creator 5 0.4 nozzle.json" @@ -2915,6 +2935,10 @@ "name": "Flashforge Creator 5 0.8 nozzle", "sub_path": "machine/Flashforge Creator 5 0.8 nozzle.json" }, + { + "name": "Flashforge Creator 5 Pro 0.25 nozzle", + "sub_path": "machine/Flashforge Creator 5 Pro 0.25 nozzle.json" + }, { "name": "Flashforge Creator 5 Pro 0.4 nozzle", "sub_path": "machine/Flashforge Creator 5 Pro 0.4 nozzle.json" diff --git a/resources/profiles/Flashforge/filament/Flashforge HS PETG @FF G4 HF.json b/resources/profiles/Flashforge/filament/Flashforge HS PETG @FF G4 HF.json index 955235b442..3534f1a60b 100644 --- a/resources/profiles/Flashforge/filament/Flashforge HS PETG @FF G4 HF.json +++ b/resources/profiles/Flashforge/filament/Flashforge HS PETG @FF G4 HF.json @@ -87,6 +87,12 @@ "support_material_interface_fan_speed": [ "90" ], + "supertack_plate_temp": [ + "60" + ], + "supertack_plate_temp_initial_layer": [ + "60" + ], "temperature_vitrification": [ "70" ] diff --git a/resources/profiles/Flashforge/filament/Flashforge HS PETG @FF G4P HF.json b/resources/profiles/Flashforge/filament/Flashforge HS PETG @FF G4P HF.json index 82a8dc096a..263224a1ea 100644 --- a/resources/profiles/Flashforge/filament/Flashforge HS PETG @FF G4P HF.json +++ b/resources/profiles/Flashforge/filament/Flashforge HS PETG @FF G4P HF.json @@ -93,6 +93,12 @@ "support_material_interface_fan_speed": [ "90" ], + "supertack_plate_temp": [ + "60" + ], + "supertack_plate_temp_initial_layer": [ + "60" + ], "temperature_vitrification": [ "70" ], diff --git a/resources/profiles/Flashforge/filament/Flashforge PETG Pro @FF G4 HF.json b/resources/profiles/Flashforge/filament/Flashforge PETG Pro @FF G4 HF.json index d1c404cd19..8308064c52 100644 --- a/resources/profiles/Flashforge/filament/Flashforge PETG Pro @FF G4 HF.json +++ b/resources/profiles/Flashforge/filament/Flashforge PETG Pro @FF G4 HF.json @@ -90,6 +90,12 @@ "support_material_interface_fan_speed": [ "90" ], + "supertack_plate_temp": [ + "60" + ], + "supertack_plate_temp_initial_layer": [ + "60" + ], "temperature_vitrification": [ "70" ] diff --git a/resources/profiles/Flashforge/filament/Flashforge PETG Pro @FF G4P HF.json b/resources/profiles/Flashforge/filament/Flashforge PETG Pro @FF G4P HF.json index 1385ac8454..d8bdc53caa 100644 --- a/resources/profiles/Flashforge/filament/Flashforge PETG Pro @FF G4P HF.json +++ b/resources/profiles/Flashforge/filament/Flashforge PETG Pro @FF G4P HF.json @@ -96,6 +96,12 @@ "support_material_interface_fan_speed": [ "90" ], + "supertack_plate_temp": [ + "60" + ], + "supertack_plate_temp_initial_layer": [ + "60" + ], "temperature_vitrification": [ "70" ], diff --git a/resources/profiles/Flashforge/filament/Flashforge PETG Transparent @FF G4 HF.json b/resources/profiles/Flashforge/filament/Flashforge PETG Transparent @FF G4 HF.json index 2736fea042..f38a075dea 100644 --- a/resources/profiles/Flashforge/filament/Flashforge PETG Transparent @FF G4 HF.json +++ b/resources/profiles/Flashforge/filament/Flashforge PETG Transparent @FF G4 HF.json @@ -90,6 +90,12 @@ "support_material_interface_fan_speed": [ "90" ], + "supertack_plate_temp": [ + "60" + ], + "supertack_plate_temp_initial_layer": [ + "60" + ], "temperature_vitrification": [ "70" ] diff --git a/resources/profiles/Flashforge/filament/Flashforge PETG Transparent @FF G4P HF.json b/resources/profiles/Flashforge/filament/Flashforge PETG Transparent @FF G4P HF.json index bc6fbecdb4..c920f757e5 100644 --- a/resources/profiles/Flashforge/filament/Flashforge PETG Transparent @FF G4P HF.json +++ b/resources/profiles/Flashforge/filament/Flashforge PETG Transparent @FF G4P HF.json @@ -96,6 +96,12 @@ "support_material_interface_fan_speed": [ "90" ], + "supertack_plate_temp": [ + "60" + ], + "supertack_plate_temp_initial_layer": [ + "60" + ], "temperature_vitrification": [ "70" ], diff --git a/resources/profiles/Flashforge/filament/fdm_filament_pet.json b/resources/profiles/Flashforge/filament/fdm_filament_pet.json index 678ab77822..a19e1faece 100644 --- a/resources/profiles/Flashforge/filament/fdm_filament_pet.json +++ b/resources/profiles/Flashforge/filament/fdm_filament_pet.json @@ -28,6 +28,12 @@ "textured_plate_temp_initial_layer": [ "85" ], + "supertack_plate_temp": [ + "60" + ], + "supertack_plate_temp_initial_layer": [ + "60" + ], "slow_down_for_layer_cooling": [ "1" ], diff --git a/resources/profiles/Flashforge/machine/Flashforge Creator 5 0.25 nozzle.json b/resources/profiles/Flashforge/machine/Flashforge Creator 5 0.25 nozzle.json new file mode 100644 index 0000000000..54be573cf7 --- /dev/null +++ b/resources/profiles/Flashforge/machine/Flashforge Creator 5 0.25 nozzle.json @@ -0,0 +1,311 @@ +{ + "type": "machine", + "name": "Flashforge Creator 5 0.25 nozzle", + "inherits": "Flashforge Adventurer 5M Pro 0.4 Nozzle", + "from": "system", + "setting_id": "xp3cpTEGYdWjcrMM", + "instantiation": "true", + "adaptive_bed_mesh_margin": "0", + "auxiliary_fan": "1", + "bbl_use_printhost": "0", + "bed_custom_model": "", + "bed_custom_texture": "", + "bed_exclude_area": [ + "0x0" + ], + "bed_mesh_max": "99999,99999", + "bed_mesh_min": "-99999,-99999", + "bed_mesh_probe_distance": "50,50", + "before_layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]", + "best_object_pos": "0.5,0.5", + "change_extrusion_role_gcode": "", + "change_filament_gcode": "", + "cooling_tube_length": "0", + "cooling_tube_retraction": "0", + "default_bed_type": "", + "default_filament_profile": [ + "Flashforge Generic PLA" + ], + "default_print_profile": "0.12mm Standard @FF C5 0.25 nozzle", + "deretraction_speed": [ + "30", + "30", + "30", + "30" + ], + "disable_m73": "1", + "emit_machine_limits_to_gcode": "1", + "enable_filament_ramming": "0", + "enable_long_retraction_when_cut": "0", + "extra_loading_move": "0", + "extruder_clearance_height_to_lid": "360", + "extruder_clearance_height_to_rod": "60", + "extruder_clearance_radius": "92", + "extruder_colour": [ + "#FCE94F", + "#FCE94F", + "#FCE94F", + "#FCE94F" + ], + "extruder_offset": [ + "0x0", + "0x0", + "0x0", + "0x0" + ], + "fan_kickstart": "0", + "fan_speedup_overhangs": "1", + "fan_speedup_time": "0", + "gcode_flavor": "klipper", + "head_wrap_detect_zone": [], + "high_current_on_filament_swap": "0", + "host_type": "octoprint", + "layer_change_gcode": ";AFTER_LAYER_CHANGE\n;[layer_z]", + "long_retractions_when_cut": [ + "0", + "0", + "0", + "0" + ], + "machine_end_gcode": ";end_gcode\nG1 X150 Y150 E-1.2 F12000", + "machine_load_filament_time": "0", + "machine_max_acceleration_e": [ + "5000", + "5000" + ], + "machine_max_acceleration_extruding": [ + "30000", + "20000" + ], + "machine_max_acceleration_retracting": [ + "5000", + "5000" + ], + "machine_max_acceleration_travel": [ + "20000", + "20000" + ], + "machine_max_acceleration_x": [ + "30000", + "20000" + ], + "machine_max_acceleration_y": [ + "30000", + "20000" + ], + "machine_max_acceleration_z": [ + "300", + "500" + ], + "machine_max_jerk_e": [ + "2.5", + "2.5" + ], + "machine_max_jerk_x": [ + "9", + "9" + ], + "machine_max_jerk_y": [ + "9", + "9" + ], + "machine_max_jerk_z": [ + "3", + "3" + ], + "machine_max_junction_deviation": [ + "0", + "0" + ], + "machine_max_speed_e": [ + "30", + "30" + ], + "machine_max_speed_x": [ + "600", + "600" + ], + "machine_max_speed_y": [ + "600", + "600" + ], + "machine_max_speed_z": [ + "20", + "20" + ], + "machine_min_extruding_rate": [ + "0", + "0" + ], + "machine_min_travel_rate": [ + "0", + "0" + ], + "machine_pause_gcode": "M25", + "machine_start_gcode": ";start_gcode\nM140 S[bed_temperature_initial_layer_single]\nM106 P101 S0 ; L+R_PLA_Turbo_Fan_0-255\nM106 P2 S0 ; Center_Fresch_Air_Input_Fan_for_PLA30%_(80_0-255) \nM191 S0 ; Chamber temp. max65C\nM106 S0 ; Model_Fan_(Heat_Break_Cooler_80-255)100%=255\nM106 P3 S0 ; Filter_Unit_Fan_0-255(ABS=255)\nM104 S[nozzle_temperature_initial_layer]\nG90\nM83\nG1 Z5 F2400\nT[initial_extruder]\nM109 S[nozzle_temperature_initial_layer] T[initial_extruder]\nG1 X256 Y0 Z0.2 F6000\nG1 E5 F{filament_max_volumetric_speed[initial_no_support_extruder]/2.4053*60}\nG1 X216 E10 F{filament_max_volumetric_speed[initial_no_support_extruder]/2.4053*60}\n;start_gcode end", + "machine_tool_change_time": "7", + "machine_unload_filament_time": "0", + "manual_filament_change": "0", + "max_layer_height": [ + "0.14", + "0.14", + "0.14", + "0.14" + ], + "max_resonance_avoidance_speed": "120", + "min_layer_height": [ + "0.08", + "0.08", + "0.08", + "0.08" + ], + "min_resonance_avoidance_speed": "70", + "nozzle_diameter": [ + "0.25", + "0.25", + "0.25", + "0.25" + ], + "nozzle_height": "4", + "nozzle_hrc": "0", + "nozzle_type": "hardened_steel", + "nozzle_volume": "0", + "parking_pos_retraction": "0", + "pellet_modded_printer": "0", + "preferred_orientation": "0", + "printable_area": [ + "0x0", + "256x0", + "256x256", + "0x256" + ], + "printable_height": "256", + "printer_model": "Flashforge Creator 5", + "printer_notes": "", + "printer_settings_id": "Flashforge Creator 5 0.25 nozzle", + "printer_structure": "corexy", + "printer_technology": "FFF", + "printer_variant": "0.25", + "printhost_authorization_type": "key", + "printhost_ssl_ignore_revoke": "0", + "printing_by_object_gcode": "", + "purge_in_prime_tower": "0", + "resonance_avoidance": "0", + "retract_before_wipe": [ + "100%", + "100%", + "100%", + "100%" + ], + "retract_length_toolchange": [ + "3", + "3", + "3", + "3" + ], + "retract_lift_above": [ + "0", + "0", + "0", + "0" + ], + "retract_lift_below": [ + "0", + "0", + "0", + "0" + ], + "retract_lift_enforce": [ + "All Surfaces", + "All Surfaces", + "All Surfaces", + "All Surfaces" + ], + "retract_restart_extra": [ + "0", + "0", + "0", + "0" + ], + "retract_restart_extra_toolchange": [ + "-0.8", + "-0.8", + "-0.8", + "-0.8" + ], + "retract_when_changing_layer": [ + "1", + "1", + "1", + "1" + ], + "retraction_distances_when_cut": [ + "18", + "18", + "18", + "18" + ], + "retraction_length": [ + "0.8", + "0.8", + "0.8", + "0.8" + ], + "retraction_minimum_travel": [ + "2", + "2", + "2", + "2" + ], + "retraction_speed": [ + "30", + "30", + "30", + "30" + ], + "scan_first_layer": "0", + "silent_mode": "0", + "single_extruder_multi_material": "0", + "support_air_filtration": "0", + "support_chamber_temp_control": "0", + "support_multi_bed_types": "1", + "template_custom_gcode": "", + "thumbnails": "140x110/PNG", + "thumbnails_format": "PNG", + "time_cost": "0", + "time_lapse_gcode": "", + "travel_slope": [ + "3", + "3", + "3", + "3" + ], + "upward_compatible_machine": [], + "use_firmware_retraction": "0", + "use_relative_e_distances": "1", + "wipe": [ + "1", + "1", + "1", + "1" + ], + "wipe_distance": [ + "2", + "2", + "2", + "2" + ], + "z_hop": [ + "0.4", + "0.4", + "0.4", + "0.4" + ], + "z_hop_types": [ + "Auto Lift", + "Auto Lift", + "Auto Lift", + "Auto Lift" + ], + "z_offset": "0" +} diff --git a/resources/profiles/Flashforge/machine/Flashforge Creator 5 Pro 0.25 nozzle.json b/resources/profiles/Flashforge/machine/Flashforge Creator 5 Pro 0.25 nozzle.json new file mode 100644 index 0000000000..3eea1c58cf --- /dev/null +++ b/resources/profiles/Flashforge/machine/Flashforge Creator 5 Pro 0.25 nozzle.json @@ -0,0 +1,311 @@ +{ + "type": "machine", + "name": "Flashforge Creator 5 Pro 0.25 nozzle", + "inherits": "Flashforge Adventurer 5M Pro 0.4 Nozzle", + "from": "system", + "setting_id": "rpmib7bIKa85LNMR", + "instantiation": "true", + "adaptive_bed_mesh_margin": "0", + "auxiliary_fan": "1", + "bbl_use_printhost": "0", + "bed_custom_model": "", + "bed_custom_texture": "", + "bed_exclude_area": [ + "0x0" + ], + "bed_mesh_max": "99999,99999", + "bed_mesh_min": "-99999,-99999", + "bed_mesh_probe_distance": "50,50", + "before_layer_change_gcode": ";BEFORE_LAYER_CHANGE\n;[layer_z]", + "best_object_pos": "0.5,0.5", + "change_extrusion_role_gcode": "", + "change_filament_gcode": "", + "cooling_tube_length": "0", + "cooling_tube_retraction": "0", + "default_bed_type": "", + "default_filament_profile": [ + "Flashforge Generic PLA" + ], + "default_print_profile": "0.12mm Standard @FF C5 0.25 nozzle", + "deretraction_speed": [ + "30", + "30", + "30", + "30" + ], + "disable_m73": "1", + "emit_machine_limits_to_gcode": "1", + "enable_filament_ramming": "0", + "enable_long_retraction_when_cut": "0", + "extra_loading_move": "0", + "extruder_clearance_height_to_lid": "360", + "extruder_clearance_height_to_rod": "60", + "extruder_clearance_radius": "92", + "extruder_colour": [ + "#FCE94F", + "#FCE94F", + "#FCE94F", + "#FCE94F" + ], + "extruder_offset": [ + "0x0", + "0x0", + "0x0", + "0x0" + ], + "fan_kickstart": "0", + "fan_speedup_overhangs": "1", + "fan_speedup_time": "0", + "gcode_flavor": "klipper", + "head_wrap_detect_zone": [], + "high_current_on_filament_swap": "0", + "host_type": "octoprint", + "layer_change_gcode": ";AFTER_LAYER_CHANGE\n;[layer_z]", + "long_retractions_when_cut": [ + "0", + "0", + "0", + "0" + ], + "machine_end_gcode": ";end_gcode\nG1 X150 Y150 E-1.2 F12000", + "machine_load_filament_time": "0", + "machine_max_acceleration_e": [ + "5000", + "5000" + ], + "machine_max_acceleration_extruding": [ + "30000", + "20000" + ], + "machine_max_acceleration_retracting": [ + "5000", + "5000" + ], + "machine_max_acceleration_travel": [ + "20000", + "20000" + ], + "machine_max_acceleration_x": [ + "30000", + "20000" + ], + "machine_max_acceleration_y": [ + "30000", + "20000" + ], + "machine_max_acceleration_z": [ + "300", + "500" + ], + "machine_max_jerk_e": [ + "2.5", + "2.5" + ], + "machine_max_jerk_x": [ + "9", + "9" + ], + "machine_max_jerk_y": [ + "9", + "9" + ], + "machine_max_jerk_z": [ + "3", + "3" + ], + "machine_max_junction_deviation": [ + "0", + "0" + ], + "machine_max_speed_e": [ + "30", + "30" + ], + "machine_max_speed_x": [ + "600", + "600" + ], + "machine_max_speed_y": [ + "600", + "600" + ], + "machine_max_speed_z": [ + "20", + "20" + ], + "machine_min_extruding_rate": [ + "0", + "0" + ], + "machine_min_travel_rate": [ + "0", + "0" + ], + "machine_pause_gcode": "M25", + "machine_start_gcode": ";start_gcode\nM140 S[bed_temperature_initial_layer_single]\nM106 P101 S0 ; L+R_PLA_Turbo_Fan_0-255\nM106 P2 S0 ; Center_Fresch_Air_Input_Fan_for_PLA30%_(80_0-255) \nM191 S0 ; Chamber temp. max65C\nM106 S0 ; Model_Fan_(Heat_Break_Cooler_80-255)100%=255\nM106 P3 S0 ; Filter_Unit_Fan_0-255(ABS=255)\nM104 S[nozzle_temperature_initial_layer]\nG90\nM83\nG1 Z5 F2400\nT[initial_extruder]\nM109 S[nozzle_temperature_initial_layer] T[initial_extruder]\nG1 X256 Y0 Z0.2 F6000\nG1 E5 F{filament_max_volumetric_speed[initial_no_support_extruder]/2.4053*60}\nG1 X216 E10 F{filament_max_volumetric_speed[initial_no_support_extruder]/2.4053*60}\n;start_gcode end", + "machine_tool_change_time": "7", + "machine_unload_filament_time": "0", + "manual_filament_change": "0", + "max_layer_height": [ + "0.14", + "0.14", + "0.14", + "0.14" + ], + "max_resonance_avoidance_speed": "120", + "min_layer_height": [ + "0.08", + "0.08", + "0.08", + "0.08" + ], + "min_resonance_avoidance_speed": "70", + "nozzle_diameter": [ + "0.25", + "0.25", + "0.25", + "0.25" + ], + "nozzle_height": "4", + "nozzle_hrc": "0", + "nozzle_type": "hardened_steel", + "nozzle_volume": "0", + "parking_pos_retraction": "0", + "pellet_modded_printer": "0", + "preferred_orientation": "0", + "printable_area": [ + "0x0", + "256x0", + "256x256", + "0x256" + ], + "printable_height": "256", + "printer_model": "Flashforge Creator 5 Pro", + "printer_notes": "", + "printer_settings_id": "Flashforge Creator 5 Pro 0.25 nozzle", + "printer_structure": "corexy", + "printer_technology": "FFF", + "printer_variant": "0.25", + "printhost_authorization_type": "key", + "printhost_ssl_ignore_revoke": "0", + "printing_by_object_gcode": "", + "purge_in_prime_tower": "0", + "resonance_avoidance": "0", + "retract_before_wipe": [ + "100%", + "100%", + "100%", + "100%" + ], + "retract_length_toolchange": [ + "3", + "3", + "3", + "3" + ], + "retract_lift_above": [ + "0", + "0", + "0", + "0" + ], + "retract_lift_below": [ + "0", + "0", + "0", + "0" + ], + "retract_lift_enforce": [ + "All Surfaces", + "All Surfaces", + "All Surfaces", + "All Surfaces" + ], + "retract_restart_extra": [ + "0", + "0", + "0", + "0" + ], + "retract_restart_extra_toolchange": [ + "-0.8", + "-0.8", + "-0.8", + "-0.8" + ], + "retract_when_changing_layer": [ + "1", + "1", + "1", + "1" + ], + "retraction_distances_when_cut": [ + "18", + "18", + "18", + "18" + ], + "retraction_length": [ + "0.8", + "0.8", + "0.8", + "0.8" + ], + "retraction_minimum_travel": [ + "2", + "2", + "2", + "2" + ], + "retraction_speed": [ + "30", + "30", + "30", + "30" + ], + "scan_first_layer": "0", + "silent_mode": "0", + "single_extruder_multi_material": "0", + "support_air_filtration": "1", + "support_chamber_temp_control": "1", + "support_multi_bed_types": "1", + "template_custom_gcode": "", + "thumbnails": "140x110/PNG", + "thumbnails_format": "PNG", + "time_cost": "0", + "time_lapse_gcode": "", + "travel_slope": [ + "3", + "3", + "3", + "3" + ], + "upward_compatible_machine": [], + "use_firmware_retraction": "0", + "use_relative_e_distances": "1", + "wipe": [ + "1", + "1", + "1", + "1" + ], + "wipe_distance": [ + "2", + "2", + "2", + "2" + ], + "z_hop": [ + "0.4", + "0.4", + "0.4", + "0.4" + ], + "z_hop_types": [ + "Auto Lift", + "Auto Lift", + "Auto Lift", + "Auto Lift" + ], + "z_offset": "0" +} diff --git a/resources/profiles/Flashforge/machine/Flashforge Creator 5 Pro.json b/resources/profiles/Flashforge/machine/Flashforge Creator 5 Pro.json index bf8cbf3702..4ef02628d1 100644 --- a/resources/profiles/Flashforge/machine/Flashforge Creator 5 Pro.json +++ b/resources/profiles/Flashforge/machine/Flashforge Creator 5 Pro.json @@ -2,7 +2,7 @@ "type": "machine_model", "name": "Flashforge Creator 5 Pro", "model_id": "Flashforge-Creator-5-Pro", - "nozzle_diameter": "0.4;0.6;0.8", + "nozzle_diameter": "0.25;0.4;0.6;0.8", "machine_tech": "FFF", "family": "Flashforge", "bed_model": "flashforge_c5_buildplate_model.stl", diff --git a/resources/profiles/Flashforge/machine/Flashforge Creator 5.json b/resources/profiles/Flashforge/machine/Flashforge Creator 5.json index 8d5ac253b7..7ef9c172c0 100644 --- a/resources/profiles/Flashforge/machine/Flashforge Creator 5.json +++ b/resources/profiles/Flashforge/machine/Flashforge Creator 5.json @@ -2,7 +2,7 @@ "type": "machine_model", "name": "Flashforge Creator 5", "model_id": "Flashforge-Creator-5", - "nozzle_diameter": "0.4;0.6;0.8", + "nozzle_diameter": "0.25;0.4;0.6;0.8", "machine_tech": "FFF", "family": "Flashforge", "bed_model": "flashforge_c5_buildplate_model.stl", diff --git a/resources/profiles/Flashforge/process/0.08mm Standard @FF C5 0.25 nozzle.json b/resources/profiles/Flashforge/process/0.08mm Standard @FF C5 0.25 nozzle.json new file mode 100644 index 0000000000..09221f10b2 --- /dev/null +++ b/resources/profiles/Flashforge/process/0.08mm Standard @FF C5 0.25 nozzle.json @@ -0,0 +1,28 @@ +{ + "type": "process", + "name": "0.08mm Standard @FF C5 0.25 nozzle", + "inherits": "0.10mm Standard @FF AD5X 0.25 nozzle", + "from": "system", + "setting_id": "KrOSCGKyvNy9v08u", + "instantiation": "true", + "bottom_solid_infill_flow_ratio": "1", + "compatible_printers": [ + "Flashforge Creator 5 0.25 nozzle", + "Flashforge Creator 5 Pro 0.25 nozzle" + ], + "initial_layer_print_height": "0.1", + "internal_bridge_flow": "1", + "layer_height": "0.08", + "ooze_prevention": "1", + "preheat_time": "20", + "print_extruder_id": [ + "1" + ], + "print_extruder_variant": [ + "Direct Drive Standard" + ], + "print_settings_id": "0.08mm Standard @FF C5 0.25 nozzle", + "small_perimeter_speed": "30", + "standby_temperature_delta": "-100", + "top_solid_infill_flow_ratio": "1" +} diff --git a/resources/profiles/Flashforge/process/0.10mm Standard @FF C5 0.25 nozzle.json b/resources/profiles/Flashforge/process/0.10mm Standard @FF C5 0.25 nozzle.json new file mode 100644 index 0000000000..47ea8c6322 --- /dev/null +++ b/resources/profiles/Flashforge/process/0.10mm Standard @FF C5 0.25 nozzle.json @@ -0,0 +1,27 @@ +{ + "type": "process", + "name": "0.10mm Standard @FF C5 0.25 nozzle", + "inherits": "0.10mm Standard @FF AD5X 0.25 nozzle", + "from": "system", + "setting_id": "0Mep1gVTvD4RwK53", + "instantiation": "true", + "bottom_solid_infill_flow_ratio": "1", + "compatible_printers": [ + "Flashforge Creator 5 0.25 nozzle", + "Flashforge Creator 5 Pro 0.25 nozzle" + ], + "initial_layer_print_height": "0.1", + "internal_bridge_flow": "1", + "ooze_prevention": "1", + "preheat_time": "20", + "print_extruder_id": [ + "1" + ], + "print_extruder_variant": [ + "Direct Drive Standard" + ], + "print_settings_id": "0.10mm Standard @FF C5 0.25 nozzle", + "small_perimeter_speed": "30", + "standby_temperature_delta": "-100", + "top_solid_infill_flow_ratio": "1" +} diff --git a/resources/profiles/Flashforge/process/0.12mm Standard @FF C5 0.25 nozzle.json b/resources/profiles/Flashforge/process/0.12mm Standard @FF C5 0.25 nozzle.json new file mode 100644 index 0000000000..df33cdf344 --- /dev/null +++ b/resources/profiles/Flashforge/process/0.12mm Standard @FF C5 0.25 nozzle.json @@ -0,0 +1,28 @@ +{ + "type": "process", + "name": "0.12mm Standard @FF C5 0.25 nozzle", + "inherits": "0.10mm Standard @FF AD5X 0.25 nozzle", + "from": "system", + "setting_id": "rcKQBKZJvCfrfzqV", + "instantiation": "true", + "bottom_solid_infill_flow_ratio": "1", + "compatible_printers": [ + "Flashforge Creator 5 0.25 nozzle", + "Flashforge Creator 5 Pro 0.25 nozzle" + ], + "initial_layer_print_height": "0.1", + "internal_bridge_flow": "1", + "layer_height": "0.12", + "ooze_prevention": "1", + "preheat_time": "20", + "print_extruder_id": [ + "1" + ], + "print_extruder_variant": [ + "Direct Drive Standard" + ], + "print_settings_id": "0.12mm Standard @FF C5 0.25 nozzle", + "small_perimeter_speed": "30", + "standby_temperature_delta": "-100", + "top_solid_infill_flow_ratio": "1" +} diff --git a/resources/profiles/Flashforge/process/0.14mm Standard @FF C5 0.25 nozzle.json b/resources/profiles/Flashforge/process/0.14mm Standard @FF C5 0.25 nozzle.json new file mode 100644 index 0000000000..fd6f8f560d --- /dev/null +++ b/resources/profiles/Flashforge/process/0.14mm Standard @FF C5 0.25 nozzle.json @@ -0,0 +1,28 @@ +{ + "type": "process", + "name": "0.14mm Standard @FF C5 0.25 nozzle", + "inherits": "0.10mm Standard @FF AD5X 0.25 nozzle", + "from": "system", + "setting_id": "749mQY3CU2jk0MOd", + "instantiation": "true", + "bottom_solid_infill_flow_ratio": "1", + "compatible_printers": [ + "Flashforge Creator 5 0.25 nozzle", + "Flashforge Creator 5 Pro 0.25 nozzle" + ], + "initial_layer_print_height": "0.1", + "internal_bridge_flow": "1", + "layer_height": "0.14", + "ooze_prevention": "1", + "preheat_time": "20", + "print_extruder_id": [ + "1" + ], + "print_extruder_variant": [ + "Direct Drive Standard" + ], + "print_settings_id": "0.14mm Standard @FF C5 0.25 nozzle", + "small_perimeter_speed": "30", + "standby_temperature_delta": "-100", + "top_solid_infill_flow_ratio": "1" +} diff --git a/resources/profiles/Folgertech.json b/resources/profiles/Folgertech.json index 125cba3d46..a20dc8992e 100644 --- a/resources/profiles/Folgertech.json +++ b/resources/profiles/Folgertech.json @@ -1,6 +1,6 @@ { "name": "Folgertech", - "version": "02.04.00.01", + "version": "02.04.00.02", "force_update": "0", "description": "Folgertech configurations", "machine_model_list": [ diff --git a/resources/profiles/Folgertech/machine/Folgertech i3 0.6 nozzle.json b/resources/profiles/Folgertech/machine/Folgertech i3 0.6 nozzle.json index 40cf5fbf82..59fb023448 100644 --- a/resources/profiles/Folgertech/machine/Folgertech i3 0.6 nozzle.json +++ b/resources/profiles/Folgertech/machine/Folgertech i3 0.6 nozzle.json @@ -12,7 +12,7 @@ "printer_variant": "0.6", "printable_area": [ "0x0", - "20x0", + "200x0", "200x200", "0x200" ], diff --git a/resources/profiles/Qidi/filament/X5/Generic ABS @X-Plus 5.json b/resources/profiles/Qidi/filament/X5/Generic ABS @X-Plus 5.json index accf79987f..79a52578a4 100644 --- a/resources/profiles/Qidi/filament/X5/Generic ABS @X-Plus 5.json +++ b/resources/profiles/Qidi/filament/X5/Generic ABS @X-Plus 5.json @@ -8,6 +8,9 @@ "box_temperature_range_high": [ "45" ], + "chamber_temperature": [ + "55" + ], "close_fan_the_first_x_layers": [ "3" ], diff --git a/resources/profiles/Qidi/filament/X5/QIDI PA12-CF @X-Plus 5.json b/resources/profiles/Qidi/filament/X5/QIDI PA12-CF @X-Plus 5.json index 6abc420237..d4af23e160 100644 --- a/resources/profiles/Qidi/filament/X5/QIDI PA12-CF @X-Plus 5.json +++ b/resources/profiles/Qidi/filament/X5/QIDI PA12-CF @X-Plus 5.json @@ -14,9 +14,15 @@ "box_temperature": [ "55" ], + "chamber_temperature": [ + "55" + ], "close_fan_the_first_x_layers": [ "3" ], + "during_print_exhaust_fan_speed": [ + "0" + ], "cool_plate_temp_initial_layer": [ "60" ], diff --git a/resources/profiles/Qidi/filament/X5/QIDI PAHT-CF @X-Plus 5.json b/resources/profiles/Qidi/filament/X5/QIDI PAHT-CF @X-Plus 5.json index ca5591813d..fec939caa8 100644 --- a/resources/profiles/Qidi/filament/X5/QIDI PAHT-CF @X-Plus 5.json +++ b/resources/profiles/Qidi/filament/X5/QIDI PAHT-CF @X-Plus 5.json @@ -14,9 +14,15 @@ "box_temperature": [ "60" ], + "chamber_temperature": [ + "55" + ], "close_fan_the_first_x_layers": [ "3" ], + "during_print_exhaust_fan_speed": [ + "0" + ], "cool_plate_temp_initial_layer": [ "80" ], diff --git a/resources/profiles/Qidi/filament/X5/QIDI PAHT-GF @X-Plus 5.json b/resources/profiles/Qidi/filament/X5/QIDI PAHT-GF @X-Plus 5.json index 411ae26214..de72deb697 100644 --- a/resources/profiles/Qidi/filament/X5/QIDI PAHT-GF @X-Plus 5.json +++ b/resources/profiles/Qidi/filament/X5/QIDI PAHT-GF @X-Plus 5.json @@ -14,9 +14,15 @@ "box_temperature": [ "60" ], + "chamber_temperature": [ + "55" + ], "close_fan_the_first_x_layers": [ "3" ], + "during_print_exhaust_fan_speed": [ + "0" + ], "cool_plate_temp_initial_layer": [ "80" ], diff --git a/resources/profiles/Qidi/filament/X5/QIDI PPS-GF @X-Plus 5.json b/resources/profiles/Qidi/filament/X5/QIDI PPS-GF @X-Plus 5.json index 615d895851..f973121e62 100644 --- a/resources/profiles/Qidi/filament/X5/QIDI PPS-GF @X-Plus 5.json +++ b/resources/profiles/Qidi/filament/X5/QIDI PPS-GF @X-Plus 5.json @@ -14,8 +14,8 @@ "box_temperature": [ "65" ], - "chamber_temperatures": [ - "0" + "chamber_temperature": [ + "55" ], "close_fan_the_first_x_layers": [ "3" diff --git a/resources/profiles/Qidi/filament/X5/QIDI UltraPA-CF25 @X-Plus 5.json b/resources/profiles/Qidi/filament/X5/QIDI UltraPA-CF25 @X-Plus 5.json index 2ea27efa4b..82c261fbd5 100644 --- a/resources/profiles/Qidi/filament/X5/QIDI UltraPA-CF25 @X-Plus 5.json +++ b/resources/profiles/Qidi/filament/X5/QIDI UltraPA-CF25 @X-Plus 5.json @@ -14,6 +14,9 @@ "box_temperature": [ "60" ], + "chamber_temperature": [ + "55" + ], "close_fan_the_first_x_layers": [ "3" ], diff --git a/resources/web/guide/21/common.css b/resources/web/guide/21/common.css index e7cd57a32f..992c55d7a1 100644 --- a/resources/web/guide/21/common.css +++ b/resources/web/guide/21/common.css @@ -415,7 +415,9 @@ img.ModelThumbnail { border-width: 1px; border-style: solid; border-radius: 4px; - background-color: inherit; + border-color: var(--border-color); + background-color: var(--bg-color); + color: var(--fg-color-text); position: absolute; left: 50%; top: 200px; @@ -423,13 +425,17 @@ img.ModelThumbnail { } #NoticeBar { - background-color:#00f0d8; + background-color: var(--main-color); height: 40px; line-height: 40px; color: #fff; text-align: center; } +#NoticeBar.notice-error { + background-color: var(--button-bg-alert); +} + #NoticeContent { padding: 4mm 10mm; } diff --git a/resources/web/guide/21/common.js b/resources/web/guide/21/common.js index f7e8b1d409..9a3c76c248 100644 --- a/resources/web/guide/21/common.js +++ b/resources/web/guide/21/common.js @@ -37,6 +37,34 @@ function HandleStudio( pVal ) { HandleModelList(pVal['response']); } + else if(strCmd=='check_new_printers_result') + { + let button = document.getElementById("CheckNewPrintersBtn"); + if (button) { + button.style.pointerEvents = "auto"; + button.style.opacity = "1"; + } + + let noticeBar = document.getElementById("NoticeBar"); + let noticeText = document.getElementById("NoticeText"); + let hasError = pVal.hasOwnProperty("error"); + noticeBar.classList.toggle("notice-error", hasError); + if (hasError) { + noticeBar.textContent = "Error"; + noticeText.textContent = pVal["error"]; + } else if (pVal["vendors"] && pVal["vendors"].length > 0) { + noticeBar.textContent = "New printers found"; + noticeText.textContent = "New printer vendors installed: " + pVal["vendors"].join(", "); + } else if (pVal["declined"]) { + noticeBar.textContent = "Information"; + noticeText.textContent = "New printer vendors were found, but installation was cancelled."; + } else { + noticeBar.textContent = "Information"; + noticeText.textContent = "No new printers found."; + } + + ShowNotice(1); + } } function HandleModelList( pVal ) @@ -78,9 +106,13 @@ function HandleModelList( pVal ) } //Update Nozzel Html Append + // ORCA: HandleModelList can now be called more than once per dialog (e.g. after a new vendor + // is installed via "check for new printers"). pModel always holds the full, current list, so + // clear each vendor's printer area before repopulating instead of appending on top of a + // previous render. for( let key in ModelHtml ) { - $(".OneVendorBlock[vendor='"+key+"'] .PrinterArea").append( ModelHtml[key] ); + $(".OneVendorBlock[vendor='"+key+"'] .PrinterArea").empty().append( ModelHtml[key] ); } //Update Checkbox @@ -343,6 +375,9 @@ function OnExit() let nTotal=ModelSelect.length; if( nTotal==0 ) { + let noticeBar = document.getElementById("NoticeBar"); + noticeBar.classList.add("notice-error"); + noticeBar.textContent = "Error"; ShowNotice(1); return 0; } diff --git a/resources/web/guide/24/24.js b/resources/web/guide/24/24.js index 74a03db7b9..19ccb892c6 100644 --- a/resources/web/guide/24/24.js +++ b/resources/web/guide/24/24.js @@ -36,6 +36,22 @@ function ConfirmSelect() } } +function CheckForNewPrinters() +{ + var tSend={}; + tSend['sequence_id']=Math.round(new Date() / 1000); + tSend['command']="check_for_new_printers"; + tSend['data']={}; + + var button = document.getElementById("CheckNewPrintersBtn"); + if (button) { + button.style.pointerEvents = "none"; + button.style.opacity = "0.6"; + } + + SendWXMessage( JSON.stringify(tSend) ); +} + function CreateNewPrinter() { var tSend={}; diff --git a/resources/web/guide/24/index.html b/resources/web/guide/24/index.html index efb6148e0d..5f0a2407cc 100644 --- a/resources/web/guide/24/index.html +++ b/resources/web/guide/24/index.html @@ -82,7 +82,8 @@

Create
-
Confirm
+
Check for new printers
+
Confirm
Cancel
diff --git a/resources/web/homepage/css/home.css b/resources/web/homepage/css/home.css index 2cf5742747..950e330b86 100644 --- a/resources/web/homepage/css/home.css +++ b/resources/web/homepage/css/home.css @@ -716,7 +716,8 @@ body background-color: #E4E4E4; border-radius: 8px; width: 184px; - height: 184px; + height: 184px; + position: relative; } .FileItem img @@ -728,6 +729,19 @@ body object-fit: cover; } +.FileImg .FileLogoBadge +{ + position: absolute; + top: 8px; + left: 8px; + width: 26px; + height: 26px; + padding: 2px; + box-sizing: border-box; + background-color: rgba(255,255,255,0.85); + border-radius: 50%; +} + .FileName { white-space: nowrap; @@ -736,6 +750,35 @@ body word-break: break-all; } +.FileNamePack +{ + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; +} + +.FileNamePack .FileName +{ + flex: 1 1 auto; + min-width: 0; +} + +.FilePublishedBadge +{ + flex: 0 0 auto; + margin-right: 4px; + padding: 0 4px; + height: 16px; + line-height: 16px; + font-size: 10px; + font-weight: 600; + color: #FFFFFF; + background-color: #009688; + border-radius: 2px; + white-space: nowrap; +} + .FileDate { color: #A8A8A8; diff --git a/resources/web/homepage/js/home.js b/resources/web/homepage/js/home.js index f07c6f10f5..aee0d8cd37 100644 --- a/resources/web/homepage/js/home.js +++ b/resources/web/homepage/js/home.js @@ -305,15 +305,19 @@ function ShowRecentFileList( pList ) let sImg=OneFile["image"] || sImages[sPath]; let sTime=OneFile['time']; let sName=OneFile['project_name']; + let sPublished=OneFile['published'] == '1'; sImages[sPath] = sImg; //let index=sPath.lastIndexOf('\\')>0?sPath.lastIndexOf('\\'):sPath.lastIndexOf('\/'); //let sShortName=sPath.substring(index+1,sPath.length); + let sBadge=sPublished? 'PUB':''; + let sLogoBadge=sPublished? '':''; + let TmpHtml='
'+ ''+ - '
No Image
'+ - '
'+sName+'
'+ + '
No Image'+sLogoBadge+'
'+ + '
'+sBadge+'
'+sName+'
'+ '
'+sTime+'
'+ '
'; diff --git a/scripts/test_build_win.ps1 b/scripts/test_build_win.ps1 index a8abfbc7bf..65dec8ffa1 100644 --- a/scripts/test_build_win.ps1 +++ b/scripts/test_build_win.ps1 @@ -22,6 +22,7 @@ Match regexes; each must match at least one output line NotMatch regexes; none may match any output line NotExists paths that must not exist after the case runs + DateStampedZip require a bundle date from during this case's invocation .PARAMETER Name Run only the cases whose name matches this regex. Headings with no @@ -109,12 +110,6 @@ $slnDir = Join-Path $fixtures 'sln' New-Item -ItemType Directory -Force -Path $slnDir | Out-Null Set-Content -Path (Join-Path $slnDir 'OrcaSlicer.sln') -Value '' -Encoding ascii -# The pack stamp is checked against real dates, so a locale-dependent parse -# in the script cannot pass by looking date-shaped. Yesterday is accepted too, -# so a run that crosses midnight does not flake. -$dateStamps = @((Get-Date -Format 'yyyyMMdd'), (Get-Date).AddDays(-1).ToString('yyyyMMdd')) -$stampPattern = '_(' + ($dateStamps -join '|') + ')\.zip$' - $cases = @( 'argument handling' @{ Name = 'no arguments prints help'; Args = @(); DryRun = $false @@ -326,12 +321,12 @@ $cases = @( Contains = @('OrcaSlicer_dep_win-x64_') NotContains = @('-clang', '-Release') } @{ Name = 'the bundle is stamped with today, not a shuffled date'; Args = @('-p') - Match = @($stampPattern) } + DateStampedZip = $true } # powershell.exe is not in System32 itself, so a trimmed PATH used to # leave the stamp empty and the bundle named OrcaSlicer_dep_win-x64_.zip. @{ Name = 'the bundle is stamped even with a bare PATH'; Args = @('-p') Env = @{ PATH = 'C:\Windows\system32;C:\Windows' } - Match = @($stampPattern) } + DateStampedZip = $true } @{ Name = '-p packs without rebuilding'; Args = @('-p') Match = @('^\+ .*(7z\.exe a|tar\.exe -a -c -f) ') NotContains = @('cmake -S deps') } @@ -861,7 +856,7 @@ function Invoke-BuildScript { $knownFields = @( 'Name', 'Args', 'ExpectExit', 'DryRun', 'First', 'Env', - 'Contains', 'NotContains', 'Match', 'NotMatch', 'NotExists' + 'Contains', 'NotContains', 'Match', 'NotMatch', 'NotExists', 'DateStampedZip' ) function Test-Case { @@ -876,7 +871,9 @@ function Test-Case { $expect = 0 if ($Case.ContainsKey('ExpectExit')) { $expect = $Case['ExpectExit'] } + $started = Get-Date $result = Invoke-BuildScript -Arguments $argv -Environment $Case['Env'] + $finished = Get-Date $problems = @() @@ -902,6 +899,15 @@ function Test-Case { $problems += "no line matching /$pattern/" } } + if ($Case['DateStampedZip']) { + # Bound the accepted dates to this invocation so crossing midnight is + # valid without allowing an unrelated past or future date. + $dateStamps = @($started.ToString('yyyyMMdd'), $finished.ToString('yyyyMMdd')) | Select-Object -Unique + $pattern = '_(' + ($dateStamps -join '|') + ')\.zip$' + if (@($lines | Where-Object { $_ -match $pattern }).Count -eq 0) { + $problems += "no line matching /$pattern/" + } + } foreach ($pattern in $Case['NotMatch']) { foreach ($line in @($lines | Where-Object { $_ -match $pattern })) { $problems += "line matches /$pattern/: $line" diff --git a/src/OrcaSlicer.cpp b/src/OrcaSlicer.cpp index 0a770e0d8a..d3e24437fb 100644 --- a/src/OrcaSlicer.cpp +++ b/src/OrcaSlicer.cpp @@ -53,6 +53,7 @@ using namespace nlohmann; #include "libslic3r/libslic3r.h" #include "libslic3r/Config.hpp" +#include "libslic3r/Preset.hpp" #include "libslic3r/Geometry.hpp" #include "libslic3r/GCode.hpp" #include "libslic3r/Model.hpp" @@ -77,8 +78,9 @@ using namespace nlohmann; #include "libslic3r/ObjColorUtils.hpp" #include "OrcaSlicer.hpp" -//BBS: add exception handler for win32 +#include #include +//BBS: add exception handler for win32 #ifdef WIN32 #include "dev-utils/BaseException.h" #endif @@ -1466,6 +1468,10 @@ int CLI::run(int argc, char **argv) std::vector upward_compatible_printers, new_print_compatible_printers, current_print_compatible_printers, current_different_settings; std::vector current_filaments_name, current_filaments_system_name, current_inherits_group, current_extruder_variants, new_extruder_variants, current_print_extruder_variants, new_printer_extruder_variants; DynamicPrintConfig load_process_config, load_machine_config; + //ORCA: full configs of the "current" (3MF-embedded) process/printer presets, kept so that + // compatible_printers_condition can be evaluated for them below. Previously only the + // literal compatible_printers list was extracted. + DynamicPrintConfig current_process_full_config, current_printer_full_config; bool new_process_config_is_system = true, new_printer_config_is_system = true; std::string pipe_name, makerlab_name, makerlab_version, different_process_setting; const std::vector &metadata_name = m_config.option("metadata_name", true)->values; @@ -1925,7 +1931,7 @@ int CLI::run(int argc, char **argv) } } catch (std::exception& e) { - boost::nowide::cerr << construct_assemble_list << ": " << e.what() << std::endl; + boost::nowide::cerr << "construct_assemble_list: " << e.what() << std::endl; record_exit_reson(outfile_dir, CLI_DATA_FILE_ERROR, 0, cli_errors[CLI_DATA_FILE_ERROR], sliced_info); flush_and_exit(CLI_DATA_FILE_ERROR); } @@ -1974,7 +1980,124 @@ int CLI::run(int argc, char **argv) } } - auto load_config_file = [](const std::string& file, DynamicPrintConfig& config, std::string& config_type, + std::unique_ptr cli_preset_bundle; + auto ensure_cli_preset_bundle = [&cli_preset_bundle](std::string &error) -> PresetBundle * { + if (cli_preset_bundle) + return cli_preset_bundle.get(); + try { + AppConfig app_config; + const std::string app_config_error = app_config.load_if_exists(); + if (!app_config_error.empty()) { + BOOST_LOG_TRIVIAL(warning) << "Ignoring invalid app config during CLI preset resolution: " << app_config_error; + app_config.reset(); + } + + auto bundle = std::make_unique(); + std::string load_error; + bundle->load_presets(app_config, config_substitution_rule, + PresetBundle::PresetPreferences(), &load_error, true); + if (!load_error.empty()) { + error = "Failed to load presets for inheritance resolution: " + load_error; + return nullptr; + } + cli_preset_bundle = std::move(bundle); + return cli_preset_bundle.get(); + } catch (const std::exception &ex) { + error = ex.what(); + return nullptr; + } + }; + + auto resolve_preset = [&ensure_cli_preset_bundle](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(); + allow_source_manifest = true; + } else { + bundle = ensure_cli_preset_bundle(error); + if (bundle == nullptr) + return false; + } + + if (probe_type) { + Preset::Type preset_type; + if (!bundle->resolve_preset_config_type(config, preset_type, file, config_substitution_rule, + error, allow_source_manifest)) + return false; + config_type = Preset::get_type_string(preset_type); + return true; + } + + Preset::Type preset_type; + if (config_type == "process") + preset_type = Preset::TYPE_PRINT; + else if (config_type == "filament") + preset_type = Preset::TYPE_FILAMENT; + else if (config_type == "machine") + preset_type = Preset::TYPE_PRINTER; + else { + error = "Unsupported preset type: " + config_type; + return false; + } + return bundle->resolve_preset_config(config, preset_type, file, config_substitution_rule, + error, allow_source_manifest); + }; + + //ORCA: list the keys a user preset overrides relative to its system parent, for the + // `different_settings_to_system` column of an exported 3MF. Without it the CLI + // writes an empty column, so re-opening a CLI-exported project in the GUI shows + // spurious "unsaved changes" and can revert inherited process/filament/machine + // values to system defaults. + // + // The parent comes from the preset bundle that inherits resolution already builds, + // so this adds no extra loading. Returns "" whenever the parent cannot be resolved, + // which is exactly the previous behaviour. + auto cli_different_settings = [&ensure_cli_preset_bundle](const DynamicPrintConfig &resolved, + const std::string &parent_name, + Preset::Type type) -> std::string { + if (parent_name.empty()) + return std::string(); + std::string error; + PresetBundle *bundle = ensure_cli_preset_bundle(error); + if (bundle == nullptr) { + BOOST_LOG_TRIVIAL(warning) << "CLI: no preset bundle for different_settings_to_system: " << error; + return std::string(); + } + const PresetCollection *collection = nullptr; + switch (type) { + case Preset::TYPE_PRINT: collection = &bundle->prints; break; + case Preset::TYPE_FILAMENT: collection = &bundle->filaments; break; + case Preset::TYPE_PRINTER: collection = &bundle->printers; break; + default: return std::string(); + } + const Preset *parent = collection->find_preset2(parent_name, true); + if (parent == nullptr) { + BOOST_LOG_TRIVIAL(warning) << boost::format("CLI: parent preset '%1%' not found; leaving different_settings_to_system empty")%parent_name; + return std::string(); + } + std::vector keys = resolved.diff(parent->config); + //ORCA: preset metadata, not user-tunable settings. compatible_printers / + // compatible_prints have their own tracking columns and would double-count. + keys.erase(std::remove_if(keys.begin(), keys.end(), [](const std::string &k) { + return k == "inherits" || k == "compatible_printers" || k == "compatible_prints" + || k == "compatible_printers_condition" || k == "compatible_prints_condition" + || k == "print_settings_id" || k == "filament_settings_id" || k == "printer_settings_id"; + }), + keys.end()); + BOOST_LOG_TRIVIAL(info) << boost::format("CLI: %1% overrides vs parent '%2%'")%keys.size()%parent_name; + return Slic3r::escape_strings_cstyle(keys); + }; + + auto load_config_file = [&resolve_preset](const std::string& file, DynamicPrintConfig& config, std::string& config_type, std::string& config_name, std::string& filament_id, std::string& config_from) { if (! boost::filesystem::exists(file)) { boost::nowide::cerr << __FUNCTION__<< ": can not find setting file: " << file << std::endl; @@ -2003,9 +2126,15 @@ int CLI::run(int argc, char **argv) } auto type_iter = key_values.find(BBL_JSON_KEY_TYPE); - if (type_iter != key_values.end()) { + const bool probe_type = type_iter == key_values.end(); + if (!probe_type) config_type = type_iter->second; + + if (!resolve_preset(file, config, config_type, config_from, probe_type, reason)) { + boost::nowide::cerr << __FUNCTION__ << boost::format(": can not resolve preset %1%: %2%") % file % reason << std::endl; + return CLI_CONFIG_FILE_ERROR; } + if (config_type == "machine") { //config.set("printer_settings_id", config_name, true); //printer_inherits = config.option("inherits", true)->value; @@ -2557,6 +2686,8 @@ int CLI::run(int argc, char **argv) flush_and_exit(ret); } upward_compatible_printers = config.option("upward_compatible_machine", true)->values; + //ORCA: keep the full config so compatible_printers_condition can be evaluated against it below + current_printer_full_config = std::move(config); } } } @@ -2579,6 +2710,8 @@ int CLI::run(int argc, char **argv) flush_and_exit(ret); } current_print_compatible_printers = config.option("compatible_printers", true)->values; + //ORCA: keep the full config so compatible_printers_condition can be evaluated against it below + current_process_full_config = std::move(config); } } } @@ -2597,46 +2730,88 @@ int CLI::run(int argc, char **argv) for (int index = 0; index < upward_compatible_printers.size(); index++) { BOOST_LOG_TRIVIAL(info) << boost::format("index %1%, upward_compatible_printers %2%")%index %upward_compatible_printers[index]; } + //ORCA: Replace the four manual equality-loop checks below with is_compatible_with_printer(), the + // same helper the GUI uses, which also evaluates compatible_printers_condition. Process + // profiles that declare compatibility via condition only -- leaving compatible_printers + // empty -- were always reported incompatible by the literal-name match, so a CLI slice with + // such a preset exited with CLI_PROCESS_NOT_COMPATIBLE (-17) even though the GUI accepts the + // same pair. Behaviour is unchanged where an explicit list exists: is_compatible_with_printer + // does the same name match, and returns true when both list and condition are empty (which + // matches the "old 3mf, no compatible printers" path below). + auto check_compat = [](const DynamicPrintConfig &process_cfg, + const DynamicPrintConfig &printer_cfg, + const std::string &printer_name) -> bool { + return is_compatible_with_printer(process_cfg, Preset::TYPE_PRINT, printer_cfg, printer_name); + }; + + //ORCA: a 3MF's project config does not carry compatible_printers / compatible_printers_condition. + // PresetBundle::construct_full_config() erases both and re-emits them as + // print_compatible_printers and compatible_machine_expression_group; they are renamed back + // only on the PresetBundle load path, which the CLI does not take. Feeding the project config + // to the check as-is therefore presents no list and no condition, and + // is_compatible_with_printer() reads that as "no constraint" and accepts every printer. + // Translate the two keys back. Index 0 of the expression group is the print preset -- the + // group is filled print, filaments, printer (PresetBundle.cpp). + // The raw keys win whenever they carry something. A project the CLI exported itself has the + // real compatible_printers_condition AND an all-empty compatible_machine_expression_group, + // so copying the group's first entry unconditionally would overwrite a valid condition with + // "" and accept every printer. The renamed keys are only a fallback, and an empty value is + // never written over a real one. + auto cli_process_compat_config = [](const DynamicPrintConfig &project_cfg) -> DynamicPrintConfig { + DynamicPrintConfig cfg = project_cfg; + const auto *raw_list = project_cfg.option("compatible_printers"); + const auto *list = project_cfg.option("print_compatible_printers"); + if ((raw_list == nullptr || raw_list->values.empty()) && list != nullptr && !list->values.empty()) + cfg.set_key_value("compatible_printers", new ConfigOptionStrings(list->values)); + const auto *raw_cond = project_cfg.option("compatible_printers_condition"); + const auto *group = project_cfg.option("compatible_machine_expression_group"); + if ((raw_cond == nullptr || raw_cond->value.empty()) && group != nullptr && !group->values.empty() && + !group->values.front().empty()) + cfg.set_key_value("compatible_printers_condition", new ConfigOptionString(group->values.front())); + return cfg; + }; if (!new_printer_name.empty()) { if (!new_process_name.empty()) { - for (int index = 0; index < new_print_compatible_printers.size(); index++) { - if (new_print_compatible_printers[index] == new_printer_system_name) { - process_compatible = true; - break; - } - } + //new process + new printer: both configs came from --load-settings + process_compatible = check_compat(load_process_config, load_machine_config, new_printer_system_name); BOOST_LOG_TRIVIAL(info) << boost::format("new printer %1%, inherited from %2%, new process %3%, inherited from %4% ,compatible %5%") %new_printer_name %new_printer_system_name %new_process_name %new_process_system_name %process_compatible; } else { - for (int index = 0; index < current_print_compatible_printers.size(); index++) { - if (current_print_compatible_printers[index] == new_printer_system_name) { - process_compatible = true; - break; - } + //3MF-embedded process vs new printer. current_process_full_config is only populated from + //profiles/BBL/process_full/, so for every other vendor fall back to the 3MF's own project + //config in m_print_config, with its renamed compatibility keys translated back (see + //cli_process_compat_config above). Without this a 3MF built from a condition-only process + //is rejected when re-sliced with the very printer it was made for. + { + //ORCA: profiles/BBL/{process,machine}_full/ are gitignored and not generated in-tree, + // so current_*_full_config is always empty and this fallback is the only live path. + const DynamicPrintConfig process_cfg = current_process_full_config.empty() + ? cli_process_compat_config(m_print_config) + : current_process_full_config; + process_compatible = check_compat(process_cfg, load_machine_config, new_printer_system_name); } BOOST_LOG_TRIVIAL(info) << boost::format("new printer %1%, inherited from %2%, old process %3%, inherited from %4% ,compatible %5%") %new_printer_name %new_printer_system_name %current_process_name %current_process_system_name %process_compatible; } } else if (!new_process_name.empty()) { - for (int index = 0; index < new_print_compatible_printers.size(); index++) { - if (new_print_compatible_printers[index] == current_printer_system_name) { - process_compatible = true; - break; - } + //new process vs 3MF-embedded printer. As above, current_printer_full_config only resolves for + //BBL profiles; otherwise evaluate against the 3MF's own project config in m_print_config, which + //holds the embedded printer's printer_notes / nozzle_diameter. + { + const DynamicPrintConfig &printer_cfg = current_printer_full_config.empty() ? m_print_config : current_printer_full_config; + process_compatible = check_compat(load_process_config, printer_cfg, current_printer_system_name); } BOOST_LOG_TRIVIAL(info) << boost::format("old printer %1%, inherited from %2%, new process %3%, inherited from %4% ,compatible %5%") %current_printer_name %current_printer_system_name %new_process_name %new_process_system_name %process_compatible; } else { - //check the compatible of old printer&&process - for (int index = 0; index < current_print_compatible_printers.size(); index++) { - if (current_print_compatible_printers[index] == current_printer_system_name) { - process_compatible = true; - break; - } - } + //both sides 3MF-embedded (pure reprocess) + if (!current_process_full_config.empty() && !current_printer_full_config.empty()) + process_compatible = check_compat(current_process_full_config, current_printer_full_config, current_printer_system_name); + else + process_compatible = std::find(current_print_compatible_printers.begin(), current_print_compatible_printers.end(), current_printer_system_name) != current_print_compatible_printers.end(); if (!process_compatible && current_print_compatible_printers.empty()) { BOOST_LOG_TRIVIAL(info) << boost::format("old 3mf, no compatible printers, set to compatible"); @@ -2859,8 +3034,10 @@ int CLI::run(int argc, char **argv) } } else { - //todo: support user machine preset's different settings - different_settings[filament_count+1] = ""; + //ORCA: was a //todo — compute the user's overrides instead of writing an empty column. + different_settings[filament_count+1] = new_printer_config_is_system + ? std::string() + : cli_different_settings(load_machine_config, new_printer_system_name, Preset::TYPE_PRINTER); if (new_printer_config_is_system) inherits_group[filament_count+1] = ""; else @@ -3002,8 +3179,14 @@ int CLI::run(int argc, char **argv) print_compatible_printers = std::move(current_print_compatible_printers); } else { - //todo: support system process preset - different_settings[0] = ""; + //ORCA: was a //todo. Prefer a value the loaded JSON already carried, otherwise + // compute the overrides against the system parent. + if (!different_process_setting.empty()) + different_settings[0] = different_process_setting; + else + different_settings[0] = new_process_config_is_system + ? std::string() + : cli_different_settings(load_process_config, new_process_system_name, Preset::TYPE_PRINT); if (new_process_config_is_system) inherits_group[0] = ""; else @@ -3190,6 +3373,16 @@ int CLI::run(int argc, char **argv) int filament_index = load_filaments_index[index]; std::vector different_keys; + //ORCA: diff before load_default_gcodes_to_config, the way the process and machine + // slots above already do. That call materialises absent gcode keys via + // option(..., true), and DynamicConfig::diff only compares keys present in + // both configs -- so a gcode key the leaf did not carry would go from "not + // compared" to "compared as empty against the parent" and land in the column + // as an override the user never made. + std::string filament_different_settings; + if (load_filament_count > 0) + filament_different_settings = cli_different_settings(config, load_filaments_inherit[index], Preset::TYPE_FILAMENT); + load_default_gcodes_to_config(config, Preset::TYPE_FILAMENT); if (load_filament_count > 0) { @@ -3201,8 +3394,8 @@ int CLI::run(int argc, char **argv) opt_filament_settings->set_at(filament_name_setting, filament_index-1, 0); config.erase("filament_settings_id"); - //todo: update different settings of filaments - different_settings[filament_index] = ""; + //ORCA: was a //todo — same treatment as process/machine above. + different_settings[filament_index] = filament_different_settings; inherits_group[filament_index] = load_filaments_inherit[index]; } else { @@ -3937,7 +4130,7 @@ int CLI::run(int argc, char **argv) } }; - auto check_plate_wipe_tower = [get_print_sequence, is_smooth_timelapse, new_extruder_count](Slic3r::GUI::PartPlate* plate, int plate_index, DynamicPrintConfig& print_config, plate_obj_size_info_t &plate_obj_size_info) { + auto check_plate_wipe_tower = [get_print_sequence, is_smooth_timelapse](Slic3r::GUI::PartPlate* plate, int plate_index, DynamicPrintConfig& print_config, plate_obj_size_info_t &plate_obj_size_info) { plate_obj_size_info.obj_bbox= plate->get_objects_bounding_box(); BOOST_LOG_TRIVIAL(info) << boost::format("plate %1%, object bbox: min {%2%, %3%, %4%} - max {%5%, %6%, %7%}") %(plate_index+1) %plate_obj_size_info.obj_bbox.min.x() % plate_obj_size_info.obj_bbox.min.y() % plate_obj_size_info.obj_bbox.min.z() %plate_obj_size_info.obj_bbox.max.x() % plate_obj_size_info.obj_bbox.max.y() % plate_obj_size_info.obj_bbox.max.z(); @@ -3981,22 +4174,13 @@ int CLI::run(int argc, char **argv) plate_obj_size_info.wipe_x = wipe_x_option->get_at(plate_index); plate_obj_size_info.wipe_y = wipe_y_option->get_at(plate_index); - ConfigOptionFloat* width_option = print_config.option("prime_tower_width", true); - plate_obj_size_info.wipe_width = width_option->value; + // Body and brim from one estimate: resolving an auto (-1) brim against a different + // height would size the two halves of the same tower from two different objects. + const WipeTowerFootprint footprint = plate->estimate_wipe_tower_footprint(print_config, filaments_cnt); + float brim_width = float(footprint.brim_width); - ConfigOptionFloat* brim_width_option = print_config.option("prime_tower_brim_width", true); - float brim_width = brim_width_option->value; - if (brim_width < 0) brim_width = WipeTower::get_auto_brim_by_height((float)plate_obj_size_info.obj_bbox.max.z()); - - ConfigOptionFloat* volume_option = print_config.option("prime_volume", true); - float wipe_volume = volume_option->value; - - const ConfigOptionBool * wrapping_detection = print_config.option("enable_wrapping_detection"); - bool enable_wrapping = (wrapping_detection != nullptr) && wrapping_detection->value; - - Vec3d wipe_tower_size = plate->estimate_wipe_tower_size(print_config, plate_obj_size_info.wipe_width, wipe_volume, new_extruder_count, filaments_cnt, false, enable_wrapping); - plate_obj_size_info.wipe_width = wipe_tower_size(0); - plate_obj_size_info.wipe_depth = wipe_tower_size(1); + plate_obj_size_info.wipe_width = footprint.width; + plate_obj_size_info.wipe_depth = footprint.depth; Vec3d origin = plate->get_origin(); Vec3d start(origin(0) + plate_obj_size_info.wipe_x - brim_width, origin(1) + plate_obj_size_info.wipe_y, 0.f); @@ -4757,13 +4941,16 @@ int CLI::run(int argc, char **argv) } } - if (!arrange_cfg.is_seq_print && (assemble_plate.filaments_count > 1)||(enable_wrapping_detect && !current_wrapping_exclude_area.empty())) + if ((!arrange_cfg.is_seq_print && (assemble_plate.filaments_count > 1))||(enable_wrapping_detect && !current_wrapping_exclude_area.empty())) { //prepare the wipe tower int plate_count = partplate_list.get_plate_count(); auto printer_structure_opt = m_print_config.option>("printer_structure"); - const float tower_brim_width = m_print_config.option("prime_tower_width", true)->value; + // This margin only pre-adjusts the default away from the near edges; + // estimate_wipe_tower_polygon below computes the real clamped position. + float tower_brim_width = m_print_config.option("prime_tower_brim_width", true)->value; + if (tower_brim_width < 0.f) tower_brim_width = 8.f; // auto: object heights unknown here, 8 mm is the auto cap const float tower_margin = WIPE_TOWER_MARGIN + tower_brim_width; // set the default position, the same with print config(left top) @@ -4797,7 +4984,7 @@ int CLI::run(int argc, char **argv) wipe_y_option->set_at(&wt_y_opt, i, 0); Vec3d wipe_tower_size, wipe_tower_pos; - ArrangePolygon wipe_tower_ap = cur_plate->estimate_wipe_tower_polygon(m_print_config, i, wipe_tower_pos, wipe_tower_size, new_extruder_count, assemble_plate.filaments_count, true); + ArrangePolygon wipe_tower_ap = cur_plate->estimate_wipe_tower_polygon(m_print_config, i, wipe_tower_pos, wipe_tower_size, assemble_plate.filaments_count, true); //update the new wp position wt_x_opt.value = wipe_tower_pos(0); @@ -5060,7 +5247,10 @@ int CLI::run(int argc, char **argv) int extruder_size = used_filament_set.size(); auto printer_structure_opt = m_print_config.option>("printer_structure"); - const float tower_brim_width = m_print_config.option("prime_tower_width", true)->value; + // This margin only pre-adjusts the default away from the near edges; + // estimate_wipe_tower_polygon below computes the real clamped position. + float tower_brim_width = m_print_config.option("prime_tower_brim_width", true)->value; + if (tower_brim_width < 0.f) tower_brim_width = 8.f; // auto: object heights unknown here, 8 mm is the auto cap const float tower_margin = WIPE_TOWER_MARGIN + tower_brim_width; // set the default position, the same with print config(left top) float x = WIPE_TOWER_DEFAULT_X_POS; @@ -5097,7 +5287,7 @@ int CLI::run(int argc, char **argv) } Vec3d wipe_tower_size, wipe_tower_pos; - ArrangePolygon wipe_tower_ap = partplate_list.get_plate(plate_index_valid)->estimate_wipe_tower_polygon(m_print_config, plate_index_valid, wipe_tower_pos, wipe_tower_size, new_extruder_count, extruder_size, true); + ArrangePolygon wipe_tower_ap = partplate_list.get_plate(plate_index_valid)->estimate_wipe_tower_polygon(m_print_config, plate_index_valid, wipe_tower_pos, wipe_tower_size, extruder_size, true); //update the new wp position if (bedid < plate_count) { @@ -5198,22 +5388,16 @@ int CLI::run(int argc, char **argv) //float depth = v * (filaments_cnt - 1) / (layer_height * w); - const ConfigOptionBool *wrapping_detection = m_print_config.option("enable_wrapping_detection"); - bool enable_wrapping = (wrapping_detection != nullptr) && wrapping_detection->value; - - Vec3d wipe_tower_size = cur_plate->estimate_wipe_tower_size(m_print_config, w, v, new_extruder_count, filaments_cnt, false, enable_wrapping); + const WipeTowerFootprint footprint = cur_plate->estimate_wipe_tower_footprint(m_print_config, filaments_cnt); + Vec3d wipe_tower_size(footprint.width, footprint.depth, footprint.height); Vec3d plate_origin = cur_plate->get_origin(); int plate_width, plate_depth; double plate_height; partplate_list.get_plate_size(plate_width, plate_depth, plate_height); float depth = wipe_tower_size(1); - float margin = 15.f, wp_brim_width = 0.f; - ConfigOption *wipe_tower_brim_width_opt = m_print_config.option("prime_tower_brim_width"); - if (wipe_tower_brim_width_opt ) { - wp_brim_width = wipe_tower_brim_width_opt->getFloat(); - if (wp_brim_width < 0) wp_brim_width = WipeTower::get_auto_brim_by_height((float) wipe_tower_size.z()); - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format("arrange wipe_tower: wp_brim_width %1%")%wp_brim_width; - } + // Brim already resolved against the height the body was sized from. + float margin = 15.f, wp_brim_width = float(footprint.brim_width); + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format("arrange wipe_tower: wp_brim_width %1%")%wp_brim_width; w = wipe_tower_size(0); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format("arrange wipe_tower: x=%1%, y=%2%, width=%3%, depth=%4%, angle=%5%, prime_volume=%6%, filaments_cnt=%7%, layer_height=%8%, plate_width=%9%, plate_depth=%10%") @@ -5729,6 +5913,34 @@ int CLI::run(int argc, char **argv) //Print fff_print; std::vector plate_triangle_counts(partplate_list.get_plate_count(), 0); + // The stored (or default) tower position may not fit the tower these plates + // need, and no CLI placement site runs on a plain slice - mirror the GUI's + // reload clamp and fit every plate's tower into the printable area first. + if (m_print_config.option("enable_prime_tower", true)->value) { + for (int index = 0; index < partplate_list.get_plate_count(); index++) { + if ((plate_to_slice != 0) && (plate_to_slice != (index + 1))) + continue; + Slic3r::GUI::PartPlate *plate = partplate_list.get_plate(index); + // Printing by object disables the tower only with more than one instance. + bool is_seq_print = false; + get_print_sequence(plate, m_print_config, is_seq_print); + if (is_seq_print && plate->printable_instance_size() > 1) + continue; + // An empty estimate is a plate that prints no tower (one filament and + // neither smooth timelapse, wrapping detection nor a raft). + Vec3d wt_pos, wt_size; + plate->estimate_wipe_tower_polygon(m_print_config, index, wt_pos, wt_size); + if (wt_size(0) < EPSILON || wt_size(1) < EPSILON) + continue; + ConfigOptionFloat wt_x_opt((float) wt_pos(0)); + ConfigOptionFloat wt_y_opt((float) wt_pos(1)); + m_print_config.option("wipe_tower_x", true)->set_at(&wt_x_opt, index, 0); + m_print_config.option("wipe_tower_y", true)->set_at(&wt_y_opt, index, 0); + BOOST_LOG_TRIVIAL(info) << boost::format("plate %1%: wipe tower clamped to {%2%, %3%}, size {%4%, %5%}") + % (index + 1) % wt_pos(0) % wt_pos(1) % wt_size(0) % wt_size(1); + } + } + while(!finished) { //BBS: slice every partplate one by one diff --git a/src/OrcaSlicer_app_msvc.cpp b/src/OrcaSlicer_app_msvc.cpp index 35568a9cfa..265047fa34 100644 --- a/src/OrcaSlicer_app_msvc.cpp +++ b/src/OrcaSlicer_app_msvc.cpp @@ -297,7 +297,7 @@ int wmain(int argc, wchar_t **argv) // printf("Loading Slic3r library: %S\n", path_to_slic3r); HINSTANCE hInstance_Slic3r = LoadLibraryExW(path_to_slic3r, nullptr, 0); if (hInstance_Slic3r == nullptr) { - printf("OrcaSlicer.dll was not loaded, error=%d\n", GetLastError()); + printf("OrcaSlicer.dll was not loaded, error=%lu\n", GetLastError()); return -1; } diff --git a/src/dev-utils/BaseException.h b/src/dev-utils/BaseException.h index 2cb65d945e..20b6fb0c89 100644 --- a/src/dev-utils/BaseException.h +++ b/src/dev-utils/BaseException.h @@ -1,7 +1,7 @@ #pragma once #include #include -#include "stackwalker.h" +#include "StackWalker.h" #include class CBaseException : public CStackWalker diff --git a/src/dev-utils/OrcaSlicer_profile_validator.cpp b/src/dev-utils/OrcaSlicer_profile_validator.cpp index 67d0ba444d..56acbaa732 100644 --- a/src/dev-utils/OrcaSlicer_profile_validator.cpp +++ b/src/dev-utils/OrcaSlicer_profile_validator.cpp @@ -8,7 +8,11 @@ #define NANOSVGRAST_IMPLEMENTATION #include "nanosvg/nanosvgrast.h" +#include "libslic3r/BoundingBox.hpp" #include "libslic3r/GCode.hpp" +#include "libslic3r/GCode/WipeTower.hpp" +#include "libslic3r/GCode/WipeTowerEstimate.hpp" +#include "libslic3r/Geometry.hpp" #include "libslic3r/Preset.hpp" #include "libslic3r/Config.hpp" #include "libslic3r/PresetBundle.hpp" @@ -116,15 +120,45 @@ Vec2d printable_area_center(const DynamicPrintConfig &cfg) return 0.5 * (lo + hi); } +// Put the prime tower where the GUI and CLI would before slicing. The config default (x 15, y 220) +// lies off any bed shallower than the tower, and generation rejects an off-plate tower instead of +// exporting it. Beside the centred cube, clear of the edge exclusion strips some beds carry, then +// pulled inside the printable outline by the tower's own estimated footprint, with a few mm of +// clearance so the conflict checker never sees the two touch. +void place_wipe_tower(DynamicPrintConfig &cfg, const Vec2d ¢er) +{ + const auto *area = cfg.option("printable_area"); + if (area == nullptr || area->values.size() < 3) + return; + const WipeTowerFootprint footprint = estimate_wipe_tower_footprint(cfg, resolve_wipe_tower_type(cfg), {0, 1}, cfg.opt_float("layer_height"), 10.); + if (footprint.depth < EPSILON) + return; + const double margin = WIPE_TOWER_MARGIN + footprint.brim_width; + // The position is the tower's own origin; a rotated tower extends from it in another + // direction, so place the rotated box's extents rather than the origin. + Slic3r::Polygon box({Point::new_scale(0., 0.), Point::new_scale(footprint.width, 0.), Point::new_scale(footprint.width, footprint.depth), Point::new_scale(0., footprint.depth)}); + box.rotate(Geometry::deg2rad(cfg.opt_float("wipe_tower_rotation_angle"))); + const BoundingBox local = get_extents(box); + const Vec2d lo = unscale(local.min); + const Vec2d size = unscale(local.max) - lo; + Vec2d pos(center.x() + 5. + margin + 5. - lo.x(), center.y() - size.y() / 2. - lo.y()); + box.translate(Point::new_scale(pos.x(), pos.y())); + const Vec2f move = WipeTower::move_box_inside_polygon(get_extents(box), Polygons{Polygon::new_scale(area->values)}, scaled(margin)); + pos += move.cast(); + cfg.option("wipe_tower_x", true)->values = {pos.x()}; + cfg.option("wipe_tower_y", true)->values = {pos.y()}; +} + // Slice one centered cube that switches from filament 1 to filament 2 partway up, so exactly one // filament change fires, then export. The change drives the printer's own change_filament_gcode: on a // single-nozzle machine it rides the AMS prime tower (append_tcr), on a multi-nozzle machine it routes // through the nozzle swap (set_extruder / append_tcr2) - the engine picks the path from the printer's // topology, so one model covers both. An undefined placeholder in any shipped custom g-code throws // Slic3r::PlaceholderParserError from export. -std::string slice_two_color_cube_and_export(const DynamicPrintConfig &cfg, bool is_bbl) +std::string slice_two_color_cube_and_export(DynamicPrintConfig cfg, bool is_bbl) { const Vec2d center = printable_area_center(cfg); + place_wipe_tower(cfg, center); TriangleMesh m = make_cube(10, 10, 10); m.translate(float(center.x() - 5.), float(center.y() - 5.), 0.f); diff --git a/src/dev-utils/StackWalker.cpp b/src/dev-utils/StackWalker.cpp index 468e8927a5..3ef983cd86 100644 --- a/src/dev-utils/StackWalker.cpp +++ b/src/dev-utils/StackWalker.cpp @@ -364,7 +364,7 @@ void CStackWalker::GetModuleInformation(LPMODULE_INFO pmi) if (dwInfoSize > 0) { - LPVOID lpData = new byte[dwInfoSize]; + byte *lpData = new byte[dwInfoSize]; ZeroMemory(lpData, dwInfoSize * sizeof(byte)); if (GetFileVersionInfo(pmi->szModulePath, dwHandle, dwInfoSize, lpData) > 0 ) @@ -425,7 +425,7 @@ LPSTACKINFO CStackWalker::StackWalker(HANDLE hThread, const CONTEXT* context) else c = *context; - STACKFRAME64 sf = {0}; + STACKFRAME64 sf = {}; DWORD imageType; //intel X86 diff --git a/src/libslic3r/Algorithm/LineSplit.hpp b/src/libslic3r/Algorithm/LineSplit.hpp index 58b9fdc34a..e12113fa6c 100644 --- a/src/libslic3r/Algorithm/LineSplit.hpp +++ b/src/libslic3r/Algorithm/LineSplit.hpp @@ -49,7 +49,7 @@ SplittedLine split_line(const PathType& path, const ExPolygons& clip, bool close // Convert the input path into an open ZPath ClipperZUtils::ZPath p; - p.reserve(path.size() + closed ? 1 : 0); + p.reserve(path.size() + (closed ? 1 : 0)); ClipperLib_Z::cInt z = 0; for (const auto& point : path) { p.emplace_back(point.x(), point.y(), z); diff --git a/src/libslic3r/AppConfig.cpp b/src/libslic3r/AppConfig.cpp index 29eb96aa5b..4c6eefa470 100644 --- a/src/libslic3r/AppConfig.cpp +++ b/src/libslic3r/AppConfig.cpp @@ -42,6 +42,9 @@ namespace Slic3r { static const std::string VERSION_CHECK_URL = "https://check-version.orcaslicer.com/latest"; static const std::string PROFILE_UPDATE_URL = "https://check-version.orcaslicer.com/profile"; + +constexpr const char* CONFIG_ORCA_UPDATER_URL = "orca_updater_url"; + static const std::string MODELS_STR = "models"; const std::string AppConfig::SECTION_FILAMENTS = "filaments"; @@ -635,6 +638,11 @@ void AppConfig::set_defaults() set_bool("use_printer_agents", false); } + if (get("enable_ota").empty()) + { + set_bool("enable_ota", false); + } + // Remove legacy window positions/sizes erase("app", "main_frame_maximized"); erase("app", "main_frame_pos"); @@ -1815,7 +1823,10 @@ std::string AppConfig::version_check_url() const std::string AppConfig::profile_update_url() const { - return PROFILE_UPDATE_URL; + std::string orca_updater_url = get(CONFIG_ORCA_UPDATER_URL); + if (orca_updater_url.empty()) + return PROFILE_UPDATE_URL; + return orca_updater_url; } bool AppConfig::exists() @@ -1823,4 +1834,9 @@ bool AppConfig::exists() return boost::filesystem::exists(config_path()); } +std::string AppConfig::load_if_exists() +{ + return boost::filesystem::exists(loading_path()) ? load() : std::string(); +} + }; // namespace Slic3r diff --git a/src/libslic3r/AppConfig.hpp b/src/libslic3r/AppConfig.hpp index 0a278f4f1f..c799502993 100644 --- a/src/libslic3r/AppConfig.hpp +++ b/src/libslic3r/AppConfig.hpp @@ -113,8 +113,10 @@ public: void set_defaults(); // Load the slic3r.ini from a user profile directory (or a datadir, if configured). - // return error string or empty strinf + // Return an error string, or an empty string on success. std::string load(); + // Treat a missing config as default state; otherwise load it normally. + std::string load_if_exists(); // Store the slic3r.ini into a user profile directory (or a datadir, if configured). void save(); diff --git a/src/libslic3r/CMakeLists.txt b/src/libslic3r/CMakeLists.txt index d07c42d8f6..ffc6b5cee6 100644 --- a/src/libslic3r/CMakeLists.txt +++ b/src/libslic3r/CMakeLists.txt @@ -272,6 +272,8 @@ set(lisbslic3r_sources GCode/WipeTower2.hpp GCode/WipeTower.cpp GCode/WipeTower.hpp + GCode/WipeTowerEstimate.cpp + GCode/WipeTowerEstimate.hpp GCodeWriter.cpp GCodeWriter.hpp Geometry/ArcWelder.hpp @@ -370,6 +372,8 @@ set(lisbslic3r_sources Preset.hpp PrincipalComponents2D.cpp PrincipalComponents2D.hpp + PublishSettings.cpp + PublishSettings.hpp PrintApply.cpp PrintBase.cpp PrintBase.hpp @@ -554,6 +558,12 @@ if (_opts) target_compile_options(libslic3r_cgal PRIVATE "${_opts_bad}") endif() +if (IS_CLANG_CL) + # CGAL passes /fp:strict /fp:except-. clang-cl reports the second as overriding part of + # the first; the settings cc1 receives are the same ones MSVC produces from that pair. + target_compile_options(libslic3r_cgal PRIVATE -Wno-overriding-option) +endif () + target_link_libraries(libslic3r_cgal PRIVATE ${_cgal_tgt} admesh libigl mcut boost_libs) if (MSVC AND "${CMAKE_SIZEOF_VOID_P}" STREQUAL "4") # 32 bit MSVC workaround diff --git a/src/libslic3r/Config.hpp b/src/libslic3r/Config.hpp index 9e4344820d..ea85cda1e7 100644 --- a/src/libslic3r/Config.hpp +++ b/src/libslic3r/Config.hpp @@ -1006,6 +1006,7 @@ public: int getInt() const override { return this->value; } void setInt(int val) override { this->value = val; } ConfigOption* clone() const override { return new ConfigOptionInt(*this); } + using ConfigOptionSingle::operator==; bool operator==(const ConfigOptionInt &rhs) const throw() { return this->value == rhs.value; } std::string serialize() const override @@ -1048,6 +1049,7 @@ public: ConfigOptionType type() const override { return static_type(); } ConfigOption* clone() const override { return new ConfigOptionIntsTempl(*this); } ConfigOptionIntsTempl& operator= (const ConfigOption *opt) { this->set(opt); return *this; } + using ConfigOptionVector::operator==; bool operator==(const ConfigOptionIntsTempl &rhs) const throw() { return this->values == rhs.values; } bool operator< (const ConfigOptionIntsTempl &rhs) const throw() { return this->values < rhs.values; } // Could a special "nil" value be stored inside the vector, indicating undefined value? @@ -1137,6 +1139,7 @@ public: ConfigOptionType type() const override { return static_type(); } ConfigOption* clone() const override { return new ConfigOptionString(*this); } ConfigOptionString& operator=(const ConfigOption *opt) { this->set(opt); return *this; } + using ConfigOptionSingle::operator==; bool operator==(const ConfigOptionString &rhs) const throw() { return this->value == rhs.value; } bool operator< (const ConfigOptionString &rhs) const throw() { return this->value < rhs.value; } bool empty() const { return this->value.empty(); } @@ -1171,6 +1174,7 @@ public: ConfigOptionType type() const override { return static_type(); } ConfigOption* clone() const override { return new ConfigOptionStrings(*this); } ConfigOptionStrings& operator=(const ConfigOption *opt) { this->set(opt); return *this; } + using ConfigOptionVector::operator==; bool operator==(const ConfigOptionStrings &rhs) const throw() { return this->values == rhs.values; } bool operator< (const ConfigOptionStrings &rhs) const throw() { return this->values < rhs.values; } bool is_nil(size_t) const override { return false; } @@ -1215,6 +1219,7 @@ public: ConfigOptionType type() const override { return static_type(); } ConfigOption* clone() const override { return new ConfigOptionPercent(*this); } ConfigOptionPercent& operator= (const ConfigOption *opt) { this->set(opt); return *this; } + using ConfigOptionFloat::operator==; bool operator==(const ConfigOptionPercent &rhs) const throw() { return this->value == rhs.value; } bool operator< (const ConfigOptionPercent &rhs) const throw() { return this->value < rhs.value; } @@ -1257,6 +1262,7 @@ public: ConfigOptionType type() const override { return static_type(); } ConfigOption* clone() const override { return new ConfigOptionPercentsTempl(*this); } ConfigOptionPercentsTempl& operator=(const ConfigOption *opt) { this->set(opt); return *this; } + using ConfigOptionFloatsTempl::operator==; bool operator==(const ConfigOptionPercentsTempl &rhs) const throw() { return ConfigOptionFloatsTempl::vectors_equal(this->values, rhs.values); } bool operator< (const ConfigOptionPercentsTempl &rhs) const throw() { return ConfigOptionFloatsTempl::vectors_lower(this->values, rhs.values); } @@ -1502,6 +1508,7 @@ public: ConfigOptionType type() const override { return static_type(); } ConfigOption* clone() const override { return new ConfigOptionPoint(*this); } ConfigOptionPoint& operator=(const ConfigOption *opt) { this->set(opt); return *this; } + using ConfigOptionSingle::operator==; bool operator==(const ConfigOptionPoint &rhs) const throw() { return this->value == rhs.value; } bool operator< (const ConfigOptionPoint &rhs) const throw() { return this->value < rhs.value; } @@ -1539,6 +1546,7 @@ public: ConfigOptionType type() const override { return static_type(); } ConfigOption* clone() const override { return new ConfigOptionPoints(*this); } ConfigOptionPoints& operator= (const ConfigOption *opt) { this->set(opt); return *this; } + using ConfigOptionVector::operator==; bool operator==(const ConfigOptionPoints &rhs) const throw() { return this->values == rhs.values; } bool operator< (const ConfigOptionPoints &rhs) const throw() { return std::lexicographical_compare(this->values.begin(), this->values.end(), rhs.values.begin(), rhs.values.end(), [](const auto &l, const auto &r){ return l < r; }); } @@ -1617,6 +1625,7 @@ public: ConfigOptionType type() const override { return static_type(); } ConfigOption* clone() const override { return new ConfigOptionPoint3(*this); } ConfigOptionPoint3& operator=(const ConfigOption *opt) { this->set(opt); return *this; } + using ConfigOptionSingle::operator==; bool operator==(const ConfigOptionPoint3 &rhs) const throw() { return this->value == rhs.value; } bool operator< (const ConfigOptionPoint3 &rhs) const throw() { return this->value.x() < rhs.value.x() || (this->value.x() == rhs.value.x() && (this->value.y() < rhs.value.y() || (this->value.y() == rhs.value.y() && this->value.z() < rhs.value.z()))); } @@ -1860,6 +1869,7 @@ public: bool getBool() const override { return this->value; } ConfigOption* clone() const override { return new ConfigOptionBool(*this); } ConfigOptionBool& operator=(const ConfigOption *opt) { this->set(opt); return *this; } + using ConfigOptionSingle::operator==; bool operator==(const ConfigOptionBool &rhs) const throw() { return this->value == rhs.value; } bool operator< (const ConfigOptionBool &rhs) const throw() { return int(this->value) < int(rhs.value); } @@ -1911,6 +1921,7 @@ public: ConfigOptionType type() const override { return static_type(); } ConfigOption* clone() const override { return new ConfigOptionBoolsTempl(*this); } ConfigOptionBoolsTempl& operator=(const ConfigOption *opt) { this->set(opt); return *this; } + using ConfigOptionVector::operator==; bool operator==(const ConfigOptionBoolsTempl &rhs) const throw() { return this->values == rhs.values; } bool operator< (const ConfigOptionBoolsTempl &rhs) const throw() { return this->values < rhs.values; } // Could a special "nil" value be stored inside the vector, indicating undefined value? @@ -2163,6 +2174,7 @@ public: ConfigOptionEnumsGenericTempl& operator= (const ConfigOption* opt) { this->set(opt); return *this; } bool operator< (const ConfigOptionInts& rhs) const throw() { return this->values < rhs.values; } + using ConfigOptionInts::operator==; bool operator==(const ConfigOptionInts& rhs) const { if (rhs.type() != this->type()) diff --git a/src/libslic3r/Emboss.cpp b/src/libslic3r/Emboss.cpp index ef144b48d3..34d9a93590 100644 --- a/src/libslic3r/Emboss.cpp +++ b/src/libslic3r/Emboss.cpp @@ -968,10 +968,10 @@ EmbossStyles Emboss::get_font_list_by_register() { } // TODO: Fix global function -bool CALLBACK EnumFamCallBack(LPLOGFONT lplf, - LPNEWTEXTMETRIC lpntm, - DWORD FontType, - LPVOID aFontList) +int CALLBACK EnumFamCallBack(const LOGFONT *lplf, + const TEXTMETRIC *lpntm, + DWORD FontType, + LPARAM aFontList) { std::vector *fontList = (std::vector *) (aFontList); @@ -988,7 +988,7 @@ EmbossStyles Emboss::get_font_list_by_enumeration() { HDC hDC = GetDC(NULL); std::vector font_names; - EnumFontFamilies(hDC, (LPCTSTR) NULL, (FONTENUMPROC) EnumFamCallBack, + EnumFontFamilies(hDC, (LPCTSTR) NULL, EnumFamCallBack, (LPARAM) &font_names); EmbossStyles font_list; diff --git a/src/libslic3r/Feature/FuzzySkin/FuzzySkin.cpp b/src/libslic3r/Feature/FuzzySkin/FuzzySkin.cpp index 97f8f743fb..e5fbeb5cdb 100644 --- a/src/libslic3r/Feature/FuzzySkin/FuzzySkin.cpp +++ b/src/libslic3r/Feature/FuzzySkin/FuzzySkin.cpp @@ -342,7 +342,7 @@ void fuzzy_polyline(Points& poly, bool closed, coordf_t slice_z, const FuzzySkin } // Thanks Cura developers for this function. -void fuzzy_extrusion_line(Arachne::ExtrusionJunctions& ext_lines, coordf_t slice_z, const FuzzySkinConfig& cfg, bool closed) +void fuzzy_extrusion_line(Arachne::ExtrusionJunctions& ext_lines, coordf_t slice_z, coordf_t layer_height, const FuzzySkinConfig& cfg, bool closed) { if (cfg.noise_type == NoiseType::Ripple) { @@ -356,7 +356,9 @@ void fuzzy_extrusion_line(Arachne::ExtrusionJunctions& ext_lines, coordf_t slice const double min_dist_between_points = cfg.point_distance * 3. / 4.; // hardcoded: the point distance may vary between 3/4 and 5/4 the supplied value const double range_random_point_dist = cfg.point_distance / 2.; - const double min_extrusion_width = 0.01; // workaround for many print options. Need overwrite formula with the layer height parameter. The width must more than >>> layer_height * (1 - 0.25 * PI) * 1.05 <<< (last num is the coeff of overlay error case) + // ExtrusionJunction::w is a scaled coord_t, so this floor must be scaled too. + // Flow::rounded_rectangle_extrusion_spacing() requires width > height * (1 - 0.25 * PI); keep 5% above it. + const double min_extrusion_width = scaled(layer_height * (1. - 0.25 * M_PI) * 1.05); double dist_left_over = random_value() * (min_dist_between_points / 2.); // the distance to be traversed on the line before making the first new point auto* p0 = &ext_lines.front(); @@ -685,12 +687,13 @@ Polygon apply_fuzzy_skin(const Polygon& polygon, const PerimeterGenerator& perim void apply_fuzzy_skin(Arachne::ExtrusionLine* extrusion, const PerimeterGenerator& perimeter_generator, const bool is_contour, const bool closed) { const auto slice_z = perimeter_generator.slice_z; + const auto layer_height = perimeter_generator.layer_height; const auto& regions = perimeter_generator.regions_by_fuzzify; if (regions.size() == 1) { // optimization const auto& config = regions.begin()->first; const bool fuzzify = should_fuzzify(config, perimeter_generator.layer_id, extrusion->inset_idx, is_contour); if (fuzzify) - fuzzy_extrusion_line(extrusion->junctions, slice_z, config, closed); + fuzzy_extrusion_line(extrusion->junctions, slice_z, perimeter_generator.layer_height, config, closed); } else { // Merge regions that produce identical fuzzy effects (differ only in type). // When the style (e.g. External) and a painted region (All) both fuzzify this loop @@ -701,7 +704,7 @@ void apply_fuzzy_skin(Arachne::ExtrusionLine* extrusion, const PerimeterGenerato // Fast path: single merged region — apply directly without splitting if (merged_regions.size() == 1 && merged_regions.front().expolygons.empty()) { - fuzzy_extrusion_line(extrusion->junctions, slice_z, *merged_regions.front().config, closed); + fuzzy_extrusion_line(extrusion->junctions, slice_z, perimeter_generator.layer_height, *merged_regions.front().config, closed); return; } @@ -761,7 +764,7 @@ void apply_fuzzy_skin(Arachne::ExtrusionLine* extrusion, const PerimeterGenerato // Fuzzy splitted extrusion if (std::all_of(splitted.begin(), splitted.end(), [](const Algorithm::SplitLineJunction& j) { return j.clipped; })) { // The entire polygon is fuzzified - fuzzy_extrusion_line(extrusion->junctions, slice_z, *r.config, closed); + fuzzy_extrusion_line(extrusion->junctions, slice_z, perimeter_generator.layer_height, *r.config, closed); continue; } else { const auto current_ext = extrusion->junctions; @@ -769,12 +772,12 @@ void apply_fuzzy_skin(Arachne::ExtrusionLine* extrusion, const PerimeterGenerato segment.reserve(current_ext.size()); extrusion->junctions.clear(); - const auto fuzzy_current_segment = [&segment, &extrusion, &r, slice_z]() { + const auto fuzzy_current_segment = [&segment, &extrusion, &r, slice_z, layer_height]() { // Orca: non fuzzy points to isolate fuzzy region const auto front = segment.front(); const auto back = segment.back(); - fuzzy_extrusion_line(segment, slice_z, *r.config, false); + fuzzy_extrusion_line(segment, slice_z, layer_height, *r.config, false); // Orca: only add non fuzzy point if it's not in the extrusion closing point. if (!extrusion->junctions.empty() && extrusion->junctions.front().p != front.p) { extrusion->junctions.push_back(front); diff --git a/src/libslic3r/Feature/FuzzySkin/FuzzySkin.hpp b/src/libslic3r/Feature/FuzzySkin/FuzzySkin.hpp index 51d503a3c9..ab0c491b38 100644 --- a/src/libslic3r/Feature/FuzzySkin/FuzzySkin.hpp +++ b/src/libslic3r/Feature/FuzzySkin/FuzzySkin.hpp @@ -9,7 +9,7 @@ namespace Slic3r::Feature::FuzzySkin { void fuzzy_polyline(Points& poly, bool closed, coordf_t slice_z, const FuzzySkinConfig& cfg); -void fuzzy_extrusion_line(Arachne::ExtrusionJunctions& ext_lines, coordf_t slice_z, const FuzzySkinConfig& cfg, bool closed = true); +void fuzzy_extrusion_line(Arachne::ExtrusionJunctions& ext_lines, coordf_t slice_z, coordf_t layer_height, const FuzzySkinConfig& cfg, bool closed = true); void group_region_by_fuzzify(PerimeterGenerator& g); diff --git a/src/libslic3r/Fill/Fill.cpp b/src/libslic3r/Fill/Fill.cpp index dc772580ca..f5386b085c 100644 --- a/src/libslic3r/Fill/Fill.cpp +++ b/src/libslic3r/Fill/Fill.cpp @@ -11,7 +11,7 @@ #include "AABBTreeLines.hpp" #include "ExtrusionEntity.hpp" -#include "FillBase.hpp" +#include "Fill.hpp" #include "FillRectilinear.hpp" #include "FillLightning.hpp" #include "FillConcentricInternal.hpp" @@ -1234,6 +1234,33 @@ std::vector group_fills(const Layer &layer, LockRegionParam &lock_p return surface_fills; } +// Orca: Anchors and printed infill must share the same body origin. Keep the choice +// here so per-model surface centering and separated sparse infill cannot drift apart. +static BoundingBox infill_bounding_box(const Layer &layer, const SurfaceFill &fill, const ExPolygon &expoly, BoundingBox bbox) +{ + const auto ¶ms = fill.params; + const auto &config = layer.regions()[fill.region_id]->region().config(); + const bool external = params.extrusion_role == erTopSolidInfill || params.extrusion_role == erBottomSurface; + const bool per_model = external && params.center_of_surface_pattern == CenterOfSurfacePattern::Each_Model && + (params.pattern == ipArchimedeanChords || params.pattern == ipOctagramSpiral); + const bool separate = !external && params.separated_infills && + (is_separable_infill_pattern(params.pattern) || !config.solid_infill_rotate_template.value.empty() || + !config.sparse_infill_rotate_template.value.empty()); + if (per_model || separate) { + double best_overlap = 0.; + for (size_t i = 0; i < layer.lslices.size() && i < layer.lslices_separated_component_bboxes.size(); ++i) { + const double overlap = area(intersection_ex(layer.lslices[i], expoly)); + if (overlap > best_overlap) { + best_overlap = overlap; + const Point center = layer.lslices_separated_component_bboxes[i].center(); + bbox = layer.object()->bounding_box(); + bbox.translate(center.x(), center.y()); + } + } + } + return bbox; +} + #ifdef SLIC3R_DEBUG_SLICE_PROCESSING void export_group_fills_to_svg(const char *path, const std::vector &fills) { @@ -1353,19 +1380,9 @@ void Layer::make_fills(FillAdaptive::Octree* adaptive_fill_octree, FillAdaptive: // Orca: Checking the filling of a centered surface by drawing for each model parts bool is_top_or_bottom = params.extrusion_role == erTopSolidInfill || params.extrusion_role == erBottomSurface; - bool is_centered_infill = surface_fill.params.pattern == ipArchimedeanChords || surface_fill.params.pattern == ipOctagramSpiral; if (is_top_or_bottom) { params.center_of_surface_pattern = surface_fill.params.center_of_surface_pattern; // Orca: center of surface pattern } - // Orca: Each_Model centers the pattern on each model part's bbox; Each_Surface / Each_Assembly - // fall through to the default (whole-object) bounding box below. - bool is_per_model_center = is_top_or_bottom && params.center_of_surface_pattern == CenterOfSurfacePattern::Each_Model && is_centered_infill; - bool is_separate_infill = !is_top_or_bottom && surface_fill.params.separated_infills && - ( - is_separable_infill_pattern(surface_fill.params.pattern) || - params.config->solid_infill_rotate_template != "" || - params.config->sparse_infill_rotate_template != "" ); - if( surface_fill.params.pattern == ipLockedZag ) { params.locked_zag = true; params.infill_lock_depth = surface_fill.params.infill_lock_depth; @@ -1389,34 +1406,8 @@ void Layer::make_fills(FillAdaptive::Octree* adaptive_fill_octree, FillAdaptive: params.can_reverse = false; for (ExPolygon& expoly : surface_fill.expolygons) { - // Orca: separate infill / per-model pattern centering. - // - // Center the pattern on each connected body of the object independently, so every piece - // is filled exactly as if it were sliced on its own: touching/overlapping parts merge - // into one body sharing a center, while separate parts and disconnected islands (even - // interleaved-but-not-touching ones, e.g. chain links) each get their own. The body each - // island belongs to, and its full bounding box, were resolved in 3D by PrintObject:: - // infill() (lslices_separated_component_bboxes, aligned with this layer's lslices). We - // match this fill region to the island it overlaps most, then re-use the whole-object - // bounding box (origin-centered — identical extent to the default, so coverage and cost - // are unchanged) re-centered on that body. - if (is_per_model_center || is_separate_infill) { - double best_overlap = 0.; - BoundingBox best_component; - for (size_t r = 0; r < this->lslices.size() && r < this->lslices_separated_component_bboxes.size(); ++ r) { - const double overlap = area(intersection_ex(this->lslices[r], expoly)); - if (overlap > best_overlap) { - best_overlap = overlap; - best_component = this->lslices_separated_component_bboxes[r]; - } - } - if (best_component.defined) { - const Point c = best_component.center(); - BoundingBox part_bbox = bbox; // origin-centered, whole-object extent (from above) - part_bbox.translate(c.x(), c.y()); // re-center on this body - f->set_bounding_box(part_bbox); - } - } // - End: separate infill / per-model pattern centering + // Orca: Reuse the body origin used for bridge anchoring, resetting it for each surface. + f->set_bounding_box(infill_bounding_box(*this, surface_fill, expoly, bbox)); f->no_overlap_expolygons = intersection_ex(surface_fill.no_overlap_expolygons, ExPolygons() = {expoly}, ApplySafetyOffset::Yes); if (params.symmetric_infill_y_axis) { @@ -1583,8 +1574,14 @@ Polylines Layer::generate_sparse_infill_polylines_for_anchoring(FillAdaptive::Oc params.multiline = surface_fill.params.multiline; params.gyroid_optimized = surface_fill.params.gyroid_optimized; params.smooth_factor = surface_fill.params.smooth_factor; + // Orca: Match make_fills() when choosing the origin of plane-path patterns. + // Without the sparse extrusion role, the filler uses each surface's bounds + // instead of the object's bounds, so bridge anchors shift away from printed infill. + params.extrusion_role = surface_fill.params.extrusion_role; for (ExPolygon &expoly : surface_fill.expolygons) { + // Orca: Match the per-body origin of make_fills() before generating physical anchors. + f->set_bounding_box(infill_bounding_box(*this, surface_fill, expoly, bbox)); // Spacing is modified by the filler to indicate adjustments. Reset it for each expolygon. f->spacing = surface_fill.params.spacing; surface_fill.surface.expolygon = std::move(expoly); diff --git a/src/libslic3r/Fill/Fill.hpp b/src/libslic3r/Fill/Fill.hpp index e92ab2dee5..b183cf0253 100644 --- a/src/libslic3r/Fill/Fill.hpp +++ b/src/libslic3r/Fill/Fill.hpp @@ -14,6 +14,12 @@ namespace Slic3r { class ExtrusionEntityCollection; class LayerRegion; +class PrintObject; + +// Orca: Share the layer rotation calculation between infill generation and internal +// bridge angle selection so both interpret rotation templates in the same way. +double calculate_infill_rotation_angle(const PrintObject *object, size_t layer_id, + const double &fixed_infill_angle, const std::string &template_string); // An interface class to Perl, aggregating an instance of a Fill and a FillData. class Filler diff --git a/src/libslic3r/Fill/FillAdaptive.cpp b/src/libslic3r/Fill/FillAdaptive.cpp index 344bb529f0..dbaa1f2ac9 100644 --- a/src/libslic3r/Fill/FillAdaptive.cpp +++ b/src/libslic3r/Fill/FillAdaptive.cpp @@ -1395,8 +1395,8 @@ void Filler::_fill_surface_single( } #endif /* ADAPTIVE_CUBIC_INFILL_DEBUG_OUTPUT */ - const auto hook_length = coordf_t(std::min(std::numeric_limits::max(), scale_(params.anchor_length))); - const auto hook_length_max = coordf_t(std::min(std::numeric_limits::max(), scale_(params.anchor_length_max))); + const auto hook_length = coordf_t(scale_(params.anchor_length)); + const auto hook_length_max = coordf_t(scale_(params.anchor_length_max)); Polylines all_polylines_with_hooks = all_polylines.size() > 1 ? connect_lines_using_hooks(std::move(all_polylines), expolygon, this->spacing, hook_length, hook_length_max) : std::move(all_polylines); diff --git a/src/libslic3r/Fill/FillRectilinear.cpp b/src/libslic3r/Fill/FillRectilinear.cpp index db340748ab..7edf350081 100644 --- a/src/libslic3r/Fill/FillRectilinear.cpp +++ b/src/libslic3r/Fill/FillRectilinear.cpp @@ -3090,10 +3090,11 @@ bool FillRectilinear::fill_surface_trapezoidal( case 0: // Grid / Trapezoidal { // Generate a non-crossing trapezoidal pattern to avoid overextrusion at intersections when `multiline > 1`. - // P2--P3 - // / \ - // P0_P1/ \P4_ - // + /* + * P2--P3 + * / \ + * P0_P1/ \P4_ + */ // P0xP1x=P4xP0x=d1/2 // P2xP3x=d1 // P1yP2y=P2yP3y=d2 @@ -3171,10 +3172,12 @@ bool FillRectilinear::fill_surface_trapezoidal( case 1: // Triangular { // Generate a non-crossing trapezoidal pattern with a base line below. - // P1-P2 - // / \ - // P0/ \P3_P4 - // ---------------- + /* + * P1-P2 + * / \ + * P0/ \P3_P4 + * ---------------- + */ // P1xP2x=P3xP4x=d2 // P0yP1y=P2yP3y=h-2d1 // diff --git a/src/libslic3r/FlushVolCalc.cpp b/src/libslic3r/FlushVolCalc.cpp index 9bdbc1737d..635328f5ab 100644 --- a/src/libslic3r/FlushVolCalc.cpp +++ b/src/libslic3r/FlushVolCalc.cpp @@ -40,8 +40,8 @@ static float DeltaHS_BBS(float h1, float s1, float v1, float h2, float s2, float return std::min(1.2f, dxy); } -FlushVolCalculator::FlushVolCalculator(int min, int max, int flush_dataset, float multiplier) - :m_min_flush_vol(min), m_max_flush_vol(max), m_multiplier(multiplier), m_flush_dataset(flush_dataset) +FlushVolCalculator::FlushVolCalculator(int min, int max, int flush_dataset) + :m_min_flush_vol(min), m_max_flush_vol(max), m_flush_dataset(flush_dataset) { } diff --git a/src/libslic3r/FlushVolCalc.hpp b/src/libslic3r/FlushVolCalc.hpp index 46d04d13ff..73baca5a89 100644 --- a/src/libslic3r/FlushVolCalc.hpp +++ b/src/libslic3r/FlushVolCalc.hpp @@ -15,7 +15,7 @@ extern const int g_max_flush_volume; class FlushVolCalculator { public: - FlushVolCalculator(int min, int max, int flush_dataset, float multiplier = 1.0f); + FlushVolCalculator(int min, int max, int flush_dataset); ~FlushVolCalculator() { } @@ -32,7 +32,6 @@ public: private: int m_min_flush_vol; int m_max_flush_vol; - float m_multiplier; int m_flush_dataset; }; diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index b0cbb1fd50..e2091da1db 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -102,45 +102,6 @@ struct ZipUnicodePathExtraField } }; -// Validate that a relative file path does not escape the root directory via path traversal. -static bool is_path_within_root(const std::string& file_path, const boost::filesystem::path& root) -{ - if (file_path.empty()) - return false; - - boost::filesystem::path p(file_path); - if (p.is_absolute()) - return false; - - // Reject any path component that is ".." - for (const auto& component : p) { - if (component == "..") - return false; - } - - // Resolve the full path and verify it starts with the canonical root (also catches symlink escapes) - try { - boost::filesystem::path full_path = root / p; - boost::filesystem::path canonical_root = boost::filesystem::weakly_canonical(root); - boost::filesystem::path canonical_full = boost::filesystem::weakly_canonical(full_path); - - auto root_str = canonical_root.string(); - auto full_str = canonical_full.string(); - if (full_str.length() < root_str.length()) - return false; - if (full_str.compare(0, root_str.length(), root_str) != 0) - return false; - // Ensure it's a proper prefix (not just a substring of a longer directory name) - if (full_str.length() > root_str.length() && - full_str[root_str.length()] != boost::filesystem::path::preferred_separator) - return false; - } catch (const boost::filesystem::filesystem_error&) { - return false; - } - - return true; -} - // VERSION NUMBERS // 0 : .3mf, files saved by older slic3r or other applications. No version definition in them. // 1 : Introduction of 3mf versioning. No other change in data saved into 3mf files. @@ -681,6 +642,11 @@ bool bbs_is_valid_object_type(const std::string& type) namespace Slic3r { +bool is_published_3mf_flag(const std::string &value) +{ + return value == "1"; +} + void PlateData::parse_filament_info(GCodeProcessorResult *result) { if (!result) return; @@ -1217,6 +1183,20 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) // add backup & restore logic bool _load_model_from_file(std::string filename, Model& model, PlateDataPtrs& plate_data_list, std::vector& project_presets, DynamicPrintConfig& config, ConfigSubstitutionContext& config_substitutions, Import3mfProgressFn proFn = nullptr, BBLProject* project = nullptr, int plate_id = 0); + + // A minimal published 3MF carries no slicer tags (any tag would make old receivers show + // a baked-in, wrong "old version" popup on their geometry-only fallback), so it + // classifies as From_Other. It is still a fully structured OrcaSlicer file though: + // identified by its own metadata, it keeps BBS-grade geometry handling (no instance + // splitting, no transform baking, no renaming) in this build. Old receivers without the + // publish feature don't know the metadata and take their third-party geometry path. + // Reads the parse-time metadata: the model XML carries it before its resources, while + // m_model->model_info is only filled in after the whole XML has been parsed. + bool _is_published_3mf() const { + const auto it = this->model_info.metadata_items.find(ORCA_PUBLISHED_TAG); + return it != this->model_info.metadata_items.end() && is_published_3mf_flag(it->second); + } + bool _is_svg_shape_file(const std::string &filename) const; bool _extract_from_archive(mz_zip_archive& archive, std::string const & path, std::function, bool restore = false); bool _extract_xml_from_archive(mz_zip_archive& archive, std::string const & path, XML_StartElementHandler start_handler, XML_EndElementHandler end_handler); @@ -2041,7 +2021,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) lock.close(); - if (!m_is_bbl_3mf) { + if (!m_is_bbl_3mf && !_is_published_3mf()) { // if the 3mf was not produced by OrcaSlicer and there is more than one instance, // split the object in as many objects as instances BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ":" << __LINE__ << boost::format(", found 3mf from other vendor, split as instance"); @@ -3605,7 +3585,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) m_index_paths.insert({ object.first.second, object.first.first}); } - if (!m_is_bbl_3mf) { + if (!m_is_bbl_3mf && !_is_published_3mf()) { // if the 3mf was not produced by OrcaSlicer and there is only one object, // set the object name to match the filename if (m_model->objects.size() == 1) @@ -5328,7 +5308,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) TriangleMesh triangle_mesh(std::move(its), volume_data.mesh_stats); - if (!m_is_bbl_3mf) { + if (!m_is_bbl_3mf && !_is_published_3mf()) { // if the 3mf was not produced by OrcaSlicer and there is only one instance, // bake the transformation into the geometry to allow the reload from disk command // to work properly @@ -5974,6 +5954,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) bool m_save_gcode { false }; // whether to save gcode for normal save bool m_skip_model { false }; // skip model when exporting .gcode.3mf bool m_skip_auxiliary { false }; // skip normal axuiliary files + bool m_minimal_published { false }; // published 3MF: omit the project config, the embedded preset files and the slicer tags bool m_use_loaded_id { false }; // whether to use loaded id for identify_id bool m_share_mesh { false }; // whether to share mesh between objects std::string m_thumbnail_middle = PRINTER_THUMBNAIL_MIDDLE_FILE; @@ -6073,6 +6054,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) m_skip_auxiliary = store_params.strategy & SaveStrategy::SkipAuxiliary; m_share_mesh = store_params.strategy & SaveStrategy::ShareMesh; m_from_backup_save = store_params.strategy & SaveStrategy::Backup; + m_minimal_published = store_params.strategy & SaveStrategy::MinimalPublished; m_use_loaded_id = store_params.strategy & SaveStrategy::UseLoadedId; @@ -6482,7 +6464,10 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) // Adds slic3r print config file ("Metadata/Slic3r_PE.config"). // This file contains the content of FullPrintConfig / SLAFullPrintConfig. - if (config != nullptr) { + // Omitted for minimal published 3MF: OrcaSlicer versions without the publish feature + // then fall back to importing the geometry only, and new versions read the published + // payload from the model metadata instead. + if (config != nullptr && !m_minimal_published) { // BBS: change to json format // if (!_add_print_config_file_to_archive(archive, *config)) { if (!_add_project_config_file_to_archive(archive, *config, model)) { return false; } @@ -6495,8 +6480,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) if (cb_cancel) return false; } - // BBS: add project config - if (project_presets.size() > 0) { + // BBS: add project config (omitted for minimal published 3MF) + if (!m_minimal_published && project_presets.size() > 0) { // BBS: add project embedded preset files _add_project_embedded_presets_to_archive(archive, model, project_presets); @@ -6968,10 +6953,31 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) // Orca: PRIVACY: do not store creation & modification date in 3mf metadata_item_map[BBL_CREATION_DATE_TAG] = ""; metadata_item_map[BBL_MODIFICATION_TAG] = ""; - // Orca: Write the BambuStudio compatibility version string using SLIC3R_VERSION - metadata_item_map[BBL_APPLICATION_TAG] = (boost::format("%1%-%2%") % "BambuStudio" % SLIC3R_VERSION).str(); + // Orca: Write the BambuStudio compatibility version string using SLIC3R_VERSION. + // A minimal published 3MF writes no slicer tags at all: any tag would route old + // receivers onto a geometry-only fallback whose baked-in popup misreports the + // file ("old OrcaSlicer version" / "BambuStudio"), while tag-less files classify + // as From_Other and import the geometry silently. + if (m_minimal_published) { + // metadata_item_map is seeded from the input file's metadata_items above, so a + // project opened from a regular Orca/BBS 3MF still carries the slicer-identifying + // tags it came with. Erase every one of them - not just the two most common - + // so a published 3MF is fully tag-less: old receivers classify it as From_Other + // and import the geometry silently instead of showing a baked-in "old version" + // popup, and no version marker survives to seed a later re-save. + metadata_item_map.erase(BBL_APPLICATION_TAG); + metadata_item_map.erase(ORCASLICER_TAG); + metadata_item_map.erase(BBS_3MF_VERSION); + metadata_item_map.erase(BBS_3MF_VERSION1); + } else { + metadata_item_map[BBL_APPLICATION_TAG] = (boost::format("%1%-%2%") % "BambuStudio" % SLIC3R_VERSION).str(); + } } - metadata_item_map[BBS_3MF_VERSION] = std::to_string(VERSION_BBS_3MF); + // The Bambu 3MF version marker is part of the slicer identity: omit it for a minimal + // published file along with the tags erased above (skipping the overwrite alone would + // leave the value the source file seeded into metadata_item_map). + if (!m_minimal_published) + metadata_item_map[BBS_3MF_VERSION] = std::to_string(VERSION_BBS_3MF); if (!model.mk_name.empty()) { metadata_item_map[BBL_MAKERLAB_TAG] = xml_escape(model.mk_name); @@ -6994,7 +7000,11 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) BOOST_LOG_TRIVIAL(info) << "bbs_3mf: save key= " << item.first << ", value = " << item.second; stream << " <" << METADATA_TAG << " name=\"" << item.first << "\">" << xml_escape(item.second) << "\n"; - if (item.first == BBL_APPLICATION_TAG) { + if (item.first == BBL_APPLICATION_TAG && !m_minimal_published) { + // The OrcaSlicer tag is only written for files that carry the Application + // tag, which a minimal published 3MF erases (see the map assignment above): + // the explicit !m_minimal_published guard keeps the tag-less guarantee from + // depending on that erase happening to run first. stream << " <" << METADATA_TAG << " name=\"" << ORCASLICER_TAG << "\">" << xml_escape(SoftFever_VERSION) << "\n"; } @@ -8834,7 +8844,7 @@ public: auto model = object.get_model(); auto o = m_temp_model.add_object(object); int backup_id = model->get_object_backup_id(object); - push_task({ AddObject, (size_t) backup_id, object.get_model()->get_backup_path(), o, 1 }); + push_task({ AddObject, (size_t) backup_id, object.get_model()->get_backup_path(), o, { 1 } }); } void remove_object_mesh(ModelObject& object) { @@ -8844,7 +8854,7 @@ public: void backup_soon() { boost::lock_guard lock(m_mutex); m_other_changes_backup = true; - m_tasks.push_back({ Backup, 0, std::string(), nullptr, ++m_task_seq }); + m_tasks.push_back({ Backup, 0, std::string(), nullptr, { ++m_task_seq } }); m_cond.notify_all(); } @@ -8862,7 +8872,7 @@ public: m_ui_tasks.clear(); m_tasks.clear(); } - m_tasks.push_back({ RemoveBackup, model.id().id, model.get_backup_path(), nullptr, removeAll }); + m_tasks.push_back({ RemoveBackup, model.id().id, model.get_backup_path(), nullptr, { removeAll } }); ++m_task_seq; if (model.is_need_backup()) { m_other_changes = false; @@ -9077,7 +9087,7 @@ public: else m_cond.wait(lock); if (m_interval > 0 && boost::get_system_time() > m_next_backup) { - m_tasks.push_back({ Backup, 0, std::string(), nullptr, ++m_task_seq }); + m_tasks.push_back({ Backup, 0, std::string(), nullptr, { ++m_task_seq } }); m_next_backup += boost::posix_time::seconds(m_interval); // Maybe wakeup from power sleep if (m_next_backup < boost::get_system_time()) @@ -9173,6 +9183,126 @@ std::string bbs_3mf_get_thumbnail(const char *path) return data; } +namespace { + +// Parses just the model-file elements, mirroring the importer's +// _handle_start_metadata/_handle_end_metadata (attribute-order independent, entity-unescaped, +// whitespace tolerant). Stops the parser as soon as the published flag node is read so the +// geometry/resources that follow are skipped, which keeps the per-file cost small. +struct PublishedXmlProbe +{ + XML_Parser parser{nullptr}; + bool in_metadata{false}; + bool found{false}; + bool published{false}; + std::string curr_name; + std::string curr_value; + + static std::string attribute(const char** attrs, const char* key) + { + if (attrs == nullptr) + return std::string(); + // expat hands the attrs as a NULL-terminated {name, value, ...} array. + for (unsigned int a = 0; attrs[a] != nullptr; a += 2) + if (::strcmp(attrs[a], key) == 0 && attrs[a + 1] != nullptr) + return attrs[a + 1]; + return std::string(); + } + + static void XMLCALL start(void* user_data, const char* name, const char** attrs) + { + auto* self = static_cast(user_data); + if (::strcmp(name, METADATA_TAG) == 0) { + self->in_metadata = true; + self->curr_name = attribute(attrs, NAME_ATTR); + self->curr_value.clear(); + } else { + self->in_metadata = false; + } + } + + static void XMLCALL characters(void* user_data, const XML_Char* s, int len) + { + auto* self = static_cast(user_data); + if (self->in_metadata) + self->curr_value.append(s, len); + } + + static void XMLCALL end(void* user_data, const char* name) + { + auto* self = static_cast(user_data); + if (!self->in_metadata || ::strcmp(name, METADATA_TAG) != 0) + return; + self->in_metadata = false; + if (self->curr_name == ORCA_PUBLISHED_TAG) { + self->published = is_published_3mf_flag(xml_unescape(self->curr_value)); + self->found = true; + if (self->parser != nullptr) + XML_StopParser(self->parser, false); + } + } +}; + +} // namespace + +bool bbs_3mf_is_published(const std::string &path) +{ + mz_zip_archive archive; + mz_zip_zero_struct(&archive); + + struct close_lock + { + mz_zip_archive *archive; + void close() + { + if (archive) { + close_zip_reader(archive); + archive = nullptr; + } + } + ~close_lock() { close(); } + } lock{&archive}; + + if (!open_zip_reader(&archive, path)) + return false; + + // Read just the model XML (the metadata node sits before the resources, so the probe below + // stops early) rather than by a raw substring match; no geometry parsing. + int index = mz_zip_reader_locate_file(&archive, MODEL_FILE.c_str(), nullptr, 0); + if (index < 0) + return false; + mz_zip_archive_file_stat stat; + if (!mz_zip_reader_file_stat(&archive, index, &stat)) + return false; + std::string xml(stat.m_uncomp_size, '\0'); + if (!mz_zip_reader_extract_to_mem(&archive, index, xml.data(), xml.size(), 0)) + return false; + + XML_Parser parser = XML_ParserCreate(nullptr); + if (parser == nullptr) + return false; + + PublishedXmlProbe probe; + probe.parser = parser; + XML_SetUserData(parser, &probe); + XML_SetElementHandler(parser, PublishedXmlProbe::start, PublishedXmlProbe::end); + XML_SetCharacterDataHandler(parser, PublishedXmlProbe::characters); + // Never resolve external entities from a file we are only probing. + XML_SetExternalEntityRefHandler(parser, nullptr); + XML_SetEntityDeclHandler(parser, nullptr); + + const XML_Status status = XML_Parse(parser, xml.data(), static_cast(xml.size()), 1); + // XML_StopParser(parser, false) from the end handler makes XML_Parse return + // XML_STATUS_ERROR with XML_ERROR_ABORTED - treat that as success (we stopped on the flag). + const bool parse_ok = (status == XML_STATUS_OK) || + (XML_GetErrorCode(parser) == XML_ERROR_ABORTED && probe.found); + XML_ParserFree(parser); + + if (!parse_ok) + return false; + return probe.published; +} + bool load_gcode_3mf_from_stream(std::istream &data, DynamicPrintConfig *config, Model *model, PlateDataPtrs *plate_data_list, Semver *file_version) { CNumericLocalesSetter locales_setter; diff --git a/src/libslic3r/Format/bbs_3mf.hpp b/src/libslic3r/Format/bbs_3mf.hpp index 7f5bb8c78d..768ae7bcfe 100644 --- a/src/libslic3r/Format/bbs_3mf.hpp +++ b/src/libslic3r/Format/bbs_3mf.hpp @@ -159,12 +159,28 @@ enum class SaveStrategy SkipAuxiliary = 1 << 9, UseLoadedId = 1 << 10, ShareMesh = 1 << 11, + // Keep this separate from SplitModel, which uses the 0x1000 bit as part of its + // production-extension value. + MinimalPublished = 1 << 13, SplitModel = 0x1000 | ProductionExt, Encrypted = SecureContentExt | SplitModel, Backup = 0x10000 | WithGcode | Silence | SkipStatic | SplitModel, }; +// Model metadata keys of a "published" 3MF (see MinimalPublished): the flag marks a minimal, +// tag-less publish export, the others carry the author-selected settings payload. Namespaced +// with the "orca_published" prefix because metadata_items round-trips verbatim through other +// slicers, where a bare "published" key could collide. +inline constexpr const char *ORCA_PUBLISHED_TAG = "orca_published"; +inline constexpr const char *ORCA_PUBLISHED_KEYS_TAG = "orca_published_keys"; +inline constexpr const char *ORCA_PUBLISHED_MATERIAL_TAG = "orca_published_material_keys"; +inline constexpr const char *ORCA_PUBLISHED_CONFIG_TAG = "orca_published_config"; + +// Published files are produced with "1". The importer and the GUI loader both gate on this +// exact value, so a "0"/"false"/unknown value is rejected consistently. +bool is_published_3mf_flag(const std::string &value); + inline SaveStrategy operator | (SaveStrategy lhs, SaveStrategy rhs) { using T = std::underlying_type_t ; @@ -277,6 +293,9 @@ extern bool load_bbs_3mf(const char* path, DynamicPrintConfig* config, ConfigSub extern std::string bbs_3mf_get_thumbnail(const char * path); +// Lightweight check: does this 3mf carry the "published" (orca_published == "1") marker? Only reads the 3D/3dmodel.model metadata node +extern bool bbs_3mf_is_published(const std::string &path); + extern bool load_gcode_3mf_from_stream(std::istream & data, DynamicPrintConfig* config, Model* model, PlateDataPtrs* plate_data_list, Semver* file_version); diff --git a/src/libslic3r/GCode/FanMover.hpp b/src/libslic3r/GCode/FanMover.hpp index 3f803fbd23..17addd855c 100644 --- a/src/libslic3r/GCode/FanMover.hpp +++ b/src/libslic3r/GCode/FanMover.hpp @@ -32,7 +32,8 @@ class FanMover private: const std::regex regex_fan_speed; const float nb_seconds_delay; - const bool with_D_option; + // Set from fan_speedup_time at the call site, but nothing here reads it. + [[maybe_unused]] const bool with_D_option; const bool relative_e; const bool only_overhangs; const float kickstart; diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp index cd6c494f33..e4da19cd73 100644 --- a/src/libslic3r/GCode/GCodeProcessor.cpp +++ b/src/libslic3r/GCode/GCodeProcessor.cpp @@ -1468,9 +1468,11 @@ void GCodeProcessor::run_post_process() // Append a per-filament usage block at a filament change. auto handle_filament_change = [&](int filament_id, int cur_line_id, int nozzle_id) { - // skip filament changes emitted inside the machine start / end gcode - if (m_machine_start_gcode_end_line_id == (unsigned int) (-1) && (unsigned int) (cur_line_id) < m_machine_start_gcode_end_line_id || - m_machine_end_gcode_start_line_id != (unsigned int) (-1) && (unsigned int) (cur_line_id) > m_machine_end_gcode_start_line_id) + // Skip filament changes emitted inside the machine start / end gcode. One forward pass assigns + // the tag ids and tests them in the same loop, so inside the start gcode the end tag is unseen + // and the id still holds the sentinel. That is why the first clause tests == and the second !=. + if ((m_machine_start_gcode_end_line_id == (unsigned int) (-1) && (unsigned int) (cur_line_id) < m_machine_start_gcode_end_line_id) || + (m_machine_end_gcode_start_line_id != (unsigned int) (-1) && (unsigned int) (cur_line_id) > m_machine_end_gcode_start_line_id)) return; if (!m_filament_blocks.empty()) m_filament_blocks.back().upper_gcode_id = cur_line_id; @@ -2777,7 +2779,7 @@ bool GCodeProcessor::check_multi_extruder_gcode_valid(const int std::map> gcode_path_pos; // object_id, filament_id, pos for (const GCodeProcessorResult::MoveVertex &move : m_result.moves) { // sometimes, the start line extrude was outside the edge of plate a little, this is allowed, so do not include into the gcode_path_pos - if (move.type == EMoveType::Extrude /* && move.extrusion_role != ExtrusionRole::erFlush || move.type == EMoveType::Travel*/) + if (move.type == EMoveType::Extrude /* && move.extrusion_role != ExtrusionRole::erFlush || move.type == EMoveType::Travel*/) { if (move.extrusion_role == ExtrusionRole::erCustom) { /*if (move.is_arc_move_with_interpolation_points()) { for (int i = 0; i < move.interpolation_points.size(); i++) { @@ -2799,6 +2801,7 @@ bool GCodeProcessor::check_multi_extruder_gcode_valid(const int gcode_path_pos[move.object_label_id][int(move.extruder_id)].max_print_z = std::max(gcode_path_pos[move.object_label_id][int(move.extruder_id)].max_print_z, move.print_z); } + } } bool valid = true; diff --git a/src/libslic3r/GCode/ToolOrderUtils.cpp b/src/libslic3r/GCode/ToolOrderUtils.cpp index 4e2934d967..4a67477d24 100644 --- a/src/libslic3r/GCode/ToolOrderUtils.cpp +++ b/src/libslic3r/GCode/ToolOrderUtils.cpp @@ -910,7 +910,7 @@ namespace Slic3r unsigned int iterations = (1 << all_extruders.size()); unsigned int final_state = iterations - 1; - std::vector>cache(iterations, std::vector(all_extruders.size(), 0x7fffffff)); + std::vector>cache(iterations, std::vector(all_extruders.size(), std::numeric_limits::max())); std::vector>prev(iterations, std::vector(all_extruders.size(), -1)); cache[1][0] = 0.; for (unsigned int state = 0; state < iterations; ++state) { diff --git a/src/libslic3r/GCode/ToolOrdering.cpp b/src/libslic3r/GCode/ToolOrdering.cpp index 0a97e7ac41..c6517c6e65 100644 --- a/src/libslic3r/GCode/ToolOrdering.cpp +++ b/src/libslic3r/GCode/ToolOrdering.cpp @@ -3137,7 +3137,7 @@ void ToolOrdering::assign_custom_gcodes(const Print &print) // Skip all custom G-codes above this layer and skip all extruder switches. for (; custom_gcode_it != custom_gcode_per_print_z.gcodes.rend() && ( (print_z_above > lt.print_z && custom_gcode_it->print_z > 0.5 * (lt.print_z + print_z_above)) - || custom_gcode_it->type == CustomGCode::ToolChange); ++ custom_gcode_it); + || custom_gcode_it->type == CustomGCode::ToolChange); ++ custom_gcode_it) {} print_z_above = lt.print_z; if (custom_gcode_it == custom_gcode_per_print_z.gcodes.rend()) // Custom G-codes were processed. diff --git a/src/libslic3r/GCode/WipeTower.cpp b/src/libslic3r/GCode/WipeTower.cpp index 8aff5f4a3f..589ac14bad 100644 --- a/src/libslic3r/GCode/WipeTower.cpp +++ b/src/libslic3r/GCode/WipeTower.cpp @@ -1630,6 +1630,94 @@ float WipeTower::get_auto_brim_by_height(float max_height) { return 8.f; } +float WipeTower::estimate_brim_real_width(float brim_width, float nozzle_diameter, float first_layer_height, bool type2) +{ + if (brim_width <= 0.f) + return brim_width; + const float spacing = nozzle_diameter * 1.25f - first_layer_height * float(1. - M_PI_4); // Width_To_Nozzle_Ratio + if (spacing <= EPSILON) + return brim_width; + const int loops_num = int((brim_width + spacing / 2.f) / spacing); + return loops_num * spacing + (type2 ? 0.f : spacing / 2.f); +} + +float WipeTower::get_wrapping_detection_depth() +{ + return float(wrapping_wipe_tower_depth); +} + +float WipeTower::nozzle_change_perimeter_width(float nozzle_diameter) +{ + auto it = nozzle_diameter_to_nozzle_change_width.find(nozzle_diameter); + return it != nozzle_diameter_to_nozzle_change_width.end() ? it->second : 2.f * nozzle_diameter * 1.25f; +} + +float WipeTower::estimate_tower_blocks_depth(const std::vector &purges, float width, float layer_height, float nozzle_diameter, float extra_spacing) +{ + if (purges.empty() || layer_height < EPSILON || nozzle_diameter < EPSILON) + return 0.f; + const float pw = nozzle_diameter * 1.25f; // Width_To_Nozzle_Ratio + const float ncpw = nozzle_change_perimeter_width(nozzle_diameter); + const float line_width = width - 2.f * pw; + if (line_width <= EPSILON) + return 0.f; + // Line cross-section as volume_to_length() sees it; the infill gap stretches the perimeter + // width by the configured ratio and nozzle-change lines keep their own width + // (calc_block_infill_gap). + auto line_area = [layer_height](float w) { return layer_height * (w - layer_height * float(1. - M_PI_4)); }; + const float extra_width = (extra_spacing - 1.f) * pw; + const float gap = pw + extra_width; + const float nc_gap = ncpw + extra_width; + // A layer purges into at most (filaments - 1) targets, so a category holding every filament + // never sees its smallest purge (the layer's first filament) in its worst layer. + struct Block { float depth = 0.f; float min_purge = 0.f; size_t filaments = 0; }; + std::map blocks; + for (const PurgeEstimate &purge : purges) { + Block &block = blocks[purge.category]; + const float purge_depth = std::ceil(purge.prime_volume / line_area(pw) / line_width) * gap; + block.min_purge = block.filaments == 0 ? purge_depth : std::min(block.min_purge, purge_depth); + block.depth += purge_depth; + ++block.filaments; + if (purge.filament_change_length > EPSILON) { + // The leaving filament is rammed over the nozzle-change flow, again in whole lines. + const float filament_area = float(M_PI) * purge.filament_diameter * purge.filament_diameter / 4.f; + const float nc_length = purge.filament_change_length * filament_area / line_area(ncpw); + block.depth += std::ceil(nc_length / (width - ncpw - pw)) * nc_gap; + } + } + float depth = pw; // plan_tower_new starts the first block one perimeter width in + for (const auto &[category, block] : blocks) + depth += block.filaments == purges.size() ? block.depth - block.min_purge : block.depth; + return depth; +} + +float WipeTower::rib_footprint_side(float width, float depth, float rib_width, float extra_rib_length, float max_height) +{ + if (width < EPSILON || depth < EPSILON) + return 0.f; + // Ribs run the diagonal; below the height-based minimum they are extended rather than the + // body, then by the extra length, never ending up shorter than the diagonal. + const float diagonal = std::sqrt(width * width + depth * depth); + float rib_length = diagonal; + if (depth + EPSILON < get_limit_depth_by_height(max_height)) + rib_length = std::max(rib_length, get_limit_depth_by_height(max_height) * float(std::sqrt(2.))); + rib_length = std::max(diagonal, rib_length + extra_rib_length); + // Half the extension at each end of the diagonal plus half the rib width, projected onto the axes. + const float rib_w = std::min(rib_width, std::min(width, depth) / 2.f); + const float per_side = ((rib_length - diagonal) / 2.f + rib_w / 2.f) / float(std::sqrt(2.)); + return std::max(width, depth) + 2.f * per_side; +} + +float WipeTower::estimate_rib_tower_bbox_side(const std::vector &purges, float width, float layer_height, float nozzle_diameter, float extra_spacing, float rib_width, float extra_rib_length, float max_height) +{ + if (purges.empty() || width < EPSILON || layer_height < EPSILON || nozzle_diameter < EPSILON) + return 0.f; + const float pw = nozzle_diameter * 1.25f; // Width_To_Nozzle_Ratio + const float square = align_ceil(std::sqrt(estimate_tower_blocks_depth(purges, width, layer_height, nozzle_diameter, extra_spacing) * width), pw); + const float depth = estimate_tower_blocks_depth(purges, square, layer_height, nozzle_diameter, extra_spacing); + return rib_footprint_side(square, depth, rib_width, extra_rib_length, max_height); +} + Vec2f WipeTower::move_box_inside_polygon(const BoundingBox &box, const Polygons &polygons, coord_t offset) { if (polygons.empty()) return Vec2f{0.f, 0.f}; @@ -4883,12 +4971,8 @@ void WipeTower::generate_new(std::vector min_depth_per_height; static float get_limit_depth_by_height(float max_height); static float get_auto_brim_by_height(float max_height); + // Both generators lay the brim in whole loops one line spacing apart, so the printed width + // differs from the configured one. WipeTower reports it with half a spacing of line width + // added, WipeTower2 reports the loops alone; an estimate has to round like the generator + // whose G-code it stands in for. + static float estimate_brim_real_width(float brim_width, float nozzle_diameter, float first_layer_height, bool type2); + // Depth a Type1 tower reserves once nothing but wrapping detection asks for one. + static float get_wrapping_detection_depth(); + // Line width of the nozzle-change purge lines at this nozzle diameter. + static float nozzle_change_perimeter_width(float nozzle_diameter); static TriangleMesh its_make_rib_tower(float width, float depth, float height, float rib_length, float rib_width, bool fillet_wall); static TriangleMesh its_make_rib_brim(const Polygon& brim, float layer_height); static Polygon rib_section(float width, float depth, float rib_length, float rib_width, bool fillet_wall); + // One filament's share of a Type1 tower layer, as plan_tower_new() reserves it. + struct PurgeEstimate + { + float prime_volume = 0.f; // mm3 wiped after changing to this filament + int category = 0; // filament_adhesiveness_category; one purge block per category + float filament_change_length = 0.f; // mm of filament rammed when it leaves its nozzle; 0 when no nozzle change is planned + float filament_diameter = 1.75f; + }; + // Depth of the Type1 purge stack at the given width (also the rectangle-wall depth): each + // purge is whole lines at the block infill gap, one block per adhesiveness category sized by + // its worst layer, stacked behind one perimeter width. + static float estimate_tower_blocks_depth(const std::vector &purges, float width, float layer_height, float nozzle_diameter, float extra_spacing); + // Side of the square bounding a rib-wall tower's first layer, brim excluded: the body plus the + // rib bulge, with the ribs extended to the height-based minimum as both generators do. + static float rib_footprint_side(float width, float depth, float rib_width, float extra_rib_length, float max_height); + // Type1 rib tower: plan_tower_new() squares the tower from the depth at the configured width, + // then re-plans the depth at the squared width. + static float estimate_rib_tower_bbox_side(const std::vector &purges, float width, float layer_height, float nozzle_diameter, float extra_spacing, float rib_width, float extra_rib_length, float max_height); // Translation that brings a footprint inside the printable outline, padded by offset. The prime // tower is validated against the real outline (see layered_print_cleareance_valid), so clamping // against the bounding box alone would leave it off a delta or hexagonal bed. box and polygons diff --git a/src/libslic3r/GCode/WipeTower2.cpp b/src/libslic3r/GCode/WipeTower2.cpp index ee0f9c375a..4e752bd772 100644 --- a/src/libslic3r/GCode/WipeTower2.cpp +++ b/src/libslic3r/GCode/WipeTower2.cpp @@ -2129,6 +2129,23 @@ std::pair WipeTower2::get_wipe_tower_cone_base(double width, dou return std::make_pair(R, support_scale); } +Polygon WipeTower2::cone_base_polygon(double width, double depth, double height, double angle_deg) +{ + Polygon box({Point::new_scale(Vec2d(0., 0.)), Point::new_scale(Vec2d(width, 0.)), + Point::new_scale(Vec2d(width, depth)), Point::new_scale(Vec2d(0., depth))}); + if (angle_deg <= EPSILON || height <= EPSILON || width <= EPSILON || depth <= EPSILON) + return box; + const auto [R, x_scale] = get_wipe_tower_cone_base(width, height, depth, angle_deg); + if (R <= EPSILON) + return box; + const Vec2d center(width / 2., depth / 2.); + Polygon ellipse; + for (double alpha = 0.; alpha < 2. * M_PI; alpha += M_PI / 20.) + ellipse.points.push_back(Point::new_scale(center + R * Vec2d(std::cos(alpha) / x_scale, std::sin(alpha)))); + Polygons u = union_({box, ellipse}); + return u.empty() ? box : u.front(); +} + // Static method to extract wipe_volumes[from][to] from the configuration. // Takes a ConfigBase so the GUI's wipe tower size estimate can pass the plate's // DynamicPrintConfig directly instead of materializing a full PrintConfig per call. diff --git a/src/libslic3r/GCode/WipeTower2.hpp b/src/libslic3r/GCode/WipeTower2.hpp index 5b1a474b5d..232cad1a6b 100644 --- a/src/libslic3r/GCode/WipeTower2.hpp +++ b/src/libslic3r/GCode/WipeTower2.hpp @@ -27,6 +27,10 @@ public: // in WipeTowerIntegration::append_tcr2 does not strip it. static const std::string wait_for_temp_tag() { return ";_WAIT_FOR_TEMP_ON_WIPE_TOWER"; } static std::pair get_wipe_tower_cone_base(double width, double height, double depth, double angle_deg); + // First-layer outline of a cone-wall tower in tower-local (scaled) coordinates: body box + // unioned with the cone's base ellipse — the model first_layer_wipe_tower_corners uses, + // and generate_support_cone_wall stays within it. Brim not included. + static Polygon cone_base_polygon(double width, double depth, double height, double angle_deg); static std::vector> extract_wipe_volumes(const ConfigBase& config); // Estimated total flush volume of a SEMM print with the given number of filaments, // used to reserve wipe tower space before the tower is generated. diff --git a/src/libslic3r/GCode/WipeTowerEstimate.cpp b/src/libslic3r/GCode/WipeTowerEstimate.cpp new file mode 100644 index 0000000000..d8cfe74527 --- /dev/null +++ b/src/libslic3r/GCode/WipeTowerEstimate.cpp @@ -0,0 +1,202 @@ +#include "WipeTowerEstimate.hpp" + +#include "WipeTower.hpp" +#include "WipeTower2.hpp" +#include "../Config.hpp" +#include "../PrintConfig.hpp" +#include "../libslic3r.h" + +#include +#include +#include + +namespace Slic3r { + +// Every caller today declares all these keys, but the signature accepts any ConfigBase: fall +// back to the key's declared default, never to a hand-copied constant. +static const ConfigOption *option_of(const ConfigBase &config, const char *key) +{ + if (const ConfigOption *opt = config.option(key); opt != nullptr) + return opt; + if (const ConfigDef *def = config.def(); def != nullptr) + if (const ConfigOptionDef *opt_def = def->get(key); opt_def != nullptr) + return opt_def->default_value.get(); + return nullptr; +} + +WipeTowerType resolve_wipe_tower_type(const ConfigBase &config) +{ + // printer_model is what the CLI keys its Bambu Lab detection on; the GUI's vendor flag + // agrees for every shipped profile. + if (const auto *model = dynamic_cast(config.option("printer_model")); + model != nullptr && model->value.compare(0, 9, "Bambu Lab") == 0) + return WipeTowerType::Type1; + // By value, not by concrete type: a static PrintConfig holds ConfigOptionEnum, a + // DynamicConfig built from presets holds ConfigOptionEnumGeneric, and both answer getInt(). + const ConfigOption *type = option_of(config, "wipe_tower_type"); + return type != nullptr ? WipeTowerType(type->getInt()) : WipeTowerType::Type2; +} + +Polygon estimate_wipe_tower_first_layer_outline(const ConfigBase &config, WipeTowerType tower_type, double width, double depth, double height) +{ + // Type1 ignores the cone option. The wall type is read by value: a preset-shaped config + // holds it as ConfigOptionEnumGeneric, which a cast to ConfigOptionEnum cannot see. + const ConfigOption *wall_type = option_of(config, "wipe_tower_wall_type"); + const ConfigOption *cone_angle = option_of(config, "wipe_tower_cone_angle"); + const bool cone = tower_type == WipeTowerType::Type2 && wall_type != nullptr && + wall_type->getInt() == int(WipeTowerWallType::wtwCone) && cone_angle != nullptr; + return WipeTower2::cone_base_polygon(width, depth, height, cone ? cone_angle->getFloat() : 0.); +} + +WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, WipeTowerType tower_type, const std::vector &filament_ids, double layer_height, double max_object_height) +{ + WipeTowerFootprint footprint; + footprint.height = max_object_height; + const size_t filaments_cnt = filament_ids.size(); + if (filaments_cnt == 0 || layer_height < EPSILON) + return footprint; + + auto opt_float = [&config](const char *key) { + const ConfigOption *opt = option_of(config, key); + return opt != nullptr ? opt->getFloat() : 0.; + }; + auto opt_bool = [&config](const char *key) { + const ConfigOption *opt = option_of(config, key); + return opt != nullptr && opt->getBool(); + }; + auto opt_enum = [&config](const char *key, int fallback) { + const ConfigOption *opt = option_of(config, key); + return opt != nullptr ? opt->getInt() : fallback; + }; + auto floats_of = [&config](const char *key) { return dynamic_cast(option_of(config, key)); }; + auto max_of = [&floats_of](const char *key, double fallback) { + const auto *opt = floats_of(key); + return (opt != nullptr && !opt->values.empty()) ? *std::max_element(opt->values.begin(), opt->values.end()) : fallback; + }; + auto float_at = [&floats_of](const char *key, unsigned int id, double fallback) { + const auto *opt = floats_of(key); + return (opt != nullptr && !opt->values.empty()) ? opt->get_at(id) : fallback; + }; + auto int_at = [&config](const char *key, unsigned int id, int fallback) { + const auto *opt = dynamic_cast(option_of(config, key)); + return (opt != nullptr && !opt->values.empty()) ? opt->get_at(id) : fallback; + }; + + // Both planners size every layer, so the tower has to fit its thinnest one: the first layer + // when it is printed thinner than the rest. + const double first_layer_height = opt_float("initial_layer_print_height"); + if (first_layer_height > EPSILON) + layer_height = std::min(layer_height, first_layer_height); + + const bool type1 = tower_type == WipeTowerType::Type1; + const double width = opt_float("prime_tower_width"); + const double prime_volume = opt_float("prime_volume"); + // Type1 spaces its purge lines by prime_tower_infill_gap, Type2 by wipe_tower_extra_spacing. + // Type2's extra flow cancels out of the depth: the line length is divided by it and the row + // pitch multiplied by it (WipeTower2::get_wipe_depth). + const double extra_spacing = opt_float(type1 ? "prime_tower_infill_gap" : "wipe_tower_extra_spacing") / 100.; + const double rib_width = opt_float("wipe_tower_rib_width"); + const double extra_rib_length = opt_float("wipe_tower_extra_rib_length"); + const auto *nozzle_opt = floats_of("nozzle_diameter"); + const double nozzle_diameter = (nozzle_opt != nullptr && !nozzle_opt->values.empty()) ? nozzle_opt->values.front() : 0.4; + const bool dual_nozzle = nozzle_opt != nullptr && nozzle_opt->values.size() == 2; + const bool rib_wall = opt_enum("wipe_tower_wall_type", int(WipeTowerWallType::wtwRectangle)) == int(WipeTowerWallType::wtwRib); + const bool smooth_timelapse = opt_enum("timelapse_type", int(TimelapseType::tlTraditional)) == int(TimelapseType::tlSmooth); + const bool wrapping = opt_bool("enable_wrapping_detection"); + // Reasons a tower is printed with no tool change to purge for: the ones that stop + // normalize_fdm_2 clearing enable_prime_tower. Its mixed-filament case is not modelled. + const bool need_wipe_tower = smooth_timelapse || wrapping; + + // A tower printed for one of the reasons above has no tool change to purge for; both + // planners give it the idle depth below and nothing more. + const size_t purge_count = filaments_cnt > 1 ? (dual_nozzle ? filaments_cnt : filaments_cnt - 1) : 0; + + // Type2 purges one volume per tool change. Type1 plans per filament below; here the volume + // only decides whether a tower exists. + double volume = prime_volume * double(purge_count); + if (dual_nozzle) { + // Dual-nozzle printers also purge the filament change length on the tower. + const double length = max_of("filament_change_length", 0.); + const double diameter = max_of("filament_diameter", 1.75); + volume += length * PI * diameter * diameter / 4. * double(filaments_cnt / 2); + } + // Single-extruder multi-material purges the flush matrix instead of the prime volume. + const bool semm_flush = opt_bool("purge_in_prime_tower") && opt_bool("single_extruder_multi_material"); + if (semm_flush) + volume = WipeTower2::estimate_semm_flush_volume(config, filaments_cnt); + + // The Type1 planner wipes each filament's own prime volume after changing to it, in a block + // per adhesiveness category. On a two-nozzle printer the leaving filament is also rammed at + // every nozzle change; the tool order groups filaments by nozzle, so a layer crosses + // (nozzles used - 1) times, charged here to the longest ramming. + std::vector purges; + if (type1 && filaments_cnt > 1) { + const bool saving_mode = opt_enum("prime_volume_mode", int(PrimeVolumeMode::pvmDefault)) == int(PrimeVolumeMode::pvmSaving); + std::set nozzles; + size_t longest_ramming = 0; + for (size_t i = 0; i < filaments_cnt; ++i) { + const unsigned int id = filament_ids[i]; + WipeTower::PurgeEstimate purge; + purge.prime_volume = saving_mode ? 15.f : float(float_at("filament_prime_volume", id, prime_volume)); + purge.category = int_at("filament_adhesiveness_category", id, 0); + purge.filament_diameter = float(float_at("filament_diameter", id, 1.75)); + purges.push_back(purge); + if (dual_nozzle) { + nozzles.insert(int_at("filament_map", id, 1)); + if (float_at("filament_change_length", id, 0.) > float_at("filament_change_length", filament_ids[longest_ramming], 0.)) + longest_ramming = i; + } + } + if (nozzles.size() > 1) + purges[longest_ramming].filament_change_length = float(float_at("filament_change_length", filament_ids[longest_ramming], 0.) * double(nozzles.size() - 1)); + } + + // Both wall types decide this together: over-reserving only wastes bed area, but reporting + // no tower for one that is built collapses the validation hull to a point. + // A tool change is a reason on its own (see the base commit); Type1 already reserves + // per filament, Type2 has only the volume, which can resolve to zero. + const bool has_purge = type1 ? !purges.empty() : volume > EPSILON; + if (!has_purge && filaments_cnt < 2 && !need_wipe_tower) + return footprint; + + const double min_depth = WipeTower::get_limit_depth_by_height(float(max_object_height)); + const float perimeter_width = float(nozzle_diameter) * 1.25f; // Width_To_Nozzle_Ratio + // With nothing to purge, plan_tower_new sizes the tower for wrapping detection or the + // stability minimum; WipeTower2 only knows the latter. + const double idle_depth = (type1 && wrapping && !smooth_timelapse) ? WipeTower::get_wrapping_detection_depth() : min_depth; + if (rib_wall) { + // Both planners square the tower to the purge area and extend the ribs, not the body, + // below the stability minimum. + double side; + if (!purges.empty()) + side = WipeTower::estimate_rib_tower_bbox_side(purges, float(width), float(layer_height), float(nozzle_diameter), float(extra_spacing), float(rib_width), float(extra_rib_length), float(max_object_height)); + else { + const double square = has_purge ? std::sqrt(volume / layer_height * extra_spacing) : idle_depth; + side = WipeTower::rib_footprint_side(float(square), float(square), float(rib_width), float(extra_rib_length), float(max_object_height)); + } + footprint.width = footprint.depth = side; + } else { + double depth; + if (type1) { + // plan_tower_new stretches a short purge stack to the stability minimum behind its + // leading perimeter width. + depth = purges.empty() ? idle_depth : std::max(min_depth + perimeter_width, double(WipeTower::estimate_tower_blocks_depth(purges, float(width), float(layer_height), float(nozzle_diameter), float(extra_spacing)))); + } else { + depth = volume / (layer_height * width); + // The flush volumes already hold the spacing between wipes. + if (!semm_flush) + depth *= extra_spacing; + depth = std::max(min_depth, depth); + } + footprint.width = width; + footprint.depth = depth; + } + + footprint.brim_width = opt_float("prime_tower_brim_width"); + if (footprint.brim_width < 0) + footprint.brim_width = WipeTower::get_auto_brim_by_height(float(max_object_height)); + footprint.brim_width = WipeTower::estimate_brim_real_width(float(footprint.brim_width), float(nozzle_diameter), float(first_layer_height > EPSILON ? first_layer_height : layer_height), !type1); + return footprint; +} + +} // namespace Slic3r diff --git a/src/libslic3r/GCode/WipeTowerEstimate.hpp b/src/libslic3r/GCode/WipeTowerEstimate.hpp new file mode 100644 index 0000000000..387028649b --- /dev/null +++ b/src/libslic3r/GCode/WipeTowerEstimate.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include + +#include "../Polygon.hpp" + +namespace Slic3r { + +class ConfigBase; +enum class WipeTowerType; + +// Pre-slice footprint of the wipe tower, shared by validation (Print), the GUI's placement +// clamp/preview/arrange and the CLI placement. The arithmetic is shared; the inputs below are +// not, so a change to how one caller derives them has to be mirrored in the others. +struct WipeTowerFootprint +{ + double width = 0.; // effective width: equals depth for a rib wall, which squares the tower + double depth = 0.; // 0 when these inputs imply no tower + double height = 0.; // tallest object; drives the stability floor and the auto brim + double brim_width = 0.; // printed width: auto (-1) resolved by height, laid in whole loops +}; + +// Which planner builds the tower: Bambu Lab printers always get Type1, the rest follow +// wipe_tower_type. The rule Print::wipe_tower_type() and the CLI apply, read off the config so +// the GUI and CLI placement can resolve it without a Print. +WipeTowerType resolve_wipe_tower_type(const ConfigBase &config); + +// First-layer outline of an estimated tower in tower-local scaled coordinates, brim excluded: +// the body box, or for a Type2 cone wall the box unioned with the cone's base. The preview, +// the placement margin and validation all take the outline from here so they cannot disagree +// about whether a cone exists. +Polygon estimate_wipe_tower_first_layer_outline(const ConfigBase &config, WipeTowerType tower_type, double width, double depth, double height); + +// filament_ids: 0-based filaments purged on the plate. The config cannot see custom G-code tool +// changes, so ids derived from the model must include them +// (Print::extruders(true)) or a real tower is sized as if it were never built. +// layer_height: thinnest layer the objects are sliced at. The first layer is folded in here. +// +// A raft is deliberately not a reason: normalize_fdm_2 clears enable_prime_tower for a plate +// purging one filament unless smooth timelapse or wrapping detection is on. +WipeTowerFootprint estimate_wipe_tower_footprint(const ConfigBase &config, + WipeTowerType tower_type, + const std::vector &filament_ids, + double layer_height, + double max_object_height); + +} // namespace Slic3r diff --git a/src/libslic3r/Int128.hpp b/src/libslic3r/Int128.hpp index e7238ca745..e55ef64cfb 100644 --- a/src/libslic3r/Int128.hpp +++ b/src/libslic3r/Int128.hpp @@ -57,7 +57,7 @@ #define HAS_INTRINSIC_128_TYPE #endif -#if defined(_MSC_VER) && defined(_WIN64) +#if defined(_MSC_VER) && defined(_M_X64) #include #pragma intrinsic(_mul128) #endif diff --git a/src/libslic3r/Layer.cpp b/src/libslic3r/Layer.cpp index 1d6c2b0703..b7ec08f856 100644 --- a/src/libslic3r/Layer.cpp +++ b/src/libslic3r/Layer.cpp @@ -187,6 +187,12 @@ void Layer::make_perimeters() { BOOST_LOG_TRIVIAL(trace) << "Generating perimeters for layer " << this->id(); + const auto clear_generated_extrusions = [](LayerRegion *layer_region) { + layer_region->perimeters.clear(); + layer_region->fills.clear(); + layer_region->thin_fills.clear(); + }; + // keep track of regions whose perimeters we have already generated std::vector done(m_regions.size(), false); @@ -217,13 +223,11 @@ void Layer::make_perimeters() if (this_region.gradient_volume_id() != other_region.gradient_volume_id()) continue; if (is_perimeter_compatible(*m_object->print(), this_region, other_region)) - { - other_layerm->perimeters.clear(); - other_layerm->fills.clear(); - other_layerm->thin_fills.clear(); - layerms.push_back(other_layerm); - done[it - m_regions.begin()] = true; - } + { + clear_generated_extrusions(other_layerm); + layerms.push_back(other_layerm); + done[it - m_regions.begin()] = true; + } } if (layerms.size() == 1) { // optimization @@ -231,6 +235,10 @@ void Layer::make_perimeters() (*layerm)->make_perimeters((*layerm)->slices, {*layerm}, &(*layerm)->fill_surfaces, &(*layerm)->fill_no_overlap_expolygons); (*layerm)->fill_expolygons = to_expolygons((*layerm)->fill_surfaces.surfaces); } else { + // Orca: Unlike the compatible regions above, the initiating region has not + // been cleared yet and may contain paths from a previous incompatible run. + clear_generated_extrusions(*layerm); + SurfaceCollection new_slices; // Use the region with highest infill rate, as the make_perimeters() function below decides on the gap fill based on the infill existence. LayerRegion *layerm_config = layerms.front(); diff --git a/src/libslic3r/Line.cpp b/src/libslic3r/Line.cpp index c74df3aa59..94453e18f7 100644 --- a/src/libslic3r/Line.cpp +++ b/src/libslic3r/Line.cpp @@ -30,8 +30,8 @@ bool Line::intersection_infinite(const Line &other, Point* point) const return false; double t1 = cross2(v12, v2) / denom; Vec2d result = (a1 + t1 * v1); - if (result.x() > std::numeric_limits::max() || result.x() < std::numeric_limits::lowest() || - result.y() > std::numeric_limits::max() || result.y() < std::numeric_limits::lowest()) { + if (result.x() > double(std::numeric_limits::max()) || result.x() < double(std::numeric_limits::lowest()) || + result.y() > double(std::numeric_limits::max()) || result.y() < double(std::numeric_limits::lowest())) { // Intersection has at least one of the coordinates much bigger (or smaller) than coord_t maximum value (or minimum). // So it can not be stored into the Point without integer overflows. That could mean that input lines are parallel or near parallel. return false; diff --git a/src/libslic3r/LocalesUtils.cpp b/src/libslic3r/LocalesUtils.cpp index 308752cc62..e727b29b09 100644 --- a/src/libslic3r/LocalesUtils.cpp +++ b/src/libslic3r/LocalesUtils.cpp @@ -3,6 +3,8 @@ #ifdef _WIN32 #include #endif +#include +#include #include #include diff --git a/src/libslic3r/MinimumSpanningTree.cpp b/src/libslic3r/MinimumSpanningTree.cpp index ff8fe6e5dd..88555e70ee 100644 --- a/src/libslic3r/MinimumSpanningTree.cpp +++ b/src/libslic3r/MinimumSpanningTree.cpp @@ -60,10 +60,15 @@ auto MinimumSpanningTree::prim(std::vector vertices) const -> AdjacencyGr //This search is O(V) right now, which can be made down to O(log(V)). This reduces the overall time complexity from O(V*V) to O(V*log(E)). //However that requires an implementation of a heap that supports the decreaseKey operation, which is not in the std library. //TODO: Implement this? + // Break equal-distance ties on coordinates: the map is keyed by address, so its + // iteration order (and therefore the first minimum) would otherwise depend on where + // the vertices were allocated. using MapValue = std::pair; const auto closest = std::min_element(smallest_distance.begin(), smallest_distance.end(), [](const MapValue& a, const MapValue& b) { - return a.second < b.second; + if (a.second != b.second) + return a.second < b.second; + return *a.first < *b.first; }); //Add this point to the graph and remove it from the candidates. diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp index 8f1dd8926c..714d45ad9b 100644 --- a/src/libslic3r/Model.cpp +++ b/src/libslic3r/Model.cpp @@ -3598,6 +3598,15 @@ void FacetsAnnotation::shift_states_above(const ModelVolume &mv, EnforcerBlocker this->set(selector); } +void FacetsAnnotation::remap_states(const ModelVolume &mv, const EnforcerBlockerStateMap &state_map) +{ + if (empty()) return; + TriangleSelector selector(mv.mesh()); + selector.deserialize(m_data, false); + selector.remap_triangle_state(state_map); + this->set(selector); +} + void FacetsAnnotation::set_enforcer_block_type_limit(const ModelVolume &mv, EnforcerBlockerType max_type, EnforcerBlockerType to_delete_filament, @@ -3862,6 +3871,43 @@ bool model_has_advanced_features(const Model &model) return false; } +void remap_model_filament_slots(Model &model, const std::map &slot_relocations) +{ + if (slot_relocations.empty()) + return; + + // Paint states and the object/volume "extruder" configs store one-based slot numbers + // (see Sidebar::on_action_add_filament's insertion remap for the same encoding). + std::map one_based_slots; + for (const auto &[from, to] : slot_relocations) + one_based_slots.emplace(from + 1, to + 1); + + EnforcerBlockerStateMap paint_state_map; + for (size_t state = 0; state < paint_state_map.size(); ++state) + paint_state_map[state] = EnforcerBlockerType(state); + for (const auto &[one_based_from, one_based_to] : one_based_slots) { + assert(one_based_from >= 0 && size_t(one_based_from) < paint_state_map.size()); + assert(one_based_to > 0 && size_t(one_based_to) < paint_state_map.size()); + paint_state_map[size_t(one_based_from)] = EnforcerBlockerType(one_based_to); + } + + auto remap_extruder_config = [&one_based_slots](ModelConfig &config) -> bool { + const auto it = config.has("extruder") ? one_based_slots.find(config.extruder()) : one_based_slots.end(); + if (it == one_based_slots.end()) + return false; + config.set("extruder", it->second); + return true; + }; + + for (ModelObject *object : model.objects) { + remap_extruder_config(object->config); + for (ModelVolume *volume : object->volumes) { + remap_extruder_config(volume->config); + volume->mmu_segmentation_facets.remap_states(*volume, paint_state_map); + } + } +} + #ifndef NDEBUG // Verify whether the IDs of Model / ModelObject / ModelVolume / ModelInstance / ModelMaterial are valid and unique. void check_model_ids_validity(const Model &model) diff --git a/src/libslic3r/Model.hpp b/src/libslic3r/Model.hpp index 6834c7a59b..8da1340fa9 100644 --- a/src/libslic3r/Model.hpp +++ b/src/libslic3r/Model.hpp @@ -745,6 +745,10 @@ public: // Shift painted filament indices >= threshold by delta. Used when a physical filament is // inserted ahead of existing slots (mixed-color slots are kept at the end of the list). void shift_states_above(const ModelVolume &mv, EnforcerBlockerType threshold, int delta); + // Relabel painted filament indices according to state_map (old state value -> new state + // value; untouched states keep their identity). Used when published-3MF import relocates + // mixed-filament definitions onto new slot numbers. + void remap_states(const ModelVolume &mv, const EnforcerBlockerStateMap &state_map); indexed_triangle_set get_facets_strict(const ModelVolume& mv, EnforcerBlockerType type) const; bool has_facets(const ModelVolume& mv, EnforcerBlockerType type) const; bool empty() const { return m_data.triangles_to_split.empty(); } @@ -1790,6 +1794,13 @@ bool model_has_multi_part_objects(const Model &model); // If the model has advanced features, then it cannot be processed in simple mode. bool model_has_advanced_features(const Model &model); +// Remap the model's filament-slot references after a published-3MF import relocated +// mixed-filament definitions onto new slot numbers: object/volume "extruder" configs and +// multi-material color-painting states (paint state stores the one-based slot number). +// slot_relocations maps the author's zero-based slot number to its final zero-based slot; +// entries are applied simultaneously (no chained lookups), untouched slots keep everything. +void remap_model_filament_slots(Model &model, const std::map &slot_relocations); + #ifndef NDEBUG // Verify whether the IDs of Model / ModelObject / ModelVolume / ModelInstance / ModelMaterial are valid and unique. void check_model_ids_validity(const Model &model); diff --git a/src/libslic3r/ObjectID.hpp b/src/libslic3r/ObjectID.hpp index f2697b74f5..56042bbaa3 100644 --- a/src/libslic3r/ObjectID.hpp +++ b/src/libslic3r/ObjectID.hpp @@ -170,6 +170,10 @@ public: this->m_check_sum = rhs.check_sum(); this->m_connectors_cnt = rhs.connectors_cnt(); } + // A user-declared copy assignment or destructor deprecates the implicitly generated + // copy constructor, and this class has both, so declare it rather than rely on it. + CutObjectBase(const CutObjectBase &) = default; + CutObjectBase &operator=(const CutObjectBase &other) { this->copy(other); diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index de17a678ce..e974ffd7f8 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -867,6 +867,20 @@ bool is_compatible_with_printer(const PresetWithVendorProfile &preset, const Pre return is_compatible_with_printer(preset, active_printer, &config); } +// ORCA: see the header. The CLI resolves --load-settings into bare DynamicPrintConfigs and has no +// Preset objects to hand; without this it would have to reimplement the policy or build the shells +// at every call site. +bool is_compatible_with_printer(const DynamicPrintConfig &preset_config, Preset::Type preset_type, + const DynamicPrintConfig &printer_config, const std::string &printer_name) +{ + Preset preset(preset_type, std::string("__compat_check")); + preset.config = preset_config; + Preset printer(Preset::TYPE_PRINTER, printer_name); + printer.config = printer_config; + return is_compatible_with_printer(PresetWithVendorProfile(preset, nullptr), + PresetWithVendorProfile(printer, nullptr)); +} + void Preset::set_visible_from_appconfig(const AppConfig &app_config) { //BBS: add config related log @@ -1653,7 +1667,7 @@ std::string PresetCollection::canonical_preset_name(const std::string &name, con void PresetCollection::load_presets( const std::string &dir_path, const std::string &subdir, PresetsConfigSubstitutions& substitutions, ForwardCompatibilitySubstitutionRule substitution_rule, - std::function preset_loaded_fn, const PresetOrigin &load_origin) + std::function preset_loaded_fn, const PresetOrigin &load_origin, bool read_only) { // Don't use boost::filesystem::canonical() on Windows, it is broken in regard to reparse points, // see https://github.com/prusa3d/PrusaSlicer/issues/732 @@ -1662,7 +1676,7 @@ void PresetCollection::load_presets( // Load custom roots first if (fs::exists(dir / "base")) { - load_presets(dir.string(), "base", substitutions, substitution_rule, nullptr, resolved_origin); + load_presets(dir.string(), "base", substitutions, substitution_rule, nullptr, resolved_origin, read_only); } //BBS: add config related logs @@ -1670,7 +1684,8 @@ void PresetCollection::load_presets( //BBS do not parse folder if not exists m_dir_path = dir.string(); if (!fs::exists(dir)) { - fs::create_directory(dir); + if (!read_only) + fs::create_directory(dir); return; } @@ -1720,10 +1735,10 @@ void PresetCollection::load_presets( substitutions.push_back({ preset.name, m_type, PresetConfigSubstitutions::Source::UserFile, preset.file, std::move(config_substitutions) }); if (!reason.empty()) { fs::path file_path(preset.file); - if (fs::exists(file_path)) + if (!read_only && fs::exists(file_path)) fs::remove(file_path); file_path.replace_extension(".info"); - if (fs::exists(file_path)) + if (!read_only && fs::exists(file_path)) fs::remove(file_path); BOOST_LOG_TRIVIAL(error) << boost::format("parse config %1% failed")%preset.file; ++m_errors; @@ -1794,7 +1809,8 @@ void PresetCollection::load_presets( size_t at_pos = name.find('@'); if (at_pos != std::string::npos && at_pos + 1 < name.length()) { compatible_printers->values.push_back(name.substr(at_pos + 1)); - preset.save(nullptr); + if (!read_only) + preset.save(nullptr); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " added compatible_printers for preset: " << name; } } @@ -1812,10 +1828,10 @@ void PresetCollection::load_presets( ++m_errors; BOOST_LOG_TRIVIAL(error) << boost::format("The user-config cannot be loaded: %1%. Reason: %2%")%preset.file %err.what(); fs::path file_path(preset.file); - if (fs::exists(file_path)) + if (!read_only && fs::exists(file_path)) fs::remove(file_path); file_path.replace_extension(".info"); - if (fs::exists(file_path)) + if (!read_only && fs::exists(file_path)) fs::remove(file_path); //throw Slic3r::RuntimeError(std::string("The selected preset cannot be loaded: ") + preset.file + "\n\tReason: " + err.what()); } catch (const std::runtime_error &err) { @@ -1823,10 +1839,10 @@ void PresetCollection::load_presets( BOOST_LOG_TRIVIAL(error) << boost::format("Failed loading the user-config file: %1%. Reason: %2%")%preset.file %err.what(); //throw Slic3r::RuntimeError(std::string("Failed loading the preset file: ") + preset.file + "\n\tReason: " + err.what()); fs::path file_path(preset.file); - if (fs::exists(file_path)) + if (!read_only && fs::exists(file_path)) fs::remove(file_path); file_path.replace_extension(".info"); - if (fs::exists(file_path)) + if (!read_only && fs::exists(file_path)) fs::remove(file_path); } @@ -3060,6 +3076,75 @@ void PresetCollection::save_current_preset(const std::string &new_name, bool det this->get_selected_preset().save(nullptr); } +// A detached standalone preset for the Full Publish receiver: create a user preset holding +// the full resolved filament config (no inheritance, no vendor/alias links), parentless. +// Note: universal printer compatibility is not enforced here - callers apply +// make_publish_universal() to the config before handing it over when they need it. +// Mirrors save_current_preset(detach=true)'s creation branch but does not force-select or +// diff against a parent; the caller decides whether to select it. +// The published entry's filament_id is forwarded so user bases keep their stable +// material grouping (get_filament_presets() groups user bases by filament_id). +// The copy is a project-embedded preset: it lives inside the loaded project only +// (serialized into the saved .3mf, restored by load_project_embedded_presets) and +// never touches the user's library directory; Preset::save() early-returns for +// embedded presets, so persistence is skipped here too. +// Returns the final (uniquified) name; on collision "" -> " (Published)" -> +// " (Published 2)" ... +std::string PresetCollection::add_detached_preset(const std::string &name_base, DynamicPrintConfig config, + const std::string &filament_id) +{ + if (name_base.empty()) + return std::string(); + Preset stored(m_type, name_base); + stored.config = std::move(config); + stored.filament_id = filament_id; + + // Uniquify verbatim; only on collision append " (Published)" then " (Published 2)". + const std::string base_name = name_base; + std::string final_name = base_name; + auto exists = [this](const std::string &candidate) -> bool { + const auto it = this->find_preset_internal(candidate); + return it != m_presets.end() && it->name == candidate; + }; + if (exists(final_name)) { + final_name = base_name + " (Published)"; + for (int i = 2; exists(final_name); ++i) + final_name = base_name + " (Published " + std::to_string(i) + ")"; + } + + // Creation branch of save_current_preset(detach=true), without its selection side + // effects or project-embedded path. + lock(); + const auto it = this->find_preset_internal(final_name); + if (m_presets.begin() + m_idx_selected >= it) + ++m_idx_selected; + Preset &preset = *m_presets.insert(it, stored); + preset.name = final_name; + preset.vendor = nullptr; + preset.alias.clear(); + preset.renamed_from.clear(); + preset.m_excluded_from.clear(); + preset.setting_id.clear(); + preset.inherits().clear(); + preset.version = Semver::parse(SoftFever_VERSION).value_or(Semver()); + preset.is_default = false; + preset.is_system = false; + preset.is_external = false; + preset.bundle_id.clear(); + preset.file = this->path_for_preset(preset); + preset.is_visible = true; + preset.is_project_embedded = true; + if (m_type == Preset::TYPE_PRINT) + preset.config.option("print_settings_id", true)->value = final_name; + else if (m_type == Preset::TYPE_FILAMENT) + preset.config.option("filament_settings_id", true)->values[0] = final_name; + else if (m_type == Preset::TYPE_PRINTER) + preset.config.option("printer_settings_id", true)->value = final_name; + unlock(); + + return final_name; +} + bool PresetCollection::delete_current_preset() { Preset &selected = this->get_selected_preset(); diff --git a/src/libslic3r/Preset.hpp b/src/libslic3r/Preset.hpp index e0728d7fa2..73052678e8 100644 --- a/src/libslic3r/Preset.hpp +++ b/src/libslic3r/Preset.hpp @@ -459,6 +459,11 @@ protected: bool is_compatible_with_print (const PresetWithVendorProfile &preset, const PresetWithVendorProfile &active_print, const PresetWithVendorProfile &active_printer); bool is_compatible_with_printer(const PresetWithVendorProfile &preset, const PresetWithVendorProfile &active_printer, const DynamicPrintConfig *extra_config); bool is_compatible_with_printer(const PresetWithVendorProfile &preset, const PresetWithVendorProfile &active_printer); +// ORCA: same check for callers that hold raw configs rather than Presets (the CLI). Wraps them in +// throwaway Preset shells and delegates, so the compatibility policy -- including the fail-open on a +// malformed compatible_printers_condition -- lives in one place for the GUI and the CLI alike. +bool is_compatible_with_printer(const DynamicPrintConfig &preset_config, Preset::Type preset_type, + const DynamicPrintConfig &printer_config, const std::string &printer_name); // Where a preset is being loaded from. `Auto` lets load_presets() infer from the directory path. struct PresetOrigin { @@ -558,7 +563,7 @@ public: void add_default_preset(const std::vector &keys, const Slic3r::StaticPrintConfig &defaults, const std::string &preset_name); // Load ini files of the particular type from the provided directory path. - void load_presets(const std::string &dir_path, const std::string &subdir, PresetsConfigSubstitutions& substitutions, ForwardCompatibilitySubstitutionRule rule, std::function preset_loaded_fn = nullptr, const PresetOrigin &load_origin = PresetOrigin()); + void load_presets(const std::string &dir_path, const std::string &subdir, PresetsConfigSubstitutions& substitutions, ForwardCompatibilitySubstitutionRule rule, std::function preset_loaded_fn = nullptr, const PresetOrigin &load_origin = PresetOrigin(), bool read_only = false); //BBS: update user presets directory void update_user_presets_directory(const std::string& dir_path, const std::string& type); @@ -631,6 +636,22 @@ public: // All presets are marked as not modified and the new preset is activated. //BBS: add project embedded preset logic void save_current_preset(const std::string &new_name, bool detach = false, bool save_to_project = false, Preset* _curr_preset = nullptr); + // Insert a standalone user preset holding the full resolved config (no inheritance, + // no vendor links): the libslic3r equivalent of "Detach from parent". Takes a + // resolved config, clears parent/vendor/alias metadata, stamps filament_settings_id. + // Unlike save_current_preset it does not force-select or diff against a parent. + // Used by the published-3MF Full Publish path. The optional filament_id seeds the + // preset's stable material grouping (get_filament_presets groups user bases by + // filament_id); the published entry's filament_id is forwarded so the copy keeps + // the author's grouping. + // The copy is a project-embedded preset ("Preset Inside Project"): it lives inside + // the loaded project only, is serialized into the saved .3mf via + // get_current_project_embedded_presets(), and is never written to the user's + // library directory. + // Returns the final (uniquified) name; on collision the suffix rule is: + // "" -> " (Published)" -> " (Published 2)" ... + std::string add_detached_preset(const std::string &name_base, DynamicPrintConfig config, + const std::string &filament_id = std::string()); // Delete the current preset, activate the first visible preset. // returns true if the preset was deleted successfully. diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 29bccf0966..4b8fb03a02 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -7,16 +7,18 @@ #include "PresetCacheFormat.hpp" #include "PrintConfig.hpp" +#include "PublishSettings.hpp" #include "FilamentMixer.hpp" #include "libslic3r.h" #include "I18N.hpp" #include "Utils.hpp" #include "LocalesUtils.hpp" #include "Model.hpp" +#include "TriangleSelector.hpp" #include "libslic3r_version.h" #include -#include +#include #include #include #include @@ -45,6 +47,10 @@ namespace Slic3r { +// Project-level options imported from a loaded 3MF into project_config. s_project_options_published +// below is the reduced subset that crosses over in "published" 3MF mode; keep both in sync. +// s_project_options_published additionally carries wipe_tower_rotation_angle, which normal +// loads do not import (it is not listed here): published-only plate geometry. static std::vector s_project_options { "flush_volumes_vector", "flush_volumes_matrix", @@ -85,6 +91,21 @@ static std::vector s_project_options { "filament_mixed_gradient_per_part" }; +// Project options applied when loading a "published" 3MF project: s_project_options minus the +// filament/purge keys, plus wipe_tower_rotation_angle (plate geometry that only published +// loads import today). A published file must not port the author's filament data +// (colors, colour types, filament/map/AMS slot state, purge/prime/flush volumes, nozzle +// volume types, filament switcher state) to the receiver's project_config, which feeds the +// scene colors, AMS slot colors and purge data. Only the plate/bed geometry keys cross over; +// normal (non-published) 3MF loads keep importing the author's flush data via s_project_options. +// +// KEEP IN SYNC with s_project_options above: when a new project option is added there, decide +// here whether it is plate/bed geometry (add it to this list) or filament/purge/mapping/device +// state (it must NOT be added). curr_bed_type is deliberately NOT in this list: the receiver +// keeps its own bed type when loading a published project. The published-mode project_config +// assertions in tests/libslic3r/test_preset_bundle_loading.cpp guard both directions. +static std::vector s_project_options_published{"wipe_tower_x", "wipe_tower_y", "wipe_tower_rotation_angle"}; + //Orca: add custom as default const char *PresetBundle::ORCA_DEFAULT_BUNDLE = "Custom"; const char *PresetBundle::ORCA_DEFAULT_PRINTER_MODEL = "MyKlipper 0.4 nozzle"; @@ -453,6 +474,158 @@ PresetBundle::PresetBundle() this->project_config.apply_only(FullPrintConfig::defaults(), s_project_options); } +bool PresetBundle::resolve_preset_config(DynamicPrintConfig &config, Preset::Type type, + const std::string &source_file, + ForwardCompatibilitySubstitutionRule compatibility_rule, + std::string &error, bool allow_source_manifest) +{ + if (compatibility_rule == ForwardCompatibilitySubstitutionRule::EnableSystemSilent) + compatibility_rule = ForwardCompatibilitySubstitutionRule::EnableSilent; + else if (compatibility_rule == ForwardCompatibilitySubstitutionRule::EnableSilentDisableSystem) + compatibility_rule = ForwardCompatibilitySubstitutionRule::Disable; + + auto collection_for_type = [](PresetBundle &bundle, Preset::Type preset_type) -> PresetCollection * { + switch (preset_type) { + case Preset::TYPE_PRINT: return &bundle.prints; + case Preset::TYPE_FILAMENT: return &bundle.filaments; + case Preset::TYPE_PRINTER: return &bundle.printers; + default: return nullptr; + } + }; + + 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); + const Preset *resolved = nullptr; + for (const Preset &preset : loaded_collection->get_presets()) { + if (preset.file.empty()) + continue; + + boost::system::error_code ec; + const bool same_file = boost::filesystem::equivalent(source_path, boost::filesystem::path(preset.file), ec); + if (ec || !same_file) + continue; + if (resolved != nullptr) { + error = "Preset identity is ambiguous"; + return nullptr; + } + resolved = &preset; + } + return resolved; + }; + + if (const Preset *resolved = find_loaded(*this)) { + config = resolved->config; + error.clear(); + return true; + } + if (error == "Preset identity is ambiguous") + return false; + if (!allow_source_manifest) { + error = "Preset was not found in the loaded bundle"; + return false; + } + + // A manifest-backed source file can be resolved without requiring the vendor + // to have been copied into data_dir()/system. Find the nearest ancestor whose + // sibling manifest names it, then let the canonical vendor loader flatten the + // complete tree (including nested sub_path entries and library inheritance). + for (boost::filesystem::path vendor_dir = source_path.parent_path(); !vendor_dir.empty(); vendor_dir = vendor_dir.parent_path()) { + const std::string vendor_id = vendor_dir.filename().string(); + if (vendor_id.empty()) + continue; + const boost::filesystem::path root_dir = vendor_dir.parent_path(); + const boost::filesystem::path manifest = root_dir / (vendor_id + ".json"); + if (!boost::filesystem::is_regular_file(manifest)) + continue; + const boost::filesystem::path manifest_relative = source_path.lexically_relative(vendor_dir); + if (manifest_relative.empty() || *manifest_relative.begin() == "..") + 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"; + return false; + } + + const Preset *resolved = find_loaded(source_bundle); + if (resolved == nullptr) { + if (error.empty()) + error = "Source file is not an instantiated preset in its vendor manifest"; + return false; + } + config = resolved->config; + error.clear(); + return true; + } catch (const std::exception &ex) { + error = ex.what(); + return false; + } + } + + error = "Preset was not found in the loaded bundle"; + return false; +} + +bool PresetBundle::resolve_preset_config_type(DynamicPrintConfig &config, Preset::Type &type, + const std::string &source_file, + ForwardCompatibilitySubstitutionRule compatibility_rule, + std::string &error, bool allow_source_manifest) +{ + std::optional> resolved; + for (Preset::Type candidate_type : types_list(ptFFF)) { + DynamicPrintConfig candidate_config(config); + std::string candidate_error; + if (!resolve_preset_config(candidate_config, candidate_type, source_file, compatibility_rule, + candidate_error, allow_source_manifest)) { + if (candidate_error == "Preset identity is ambiguous") { + error = std::move(candidate_error); + return false; + } + continue; + } + if (resolved) { + error = "Preset type is ambiguous"; + return false; + } + resolved.emplace(candidate_type, std::move(candidate_config)); + } + + if (!resolved) { + error = "Preset type could not be resolved"; + return false; + } + + type = resolved->first; + config = std::move(resolved->second); + error.clear(); + return true; +} + PresetBundle::PresetBundle(const PresetBundle &rhs) { *this = rhs; @@ -574,7 +747,8 @@ void PresetBundle::copy_files(const std::string& from) } PresetsConfigSubstitutions PresetBundle::load_presets(AppConfig &config, ForwardCompatibilitySubstitutionRule substitution_rule, - const PresetPreferences& preferred_selection/* = PresetPreferences()*/) + const PresetPreferences& preferred_selection/* = PresetPreferences()*/, + std::string *errors, bool read_only) { // First load the vendor specific system presets. PresetsConfigSubstitutions substitutions; @@ -585,16 +759,20 @@ PresetsConfigSubstitutions PresetBundle::load_presets(AppConfig &config, Forward const auto startup_t0 = std::chrono::steady_clock::now(); //BBS: change system config to json - std::tie(substitutions, errors_cummulative) = this->load_system_presets_from_json(substitution_rule); + std::tie(substitutions, errors_cummulative) = this->load_system_presets_from_json(substitution_rule, !read_only); + if (errors != nullptr) + *errors = errors_cummulative; // BBS load preset from user's folder, load system default if // BBS: change directories by design std::string dir_user_presets = config.get("preset_folder"); if (dir_user_presets.empty()) { - load_user_presets(DEFAULT_USER_FOLDER_NAME, substitution_rule); + load_user_presets(DEFAULT_USER_FOLDER_NAME, substitution_rule, read_only); } else { - load_user_presets(dir_user_presets, substitution_rule); + load_user_presets(dir_user_presets, substitution_rule, read_only); } + if (errors != nullptr && errors->empty() && m_errors != 0) + *errors = "Preset loading reported " + std::to_string(m_errors) + " error(s)"; // Rewrite renamed compatible_printers / compatible_prints references before selection. Skipped // in validation mode so the profile validator (has_errors -> check_preset_references) sees the @@ -1010,18 +1188,26 @@ std::string PresetBundle::get_hotend_model_for_printer_model(std::string model_n return out; } -PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, ForwardCompatibilitySubstitutionRule substitution_rule) +PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, ForwardCompatibilitySubstitutionRule substitution_rule, bool read_only) { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << " entry and user is: " << user; PresetsConfigSubstitutions substitutions; std::string errors_cummulative; fs::path user_folder(data_dir() + "/" + PRESET_USER_DIR); - if (!fs::exists(user_folder)) fs::create_directory(user_folder); + if (!fs::exists(user_folder)) { + if (read_only) + return substitutions; + fs::create_directory(user_folder); + } std::string dir_user_presets = data_dir() + "/" + PRESET_USER_DIR + "/" + user; fs::path folder(user_folder / user); - if (!fs::exists(folder)) fs::create_directory(folder); + if (!fs::exists(folder)) { + if (read_only) + return substitutions; + fs::create_directory(folder); + } bundles.WriteLock(); bundles.m_bundles.clear(); @@ -1049,13 +1235,13 @@ PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, For this->prints.load_presets(bundle_dir, PRESET_PRINT_NAME, substitutions, substitution_rule, [&](Preset& preset) { metadata.print_presets.push_back(preset.name); - }, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id)); + }, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id), read_only); this->filaments.load_presets(bundle_dir, PRESET_FILAMENT_NAME, substitutions, substitution_rule, [&](Preset& preset) { metadata.filament_presets.push_back(preset.name); - }, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id)); + }, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id), read_only); this->printers.load_presets(bundle_dir, PRESET_PRINTER_NAME, substitutions, substitution_rule, [&](Preset& preset) { metadata.printer_presets.push_back(preset.name); - }, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id)); + }, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id), read_only); metadata.bundle_type = BundleType::Local; metadata.path = metadata_file.string(); @@ -1085,13 +1271,13 @@ PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, For this->prints.load_presets(bundle_dir, PRESET_PRINT_NAME, substitutions, substitution_rule, [&](Preset& preset) { metadata.print_presets.push_back(preset.name); - }, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id)); + }, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id), read_only); this->filaments.load_presets(bundle_dir, PRESET_FILAMENT_NAME, substitutions, substitution_rule, [&](Preset& preset) { metadata.filament_presets.push_back(preset.name); - }, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id)); + }, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id), read_only); this->printers.load_presets(bundle_dir, PRESET_PRINTER_NAME, substitutions, substitution_rule, [&](Preset& preset) { metadata.printer_presets.push_back(preset.name); - }, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id)); + }, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id), read_only); metadata.bundle_type = BundleType::Subscribed; metadata.path = metadata_file.string(); @@ -1110,17 +1296,20 @@ PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, For const auto json_t0 = std::chrono::steady_clock::now(); try { std::string sel = prints.get_selected_preset().name; - this->prints.load_presets(dir_user_presets, PRESET_PRINT_NAME, substitutions, substitution_rule); + this->prints.load_presets(dir_user_presets, PRESET_PRINT_NAME, substitutions, substitution_rule, + nullptr, PresetOrigin(), read_only); prints.select_preset_by_name(sel, false); } catch (const std::runtime_error& err) { errors_cummulative += err.what(); } try { std::string sel = filaments.get_selected_preset().name; - this->filaments.load_presets(dir_user_presets, PRESET_FILAMENT_NAME, substitutions, substitution_rule); + this->filaments.load_presets(dir_user_presets, PRESET_FILAMENT_NAME, substitutions, substitution_rule, + nullptr, PresetOrigin(), read_only); filaments.select_preset_by_name(sel, false); } catch (const std::runtime_error& err) { errors_cummulative += err.what(); } try { std::string sel = printers.get_selected_preset().name; - this->printers.load_presets(dir_user_presets, PRESET_PRINTER_NAME, substitutions, substitution_rule); + this->printers.load_presets(dir_user_presets, PRESET_PRINTER_NAME, substitutions, substitution_rule, + nullptr, PresetOrigin(), read_only); printers.select_preset_by_name(sel, false); } catch (const std::runtime_error& err) { errors_cummulative += err.what(); } if (!errors_cummulative.empty()) throw Slic3r::RuntimeError(errors_cummulative); @@ -1446,6 +1635,12 @@ PresetsConfigSubstitutions PresetBundle::import_presets(std::vector metadata.id = to_string(uuid); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " bundle_id was empty, so generating a UUID: " << metadata.id; } + if (has_bundle_structure && !is_path_within_root(metadata.id, user_folder / user_id / PRESET_LOCAL_DIR)) { + BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " bundle id escapes the bundle directory, not importing: " << metadata.id; + fclose(zipFile); + fs::remove_all(temp_folder, ec); + continue; + } // Build bundle directory path based on whether bundle_structure.json was present fs::path bundle_base_dir; @@ -1468,11 +1663,15 @@ PresetsConfigSubstitutions PresetBundle::import_presets(std::vector if (status) { std::string file_name = file_stat.m_filename; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " From zip file: " << file << ". Read file name: " << file_stat.m_filename; - size_t index = file_name.find_last_of('/'); + size_t index = file_name.find_last_of("/\\"); if (std::string::npos != index) { file_name = file_name.substr(index + 1); } if (BUNDLE_STRUCTURE_JSON_NAME == file_name) continue; + if (!is_path_within_root(file_name, temp_folder)) { + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " zip entry escapes the temp directory, skipping: " << file_stat.m_filename; + continue; + } // create target file path std::string target_file_path = boost::filesystem::path(temp_folder / file_name).make_preferred().string(); @@ -1561,6 +1760,10 @@ bool PresetBundle::import_json_presets(PresetsConfigSubstitutions & s BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Preset type is unknown, not loading: " << name; return false; } + if (!is_path_within_root(name, fs::path(collection->m_dir_path))) { + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Preset name escapes the preset directory, not loading: " << name; + return false; + } const PresetOrigin load_origin = detect_origin_from_path(boost::filesystem::path(bundle_dir)); const std::string preset_name = get_preset_canonical_name(name, load_origin); @@ -2266,7 +2469,8 @@ void PresetBundle::clear_printer_hold_aliases() } //BBS: add json related logic, load system presets from json -std::pair PresetBundle::load_system_presets_from_json(ForwardCompatibilitySubstitutionRule compatibility_rule) +std::pair PresetBundle::load_system_presets_from_json( + ForwardCompatibilitySubstitutionRule compatibility_rule, bool allow_cache) { //BBS: add config related logs BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(" enter, compatibility_rule %1%")%compatibility_rule; @@ -2288,7 +2492,7 @@ std::pair PresetBundle::load_system_pre // The vendors below are loaded whole and against each other — the filament // library first, then every other vendor with it as the base — so each parse // is complete enough to be worth caching. - m_generate_vendor_caches = m_generate_vendor_caches || ! validation_mode; + m_generate_vendor_caches = allow_cache && (m_generate_vendor_caches || !validation_mode); PresetsConfigSubstitutions substitutions; std::string errors_cummulative; @@ -2318,7 +2522,8 @@ std::pair PresetBundle::load_system_pre // state into this load. this->clear_printer_hold_aliases(); this->m_errors = 0; - append(substitutions, this->load_vendor_configs_from_json(dir.string(), orca_lib_vendor, PresetBundle::LoadSystem, compatibility_rule).first); + append(substitutions, this->load_vendor_configs_from_json( + dir.string(), orca_lib_vendor, PresetBundle::LoadSystem, compatibility_rule, nullptr, allow_cache).first); first = false; } catch (const std::runtime_error &err) { if (validation_mode) @@ -2343,7 +2548,7 @@ std::pair PresetBundle::load_system_pre bundle->set_generate_vendor_caches(m_generate_vendor_caches); try { auto result = bundle->load_vendor_configs_from_json( - dir.string(), other_vendors[i], PresetBundle::LoadSystem, compatibility_rule, this); + dir.string(), other_vendors[i], PresetBundle::LoadSystem, compatibility_rule, this, allow_cache); parallel_substitutions[i] = std::move(result.first); parallel_bundles[i] = std::move(bundle); } catch (const std::runtime_error &err) { @@ -4756,6 +4961,142 @@ static std::map> filament_preset {"Bambu ASA @BBL H2D 0.6 nozzle", "Bambu ASA @BBL H2D 0.8 nozzle"}}} }; +// Relocate the per-slot cells of one mixed-filament project vector inside the imported +// config, applying every authored-slot -> destination move at once. Each move reads its +// source cell from a frozen snapshot of the config's current cells, so relocations never +// cross-contaminate when an earlier destination overlaps a later source (adjacent published +// tail mixes relocate onto consecutive slots). Cells the snapshot lacks degrade to +// empty/false defaults - the missing-data handling in the material pass reports them +// downstream. Left-behind source cells stay as-is: nothing else consumes the imported config +// at those indices in published mode. +static void apply_mixed_config_relocations(DynamicPrintConfig& config, + const std::string& key, + const std::vector>& moves) +{ + ConfigOption* opt = config.optptr(key); + if (opt == nullptr || moves.empty()) + return; + std::unique_ptr snapshot(opt->clone()); + switch (opt->type()) { + case coBools: { + auto* live = static_cast(opt); + const auto* frozen = static_cast(snapshot.get()); + for (const auto& [from, to] : moves) { + const unsigned char cell = from < frozen->values.size() ? frozen->values[from] : 0; + if (live->values.size() <= to) + live->values.resize(to + 1, 0); + live->values[to] = cell; + } + break; + } + case coStrings: { + auto* live = static_cast(opt); + const auto* frozen = static_cast(snapshot.get()); + for (const auto& [from, to] : moves) { + const std::string cell = from < frozen->values.size() ? frozen->values[from] : std::string(); + if (live->values.size() <= to) + live->values.resize(to + 1, std::string{}); + live->values[to] = cell; + } + break; + } + default: break; + } +} + +// Relocate the per-slot cells of the receiver's OWN mixed-filament definitions (the ones that +// pre-existed in project_config) onto fresh tail slots, clearing each vacated source cell so an +// incoming published real filament can claim it. Unlike apply_mixed_config_relocations - which +// moves the incoming file's config and leaves sources alone - a displaced receiver mix must not +// keep its mixed flag in the physical region: the source slot becomes a physical slot, so its +// mixed flag and definition are reset, while its swatch colour and mapping travel with the +// definition to the tail slot. Reads come from a frozen snapshot so an earlier move's +// destination never overwrites a later move's still-unread source (sources sit in the physical +// region and destinations past it, so they cannot overlap, but the snapshot keeps the helper +// safe for any future reordering). +static void apply_receiver_mix_relocations(DynamicPrintConfig& config, + std::vector>& ams_multi_color_filment, + const std::vector>& moves) +{ + if (moves.empty()) + return; + + auto move_bools = [&](const char* key, bool clear_source) { + ConfigOption* opt = config.optptr(key); + if (opt == nullptr) + return; + auto* live = static_cast(opt); + std::unique_ptr snapshot(opt->clone()); + const auto* frozen = static_cast(snapshot.get()); + for (const auto& [from, to] : moves) { + const bool cell = from < frozen->values.size() ? frozen->values[from] : false; + if (live->values.size() <= to) + live->values.resize(to + 1, false); + live->values[to] = cell; + if (clear_source && from < live->values.size()) + live->values[from] = false; + } + }; + auto move_strings = [&](const char* key, bool clear_source) { + ConfigOption* opt = config.optptr(key); + if (opt == nullptr) + return; + auto* live = static_cast(opt); + std::unique_ptr snapshot(opt->clone()); + const auto* frozen = static_cast(snapshot.get()); + for (const auto& [from, to] : moves) { + const std::string cell = from < frozen->values.size() ? frozen->values[from] : std::string(); + if (live->values.size() <= to) + live->values.resize(to + 1, std::string{}); + live->values[to] = cell; + if (clear_source && from < live->values.size()) + live->values[from] = std::string{}; + } + }; + auto move_ints = [&](const char* key) { + ConfigOption* opt = config.optptr(key); + if (opt == nullptr) + return; + auto* live = static_cast(opt); + std::unique_ptr snapshot(opt->clone()); + const auto* frozen = static_cast(snapshot.get()); + for (const auto& [from, to] : moves) { + const int cell = from < frozen->values.size() ? frozen->values[from] : 0; + if (live->values.size() <= to) + live->values.resize(to + 1, 0); + live->values[to] = cell; + } + }; + + // Mixed-definition cells: move to the tail and clear the source - the vacated physical slot + // no longer holds a mix. + move_bools("filament_is_mixed", true); + move_strings("filament_mixed_components", true); + move_strings("filament_mixed_sublayer_ratios", true); + move_bools("filament_mixed_gradient", true); + move_strings("filament_mixed_gradient_range", true); + move_strings("filament_mixed_gradient_curve", true); + move_bools("filament_mixed_gradient_per_part", true); + // Swatch colour and mapping travel with the definition; the source colour is left for the + // incoming real's publish_color (or the slot's resolved preset) to fill in. + move_strings("filament_colour", false); + move_strings("filament_multi_colour", false); + move_strings("filament_colour_type", false); + move_ints("filament_map"); + move_ints("filament_nozzle_map"); + move_ints("filament_volume_map"); + { + const std::vector> frozen = ams_multi_color_filment; + for (const auto& [from, to] : moves) { + const std::vector cell = from < frozen.size() ? frozen[from] : std::vector(); + if (ams_multi_color_filment.size() <= to) + ams_multi_color_filment.resize(to + 1, std::vector{}); + ams_multi_color_filment[to] = cell; + } + } +} + + //convert the old filament preset to new one after split static void convert_filament_preset_name(std::string& machine_name, std::string& filament_name) { @@ -4772,10 +5113,14 @@ static void convert_filament_preset_name(std::string& machine_name, std::string& } // Load a config file from a boost property_tree. This is a private method called from load_config_file. // is_external == false on if called from ConfigWizard -void PresetBundle::load_config_file_config(const std::string &name_or_path, bool is_external, DynamicPrintConfig &&config, Semver file_version, bool selected) +void PresetBundle::load_config_file_config(const std::string &name_or_path, bool is_external, DynamicPrintConfig &&config, Semver file_version, bool selected, PublishedConfig *published_config) { PrinterTechnology printer_technology = Preset::printer_technology(config); + // A "published" 3MF project keeps the user's currently-selected presets and overlays only + // the author-selected published keys onto the edited presets. + const bool is_published = published_config != nullptr && published_config->published; + auto clear_compatible_printers = [](DynamicPrintConfig& config){ ConfigOption *opt_compatible = config.optptr("compatible_printers"); if (opt_compatible != nullptr) { @@ -4916,127 +5261,60 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool switch (Preset::printer_technology(config)) { case ptFFF: { - //BBS: add different settings logic - BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": load print preset from print_settings_id"); - std::vector print_different_keys_vector; - std::string print_different_settings = different_values[0]; - Slic3r::unescape_strings_cstyle(print_different_settings, print_different_keys_vector); - std::set print_different_keys_set(print_different_keys_vector.begin(), print_different_keys_vector.end()); - //if (!has_different_settings_to_system) { - // print_different_keys_set.clear(); - //} - //else - print_different_keys_set.insert(ignore_settings_list.begin(), ignore_settings_list.end()); - if (!print_compatible_printers.empty()) { - ConfigOptionStrings* compatible_printers = config.option("compatible_printers", true); - compatible_printers->values = print_compatible_printers; - } - - load_preset(this->prints, 0, "print_settings_id", print_different_keys_set, std::string()); - - //clear compatible printers - clear_compatible_printers(config); - - std::vector printer_different_keys_vector; - std::string printer_different_settings = different_values[num_filaments + 1]; - Slic3r::unescape_strings_cstyle(printer_different_settings, printer_different_keys_vector); - std::set printer_different_keys_set(printer_different_keys_vector.begin(), printer_different_keys_vector.end()); - //if (!has_different_settings_to_system) { - // printer_different_keys_set.clear(); - //} - //else - printer_different_keys_set.insert(ignore_settings_list.begin(), ignore_settings_list.end()); - //BBS: add config related logs - BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": load printer preset from printer_settings_id"); - load_preset(this->printers, num_filaments + 1, "printer_settings_id", printer_different_keys_set, std::string()); - - // 3) Now load the filaments. If there are multiple filament presets, split them and load them. - auto old_filament_profile_names = config.option("filament_settings_id", true); - old_filament_profile_names->values.resize(num_filaments, std::string()); - - auto old_machine_profile_name = config.option("printer_settings_id", true); - - if (num_filaments <= 1) { - // Split the "compatible_printers_condition" and "inherits" values from the cummulative vectors to separate filament presets. - inherits = inherits_values[1]; - compatible_printers_condition = compatible_printers_condition_values[1]; - compatible_prints_condition = compatible_prints_condition_values.front(); - Preset *loaded = nullptr; - + // A "published" 3MF project keeps the user's currently-selected presets, so the + // print / printer / filament presets are NOT loaded from the file. Only the + // project config values and the published keys are applied below. + if (!is_published) { //BBS: add different settings logic - std::vector filament_different_keys_vector; - std::string filament_different_settings = different_values[1]; - Slic3r::unescape_strings_cstyle(filament_different_settings, filament_different_keys_vector); - std::set filament_different_keys_set(filament_different_keys_vector.begin(), filament_different_keys_vector.end()); + BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": load print preset from print_settings_id"); + std::vector print_different_keys_vector; + std::string print_different_settings = different_values[0]; + Slic3r::unescape_strings_cstyle(print_different_settings, print_different_keys_vector); + std::set print_different_keys_set(print_different_keys_vector.begin(), print_different_keys_vector.end()); //if (!has_different_settings_to_system) { - // filament_different_keys_set.clear(); + // print_different_keys_set.clear(); //} //else - filament_different_keys_set.insert(ignore_settings_list.begin(), ignore_settings_list.end()); + print_different_keys_set.insert(ignore_settings_list.begin(), ignore_settings_list.end()); + if (!print_compatible_printers.empty()) { + ConfigOptionStrings* compatible_printers = config.option("compatible_printers", true); + compatible_printers->values = print_compatible_printers; + } - std::string filament_id = filament_ids[0]; + load_preset(this->prints, 0, "print_settings_id", print_different_keys_set, std::string()); + + //clear compatible printers + clear_compatible_printers(config); + + std::vector printer_different_keys_vector; + std::string printer_different_settings = different_values[num_filaments + 1]; + Slic3r::unescape_strings_cstyle(printer_different_settings, printer_different_keys_vector); + std::set printer_different_keys_set(printer_different_keys_vector.begin(), printer_different_keys_vector.end()); + //if (!has_different_settings_to_system) { + // printer_different_keys_set.clear(); + //} + //else + printer_different_keys_set.insert(ignore_settings_list.begin(), ignore_settings_list.end()); //BBS: add config related logs - BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": load single filament preset from filament_settings_id"); - if (is_external) { - if (inherits.empty()) - convert_filament_preset_name(old_machine_profile_name->value, old_filament_profile_names->values.front()); - else - convert_filament_preset_name(old_machine_profile_name->value, inherits); - loaded = this->filaments.load_external_preset(name_or_path, name, old_filament_profile_names->values.front(), config, filament_different_keys_set, PresetCollection::LoadAndSelect::Always, file_version, filament_id).first; - } - else { - // called from Config Wizard. - loaded= &this->filaments.load_preset(this->filaments.path_from_name(name, inherits.empty()), name, config, true, file_version); - loaded->save(nullptr); - } - this->filament_presets.clear(); - this->filament_presets.emplace_back(loaded->name); - } else { - assert(is_external); - // Split the filament presets, load each of them separately. - std::vector configs(num_filaments, this->filaments.default_preset().config); - // loop through options and scatter them into configs. - for (const t_config_option_key &key : this->filaments.default_preset().config.keys()) { - ConfigOption *other_opt = config.option(key); - if (other_opt == nullptr) - continue; - if (other_opt->is_scalar()) { - for (size_t i = 0; i < configs.size(); ++ i) - configs[i].option(key, false)->set(other_opt); - } - else if (key != "compatible_printers" && key != "compatible_prints") { - for (size_t i = 0; i < configs.size(); ++i) { - if (process_multi_extruder && (filament_options_with_variant.find(key) != filament_options_with_variant.end())) { - ConfigOptionVectorBase* other_opt_vec = static_cast(other_opt); - if (other_opt_vec->size() != extruder_variant_count) { - other_opt_vec->resize(extruder_variant_count); - } - size_t next_index = (i < (configs.size() - 1)) ? filament_variant_index[i + 1] : extruder_variant_count; - static_cast(configs[i].option(key, false))->set(other_opt, filament_variant_index[i], next_index - filament_variant_index[i]); - } - else - static_cast(configs[i].option(key, false))->set_at(other_opt, 0, i); - } - } - } - // Load the configs into this->filaments and make them active. - this->filament_presets = std::vector(configs.size()); - // To avoid incorrect selection of the first filament preset (means a value of Preset->m_idx_selected) - // in a case when next added preset take a place of previosly selected preset, - // we should add presets from last to first - bool any_modified = false; - //BBS: add config related logs - BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": load multiple filament preset from filament_settings_id"); - for (int i = (int)configs.size()-1; i >= 0; i--) { - DynamicPrintConfig &cfg = configs[i]; - // Split the "compatible_printers_condition" and "inherits" from the cummulative vectors to separate filament presets. - cfg.opt_string("compatible_printers_condition", true) = compatible_printers_condition_values[i + 1]; - cfg.opt_string("compatible_prints_condition", true) = compatible_prints_condition_values[i]; - cfg.opt_string("inherits", true) = inherits_values[i + 1]; + BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": load printer preset from printer_settings_id"); + load_preset(this->printers, num_filaments + 1, "printer_settings_id", printer_different_keys_set, std::string()); + + // 3) Now load the filaments. If there are multiple filament presets, split them and load them. + auto old_filament_profile_names = config.option("filament_settings_id", true); + old_filament_profile_names->values.resize(num_filaments, std::string()); + + auto old_machine_profile_name = config.option("printer_settings_id", true); + + if (num_filaments <= 1) { + // Split the "compatible_printers_condition" and "inherits" values from the cummulative vectors to separate filament presets. + inherits = inherits_values[1]; + compatible_printers_condition = compatible_printers_condition_values[1]; + compatible_prints_condition = compatible_prints_condition_values.front(); + Preset *loaded = nullptr; //BBS: add different settings logic std::vector filament_different_keys_vector; - std::string filament_different_settings = different_values[i+1]; + std::string filament_different_settings = different_values[1]; Slic3r::unescape_strings_cstyle(filament_different_settings, filament_different_keys_vector); std::set filament_different_keys_set(filament_different_keys_vector.begin(), filament_different_keys_vector.end()); //if (!has_different_settings_to_system) { @@ -5045,32 +5323,105 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool //else filament_different_keys_set.insert(ignore_settings_list.begin(), ignore_settings_list.end()); - std::string filament_id = filament_ids[i]; + std::string filament_id = filament_ids[0]; + //BBS: add config related logs + BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": load single filament preset from filament_settings_id"); + if (is_external) { + if (inherits.empty()) + convert_filament_preset_name(old_machine_profile_name->value, old_filament_profile_names->values.front()); + else + convert_filament_preset_name(old_machine_profile_name->value, inherits); + loaded = this->filaments.load_external_preset(name_or_path, name, old_filament_profile_names->values.front(), config, filament_different_keys_set, PresetCollection::LoadAndSelect::Always, file_version, filament_id).first; + } + else { + // called from Config Wizard. + loaded= &this->filaments.load_preset(this->filaments.path_from_name(name, inherits.empty()), name, config, true, file_version); + loaded->save(nullptr); + } + this->filament_presets.clear(); + this->filament_presets.emplace_back(loaded->name); + } else { + assert(is_external); + // Split the filament presets, load each of them separately. + std::vector configs(num_filaments, this->filaments.default_preset().config); + // loop through options and scatter them into configs. + for (const t_config_option_key &key : this->filaments.default_preset().config.keys()) { + ConfigOption *other_opt = config.option(key); + if (other_opt == nullptr) + continue; + if (other_opt->is_scalar()) { + for (size_t i = 0; i < configs.size(); ++ i) + configs[i].option(key, false)->set(other_opt); + } + else if (key != "compatible_printers" && key != "compatible_prints") { + for (size_t i = 0; i < configs.size(); ++i) { + if (process_multi_extruder && (filament_options_with_variant.find(key) != filament_options_with_variant.end())) { + ConfigOptionVectorBase* other_opt_vec = static_cast(other_opt); + if (other_opt_vec->size() != extruder_variant_count) { + other_opt_vec->resize(extruder_variant_count); + } + size_t next_index = (i < (configs.size() - 1)) ? filament_variant_index[i + 1] : extruder_variant_count; + static_cast(configs[i].option(key, false))->set(other_opt, filament_variant_index[i], next_index - filament_variant_index[i]); + } + else + static_cast(configs[i].option(key, false))->set_at(other_opt, 0, i); + } + } + } + // Load the configs into this->filaments and make them active. + this->filament_presets = std::vector(configs.size()); + // To avoid incorrect selection of the first filament preset (means a value of Preset->m_idx_selected) + // in a case when next added preset take a place of previosly selected preset, + // we should add presets from last to first + bool any_modified = false; + //BBS: add config related logs + BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": load multiple filament preset from filament_settings_id"); + for (int i = (int)configs.size()-1; i >= 0; i--) { + DynamicPrintConfig &cfg = configs[i]; + // Split the "compatible_printers_condition" and "inherits" from the cummulative vectors to separate filament presets. + cfg.opt_string("compatible_printers_condition", true) = compatible_printers_condition_values[i + 1]; + cfg.opt_string("compatible_prints_condition", true) = compatible_prints_condition_values[i]; + cfg.opt_string("inherits", true) = inherits_values[i + 1]; - // Load all filament presets, but only select the first one in the preset dialog. - std::string& filament_inherit = cfg.opt_string("inherits", true); - if (filament_inherit.empty() && (i < int(old_filament_profile_names->values.size()))) - convert_filament_preset_name(old_machine_profile_name->value, old_filament_profile_names->values[i]); - else - convert_filament_preset_name(old_machine_profile_name->value, filament_inherit); - auto [loaded, modified] = this->filaments.load_external_preset(name_or_path, name, - (i < int(old_filament_profile_names->values.size())) ? old_filament_profile_names->values[i] : "", - std::move(cfg), - filament_different_keys_set, - i == 0 ? - PresetCollection::LoadAndSelect::Always : - any_modified ? - PresetCollection::LoadAndSelect::Never : - PresetCollection::LoadAndSelect::OnlyIfModified, - file_version, - filament_id); - any_modified |= modified; - this->filament_presets[i] = loaded->name; + //BBS: add different settings logic + std::vector filament_different_keys_vector; + std::string filament_different_settings = different_values[i+1]; + Slic3r::unescape_strings_cstyle(filament_different_settings, filament_different_keys_vector); + std::set filament_different_keys_set(filament_different_keys_vector.begin(), filament_different_keys_vector.end()); + //if (!has_different_settings_to_system) { + // filament_different_keys_set.clear(); + //} + //else + filament_different_keys_set.insert(ignore_settings_list.begin(), ignore_settings_list.end()); + + std::string filament_id = filament_ids[i]; + + // Load all filament presets, but only select the first one in the preset dialog. + std::string& filament_inherit = cfg.opt_string("inherits", true); + if (filament_inherit.empty() && (i < int(old_filament_profile_names->values.size()))) + convert_filament_preset_name(old_machine_profile_name->value, old_filament_profile_names->values[i]); + else + convert_filament_preset_name(old_machine_profile_name->value, filament_inherit); + auto [loaded, modified] = this->filaments.load_external_preset(name_or_path, name, + (i < int(old_filament_profile_names->values.size())) ? old_filament_profile_names->values[i] : "", + std::move(cfg), + filament_different_keys_set, + i == 0 ? + PresetCollection::LoadAndSelect::Always : + any_modified ? + PresetCollection::LoadAndSelect::Never : + PresetCollection::LoadAndSelect::OnlyIfModified, + file_version, + filament_id); + any_modified |= modified; + this->filament_presets[i] = loaded->name; + } } - } + } // !is_published - // 4) Load the project config values (the per extruder wipe matrix etc). - this->project_config.apply_only(config, s_project_options); + // Load the project config values. In published mode only the plate/bed geometry keys + // cross over (the receiver must not inherit the author's filament/purge data). + this->project_config.apply_only(config, is_published ? s_project_options_published : s_project_options); break; } @@ -5089,18 +5440,1143 @@ void PresetBundle::load_config_file_config(const std::string &name_or_path, bool this->update_compatible(PresetSelectCompatibleType::Never); this->update_multi_material_filament_presets(); + // A "published" 3MF project overlays the author-selected published keys onto the user's + // currently-selected (edited) process preset. Keys that cannot be applied are collected for + // notification; filament-class keys in published_keys (which only the material pass knows + // how to apply) fall through into skipped_keys. + if (is_published) { + std::vector skipped_keys; + std::set applied_keys; + // Only re-select the edited filament preset when the material overlay changed + // something: re-selecting unconditionally would discard the user's unsaved in-memory + // filament edits when the published file touches nothing. + bool material_applied = false; + // Structural keys are never applied (they would rewrite the user's preset + // inheritance/structure). Defense-in-depth: a hand-crafted 3MF could list them despite + // the dialog, so skip them here too. + const std::set& structural_keys = publish_structural_keys(); + // The printer overlay is restricted to the publishable retraction/z-hop allowlist; + // printer-class keys outside it are contract-excluded (never applied, never reported). + const std::set& printer_allowlist = publishable_printer_keys(); + const std::vector& printer_options = Preset::printer_options(); + const std::set printer_option_set(printer_options.begin(), printer_options.end()); + std::set contract_excluded_keys; + auto apply_published = [&](DynamicPrintConfig& target, const std::set* allowlist) { + // Single-extruder receivers collapse a base key's per-extruder "#N" variants onto + // their single slot: only the first serialized variant of a base key is applied + // (the author's "left or right" whichever came first), the rest are reported as + // skipped. Per-target, so the process and printer passes each track their own bases. + std::set collapsed_bases; + for (const std::string& key : published_config->published_keys) { + if (applied_keys.count(key) != 0) + continue; // already applied + // A '#' suffix denotes a variant key; resolve the base key. + const std::string base_key = publish_base_key(key); + // Structural keys are never applied (not "skipped due to mismatch"), so bail + // out before the applied/skipped bookkeeping. + if (structural_keys.count(base_key) != 0) + continue; + if (allowlist != nullptr && printer_option_set.count(base_key) != 0 && allowlist->count(base_key) == 0) { + // Printer-class key outside the publishable allowlist: contract-excluded. + contract_excluded_keys.insert(base_key); + continue; + } + const ConfigOption* src_opt = config.option(base_key); + if (src_opt == nullptr) + continue; // key not present in the loaded config; record later + if (src_opt->is_vector()) { + ConfigOption* dst_opt = target.option(base_key); + if (dst_opt == nullptr || !dst_opt->is_vector()) + continue; // cannot apply; will be reported as skipped + // Type mismatch: ConfigOptionVector::set() throws ConfigurationError on a + // mismatch, which would abort the whole project load; report the key as + // skipped instead (the material pass guards the same way). Note that the + // nullable variants report the same type() as their non-nullable base, so + // this catches genuinely different option kinds (e.g. a string where a float + // vector is expected), not nullable-ness. + if (dst_opt->type() != src_opt->type()) + continue; + // A '#N' variant key (e.g. per-extruder retraction_length#2) applies one + // element, so the index only needs to be in range on the author's side - the + // receiver may have a different extruder count than the author. Out-of-range + // indices are skipped (set_at would otherwise resize the receiver's vector). + if (key.size() > base_key.size()) { + // Strict numeric suffix parse: a malformed variant ("#abc", "#1x") + // must be reported as skipped, not silently applied as element 0. + const std::string suffix = key.substr(base_key.size() + 1); + size_t idx = 0; + bool valid = !suffix.empty(); + for (const char c : suffix) { + if (c < '0' || c > '9') { + valid = false; + break; + } + idx = idx * 10 + size_t(c - '0'); + if (idx > 1000000) { // overflow guard; real vector sizes are tiny + valid = false; + break; + } + } + const size_t src_size = static_cast(src_opt)->size(); + if (!valid || idx >= src_size) + continue; // malformed or out-of-range on the author's side: cannot apply; reported as skipped + const size_t dst_size = static_cast(dst_opt)->size(); + if (dst_size == 1) { + // Single-extruder receiver: collapse the author's per-extruder slots + // onto the receiver's single slot. Only the first serialized variant + // of a base key is applied (the author's "left or right" whichever was + // published first); later variants of the same base are reported as + // skipped, mirroring the receiver's single extruder. + if (collapsed_bases.count(base_key) != 0) + continue; + collapsed_bases.insert(base_key); + static_cast(dst_opt)->set_at(src_opt, 0, idx); + } else { + if (idx >= dst_size) + continue; // out-of-range variant: cannot apply; reported as skipped + target.apply_only(config, {key}, true); + } + } else if (static_cast(src_opt)->size() != + static_cast(dst_opt)->size()) { + // Whole-vector base key: the receiver must have a matching vector size, + // otherwise applying would overwrite a different number of elements. + continue; // cannot apply; will be reported as skipped + } else { + target.apply_only(config, {key}, true); + } + applied_keys.insert(key); + } else { + // A scalar key cannot carry a '#N' suffix; a hand-crafted file listing one + // is reported as skipped instead of being silently marked applied. + if (key.find('#') != std::string::npos) + continue; + // Scalar key: apply only if present on the user's machine. + const ConfigOption* dst_opt = target.option(base_key); + if (dst_opt == nullptr) + continue; // not present on the edited preset; will be reported as skipped + // Type mismatch: skipped, never thrown (see the vector branch above). + if (dst_opt->type() != src_opt->type()) + continue; + target.apply_only(config, {key}, true); + applied_keys.insert(key); + } + } + }; + apply_published(this->prints.get_edited_preset().config, nullptr); + apply_published(this->printers.get_edited_preset().config, &printer_allowlist); + + // Material pass: positional per-slot entries. The author published, per slot, either the + // entire filament (full) or specific keys plus optionally a curated type and/or colour. + // - full: lands on a freshly created standalone detached copy (no library preset used + // or mutated); + // - partial: a type requirement gates application; on mismatch the slot is replaced + // with the best visible candidate by published identity (exact name, setting_id, + // filament_id, vendor+type, type); with no replacement the receiver's material is + // kept and the keys are reported as skipped; + // - colour: applied regardless of the type gate. + // - capacity: past the printer's physical nozzles the entry becomes an empty + // mixed-filament placeholder; on a single-physical-slot receiver it is dropped. + // Applied partial values land on the collection's edited layer when the slot references + // it and that layer survives the load, otherwise on the stored preset in place. Slots + // aliasing a shared preset are re-pointed at distinct presets before the values apply. + { + // Grow the receiver's slots only as far as the highest published slot (never + // shrink, never pull filler materials for unpublished slots). + bool has_published_entries = false; + // Physical filament capacity of the receiver's printer: a non-SEMM tool-changer + // feeds filament N from nozzle N, so the nozzle count is the hard limit; a SEMM + // printer (single_extruder_multi_material) sizes its slot list by hand, so only + // the global slot limit applies (same condition as GUI_App::load_current_presets). + // Published entries that would need a NEW physical slot past this capacity are + // appended as empty mixed-filament placeholders instead of growing the list. + size_t physical_capacity = size_t(EnforcerBlockerType::ExtruderMax); + { + const Preset& receiver_printer = this->printers.get_edited_preset(); + if (receiver_printer.printer_technology() == ptFFF && + !receiver_printer.config.opt_bool("single_extruder_multi_material")) { + if (const auto* nozzle_diameter = receiver_printer.config.option("nozzle_diameter"); + nozzle_diameter != nullptr && !nozzle_diameter->values.empty()) + physical_capacity = nozzle_diameter->values.size(); + } + } + const std::set& mixed_definitions = publish_mixed_keys(); + auto is_mixed_definition = [&mixed_definitions](const PublishedMaterialEntry& entry) { + return std::any_of(entry.keys.begin(), entry.keys.end(), [&](const std::string& key) { + return mixed_definitions.count(publish_base_key(key)) != 0; + }); + }; + size_t grow_target = 0; + for (const PublishedMaterialEntry& entry : published_config->material_keys) { + has_published_entries = true; + if (entry.slot < 0) + continue; + // A physical entry past the capacity becomes a tail placeholder below; its + // growth is covered by the append counter, not the positional target. + if (!is_mixed_definition(entry) && size_t(entry.slot) >= physical_capacity) + continue; + grow_target = std::max(grow_target, size_t(entry.slot) + 1); + } + // Mixed-filament definitions live in project-level virtual slots, so applying one + // positionally onto a receiver slot holding a real filament would convert hardware + // state into a virtual mix. Compute each entry's destination before anything + // consumes entry.slot: a definition on a slot that already holds a mixed definition + // keeps its place (like-for-like); everything else follows one monotone append + // counter preserving author order (dest = max(authored, next_free)). Appends past + // the extruder limit are dropped and reported. Physical entries past capacity join + // the same counter as mixed_placeholder empties the GUI flags for assignment. + // + // The finished project keeps the physical-first invariant (see the sidebar's + // add_custom_filament: mixed slots always sit at the tail, physical slots packed + // first). That invariant must survive an import, so every receiver mixed slot that + // would end up inside (or ahead of) the incoming physical region is displaced to a + // fresh tail slot, and the tail allocator starts at the physical boundary rather + // than the receiver's slot count - otherwise a relocated mix can collide with a + // keep-placed new real slot. + size_t physical_boundary = this->num_physical_filaments(); + for (const PublishedMaterialEntry& entry : published_config->material_keys) + // Mirror the payload-real keep-place decision below: a real keeps its authored + // slot when that slot already exists on the receiver, or lies within the + // printer's physical capacity. Both end up as physical slots at index + // entry.slot, so they bound the physical region even past the capacity. + if (entry.slot >= 0 && !is_mixed_definition(entry) && + (size_t(entry.slot) < this->filament_presets.size() || size_t(entry.slot) < physical_capacity)) + physical_boundary = std::max(physical_boundary, size_t(entry.slot) + 1); + size_t next_free_slot = std::max(physical_boundary, this->filament_presets.size()); + bool any_mixed_relocated = false; + // All authored-slot -> destination moves decided by this pass, applied to the + // incoming config in one batched snapshot step below (an earlier move's + // destination can overlap a later move's source: adjacent tail mixes relocate + // onto consecutive slots, so incremental in-place shifts would overwrite a + // definition that has not been moved yet). + std::vector> mixed_moves; + // Receiver-mix relocations land in project_config, not the incoming config, so they + // get their own move list, applied below after the arrays grow. The swept set lets + // the payload loop below treat a displaced receiver mix as the physical slot it will + // become (a payload mix like-for-like overriding such a slot must itself relocate). + std::vector> receiver_mix_moves; + std::set swept_mix_slots; + for (size_t slot = 0; slot < this->filament_presets.size(); ++slot) { + if (!this->is_mixed_filament(slot)) + continue; + // Slot 0 is the receiver's base filament and is never a virtual mix; the + // sidebar's physical-first layout guarantees it, so never displace it. + if (slot == 0) + continue; + if (slot >= physical_boundary) + continue; // already lives in the tail region + const size_t dest = next_free_slot++; + swept_mix_slots.insert(slot); + receiver_mix_moves.emplace_back(slot, dest); + any_mixed_relocated = true; + // A payload mix authored at the same slot (a mix inside the physical region) is + // processed after this sweep and overrides its own destination below. + published_config->mixed_slot_relocations.insert_or_assign(int(slot), int(dest)); + published_config->material_replacements.emplace_back("slot " + std::to_string(slot) + " -> slot " + + std::to_string(dest) + ": mixed filament"); + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": published 3MF relocated receiver mixed filament slot " << slot + << " -> " << dest << " (physical-first rebalance)"; + } + for (auto entry_it = published_config->material_keys.begin(); entry_it != published_config->material_keys.end();) { + PublishedMaterialEntry& entry = *entry_it; + if (entry.slot < 0) { + ++entry_it; + continue; + } + const bool is_payload_mix = is_mixed_definition(entry); + // Does this entry need a tail slot at all? A payload mixed definition does, + // except when it like-for-like overrides a receiver slot that is already a + // mix (bounds-checked). A physical entry only becomes a placeholder when it + // would need a NEW physical slot: an authored position the receiver's list + // already covers is applied positionally as before, even when that list sits + // above the printer's capacity (pre-existing state is never shrunk). + bool keep_place = false; + if (is_payload_mix) + keep_place = this->is_mixed_filament(size_t(entry.slot)) && swept_mix_slots.count(size_t(entry.slot)) == 0; + else + keep_place = size_t(entry.slot) < this->filament_presets.size() || + size_t(entry.slot) < physical_capacity; + if (keep_place) { + ++entry_it; + continue; + } + const std::string material_label = !entry.filament_id.empty() ? entry.filament_id : + !entry.publish_type_value.empty() ? entry.publish_type_value : + entry.filament_type; + if (std::max(size_t(entry.slot), next_free_slot) >= size_t(EnforcerBlockerType::ExtruderMax)) { + // No free virtual slot left: report instead of destroying a real filament. + // The local skipped_keys is published wholesale at the end of the pass; + // writing published_config->skipped_keys here would be clobbered by it. + skipped_keys.emplace_back("material:" + material_label + (is_payload_mix ? + " (mixed filament definition: filament slot limit reached)" : + " (filament slot limit reached)")); + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": published 3MF " << (is_payload_mix ? "mixed filament definition" : "material") + << " from slot " << entry.slot << " could not be placed: all " << next_free_slot + << " slots exhausted"; + entry_it = published_config->material_keys.erase(entry_it); + continue; + } + if (!is_payload_mix && physical_capacity < 2) { + // A single physical slot can never host a mixed-filament editor (the + // sidebar's mixed section needs two physical filaments to mix), so a + // placeholder would be invisible and unfixable: drop the entry and + // report it like any other unappliable input. + skipped_keys.emplace_back("material:" + material_label + " (printer supports only " + + std::to_string(physical_capacity) + " filament)"); + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": published 3MF material from slot " << entry.slot + << " dropped: printer supports only " << physical_capacity << " filament"; + entry_it = published_config->material_keys.erase(entry_it); + continue; + } + const int authored_slot = entry.slot; + const int dest_slot = is_payload_mix ? int(std::max(size_t(authored_slot), next_free_slot)) : int(next_free_slot); + next_free_slot = size_t(dest_slot) + 1; + if (is_payload_mix) { + if (dest_slot == authored_slot) + // Uncontended fresh tail slot: the definition is already readable there. + ++entry_it; + else { + entry.slot = dest_slot; + any_mixed_relocated = true; + mixed_moves.emplace_back(size_t(authored_slot), size_t(entry.slot)); + published_config->mixed_slot_relocations.insert_or_assign(authored_slot, entry.slot); + published_config->material_replacements.emplace_back("slot " + std::to_string(authored_slot) + " -> slot " + + std::to_string(entry.slot) + ": mixed filament"); + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": published 3MF relocated mixed filament slot " << authored_slot + << " -> " << entry.slot; + ++entry_it; + } + } else { + // Surplus published material beyond the printer's capacity: become an + // empty mixed-filament placeholder at the next free tail slot. The flag + // routes the entry to the placeholder finalize below; the slot's + // definition stays empty until the user assigns components. + entry.mixed_placeholder = true; + any_mixed_relocated = true; + if (dest_slot != authored_slot) { + entry.slot = dest_slot; + mixed_moves.emplace_back(size_t(authored_slot), size_t(dest_slot)); + published_config->mixed_slot_relocations.insert_or_assign(authored_slot, dest_slot); + } + published_config->material_replacements.emplace_back( + (dest_slot != authored_slot ? + "slot " + std::to_string(authored_slot) + " -> slot " + std::to_string(dest_slot) : + "slot " + std::to_string(dest_slot)) + + ": unassigned mixed filament (printer supports only " + std::to_string(physical_capacity) + " filaments)"); + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": published 3MF material from slot " << authored_slot + << " placed as an unassigned mixed filament at slot " << dest_slot + << " (printer supports only " << physical_capacity << " filaments)"; + ++entry_it; + } + } + if (!mixed_moves.empty()) + for (const std::string& mixed_key : mixed_definitions) + apply_mixed_config_relocations(config, mixed_key, mixed_moves); + if (has_published_entries) { + // Defensive cap: growth never exceeds the file's own filament count. The + // receiver's current slot count is a floor: neither the preset list nor the + // project vectors are ever shrunk, even when the file carries fewer filaments + // than the receiver has slots. Relocated mixed entries and capacity + // placeholders legitimately land past the file's own slot count (virtual + // slots consume no nozzle or tray), so their append destinations lift the + // ceiling explicitly. + const size_t target_slots = std::max({this->filament_presets.size(), std::min(grow_target, num_filaments), + any_mixed_relocated ? next_free_slot : size_t(0)}); + // Slots carrying published content, steering the initial preset selection of + // newly grown slots. + std::set published_slots; + for (const PublishedMaterialEntry& entry : published_config->material_keys) + if (entry.slot >= 0) + published_slots.insert(entry.slot); + // Grown slots the receiver creates that carry no published content of their own + // (e.g. an unpublished mixed slot left as a gap by a published mix's authored + // position) at or beyond the receiver's physical capacity. They must not become + // physical filaments (that would overflow the nozzle count): finalized as empty + // mixed placeholders below, like the surplus-material placeholders. + std::set virtual_gap_slots; + // Exact-name resolution of each published slot's preset through the collection's + // own name machinery: find_preset2 follows renamed_from (vendor profile renames) + // and canonical bundle names, and auto-matches removed vendor-generic profiles + // to the Orca Filament Library. First entry per slot wins (mirrors the loops' + // break-on-first behavior); an empty value means "no resolution" and the string + // tiers (raw name / trimmed bare name / alias) below take over. + std::map exact_name_by_slot; + for (const PublishedMaterialEntry& entry : published_config->material_keys) + if (entry.slot >= 0 && !entry.preset_name.empty() && exact_name_by_slot.count(entry.slot) == 0) + if (const Preset* resolved = this->filaments.find_preset2(entry.preset_name, true)) + exact_name_by_slot.emplace(entry.slot, resolved->name); + auto resolved_name_for = [&exact_name_by_slot](int slot) -> std::string { + const auto it = exact_name_by_slot.find(slot); + return it == exact_name_by_slot.end() ? std::string() : it->second; + }; + // Mirror first_visible_idx()'s start index so suppressed default presets are + // never picked as a slot material. + const size_t first_candidate = this->filaments.is_default_suppressed() ? this->filaments.num_default_presets() : 0; + // Candidate preference for a published entry: exact preset name (raw author and + // collection-resolved), then trimmed bare form / alias, then exact setting_id + // (variant-level), then exact filament_id, then vendor+type, then type only. + auto candidate_score = [](const Preset& candidate, const PublishedMaterialEntry& entry, + const std::string& resolved_name) -> int { + // Exact preset name: raw and collection-resolved forms both outrank the + // fuzzy bare/alias tier by tier value, so a receiver preset literally named + // "Generic PLA" (tier 4) can never beat the author's exact + // "Generic PLA @Vendor" (tier 5). Within one tier the strict ">" + // comparison keeps the first candidate in collection order. + if (!entry.preset_name.empty() && candidate.name == entry.preset_name) + return 5; + if (!resolved_name.empty() && candidate.name == resolved_name) + return 5; + if (!entry.preset_name.empty()) { + std::string bare_name = entry.preset_name.substr(0, entry.preset_name.find('@')); + boost::trim_right(bare_name); + if (!bare_name.empty() && + (candidate.alias == entry.preset_name || candidate.name == bare_name || candidate.alias == bare_name)) + return 4; + } + if (!entry.setting_id.empty() && candidate.setting_id == entry.setting_id) + return 3; + const ConfigOptionStrings* types = candidate.config.opt("filament_type"); + const ConfigOptionStrings* vendors = candidate.config.opt("filament_vendor"); + const std::string type = (types != nullptr && !types->values.empty()) ? types->get_at(0) : std::string(); + const std::string vendor = (vendors != nullptr && !vendors->values.empty()) ? vendors->get_at(0) : std::string(); + if (!entry.filament_id.empty() && candidate.filament_id == entry.filament_id) + return 2; + // Tier 0/1 family gate: the explicit type requirement when published as + // such, otherwise the entry's own material family - so identity scoring + // also applies to entries published without a checked Type row. + const std::string required_type = !entry.publish_type_value.empty() ? entry.publish_type_value : + normalize_filament_type(entry.filament_type); + if (!required_type.empty() && normalize_filament_type(type) == required_type) { + if (!entry.filament_vendor.empty() && vendor == entry.filament_vendor) + return 1; + return 0; + } + return -1; + }; + while (this->filament_presets.size() < target_slots) { + const size_t new_slot_idx = this->filament_presets.size(); + std::string initial_preset; + if (new_slot_idx >= physical_capacity && published_slots.count(static_cast(new_slot_idx)) == 0) + // An unpublished grown slot past the printer's physical capacity cannot + // host a real filament: finalize it as a virtual placeholder below. + virtual_gap_slots.insert(static_cast(new_slot_idx)); + if (published_slots.count(static_cast(new_slot_idx)) != 0) { + // Grow the slot the way the sidebar "add filament" does: seed it with + // the receiver's last preset. + if (!this->filament_presets.empty()) + initial_preset = this->filament_presets.back(); + } + if (initial_preset.empty()) + // Unpublished filler slot, or every visible preset is already used: + // repeat the receiver's last preset ("Add one filament" behaviour). + initial_preset = this->filament_presets.empty() ? this->filaments.first_visible().name : + this->filament_presets.back(); + this->filament_presets.emplace_back(initial_preset); + } + // Published slots that alias another slot (multi-extruder with one filament) + // get re-pointed at distinct presets: the overlay writes onto the slot's + // effective preset in place, so a shared preset would leak one slot's published + // values into every aliased slot. Slot 0 (the receiver's own material) is never + // re-assigned; when no unused candidate exists the aliasing stays (unavoidable). + auto referenced_elsewhere = [&](const std::string& preset_name, size_t except_slot) { + for (size_t s = 0; s < this->filament_presets.size(); ++s) + if (s != except_slot && this->filament_presets[s] == preset_name) + return true; + return false; + }; + for (size_t slot = 1; slot < this->filament_presets.size(); ++slot) { + if (published_slots.count(static_cast(slot)) == 0 || !referenced_elsewhere(this->filament_presets[slot], slot)) + continue; + // A slot whose published entry writes nothing into a shared preset in place + // keeps its own preset: de-aliasing would needlessly swap the material. Only + // keys routed to the slot's preset can leak across an aliased slot (colour is + // slot-scoped via project_config; a type requirement is handled by the + // type-gate below; full detaches separately). Mixed-definition keys go to the + // per-slot project arrays, never the preset. + bool writes_preset_keys = false; + for (const PublishedMaterialEntry& entry : published_config->material_keys) { + if (entry.slot != static_cast(slot)) + continue; + for (const std::string& key : entry.keys) + if (mixed_definitions.count(publish_base_key(key)) == 0) { + writes_preset_keys = true; + break; + } + if (writes_preset_keys) + break; + } + if (!writes_preset_keys) + continue; + // Prefer the best distinct candidate for the slot's published material + // (exact id, then vendor+type, then type only), compatible presets first... + std::string replacement; + int best_score = -1; + for (const PublishedMaterialEntry& entry : published_config->material_keys) { + // Scored for every entry with an identity, not only when a Type + // requirement was checked (same as the growth seeding above). + if (entry.slot != static_cast(slot)) + continue; + const std::string resolved_name = resolved_name_for(entry.slot); + auto scan = [&](bool compatible_only) -> std::pair { + int best_score = -1; + std::string best_name; + for (size_t i = first_candidate; i < this->filaments.size(); ++i) { + const Preset& candidate = this->filaments.preset(i); + if (compatible_only && !candidate.is_compatible) + continue; + const int score = candidate_score(candidate, entry, resolved_name); + // Exact identity tiers (name / setting_id) may use a hidden preset; + // the referenced check stays: re-pointing exists to de-alias. + if (score < 3 && !candidate.is_visible) + continue; + if (referenced_elsewhere(candidate.name, size_t(-1))) + continue; + if (score > best_score) { + best_score = score; + best_name = candidate.name; + } + } + return {best_score, best_name}; + }; + const auto [compat_score, compat_name] = scan(true); + const auto [any_score, any_name] = scan(false); + if (compat_score >= 0) { + best_score = compat_score; + replacement = compat_name; + } else if (any_score >= 0) { + best_score = any_score; + replacement = any_name; + } + if (best_score >= 0) + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": published 3MF re-pointed slot " << slot << " to " << replacement + << " (score " << best_score << ", preset_name \"" << entry.preset_name << "\", type \"" + << entry.publish_type_value << "\")"; + break; + } + // ...otherwise any distinct visible preset not referenced by another slot, + // preferring the published material's own family when it is known. + if (replacement.empty()) { + std::string slot_family; + for (const PublishedMaterialEntry& entry : published_config->material_keys) + if (entry.slot == static_cast(slot)) { + slot_family = !entry.publish_type_value.empty() ? entry.publish_type_value : + normalize_filament_type(entry.filament_type); + break; + } + for (size_t i = first_candidate; i < this->filaments.size(); ++i) { + const Preset& candidate = this->filaments.preset(i); + if (!candidate.is_visible || referenced_elsewhere(candidate.name, size_t(-1))) + continue; + if (!slot_family.empty()) { + const ConfigOptionStrings* types = candidate.config.opt("filament_type"); + const std::string cand_type = (types != nullptr && !types->values.empty()) ? types->get_at(0) : + std::string(); + if (normalize_filament_type(cand_type) != slot_family) + continue; + } + replacement = candidate.name; + break; + } + } + if (replacement.empty()) + continue; // every visible preset is referenced: aliasing is unavoidable + const std::string aliased_name = this->filament_presets[slot]; + this->filament_presets[slot] = replacement; + material_applied = true; + // The re-point used to be silent; surface it like the other slot changes. + published_config->material_replacements.emplace_back("slot " + std::to_string(slot) + ": " + aliased_name + " -> " + + replacement); + } + // Grow the per-slot colour/type/map project vectors to the new slot count and + // seed the new entries so the slots render with colours instead of blank chips + // (mirrors set_num_filaments; existing values are left untouched). + ConfigOptionStrings* proj_colour = this->project_config.opt("filament_colour"); + ConfigOptionStrings* proj_multi_colour = this->project_config.opt("filament_multi_colour"); + ConfigOptionStrings* proj_colour_type = this->project_config.opt("filament_colour_type"); + ConfigOptionInts* proj_map = this->project_config.opt("filament_map"); + ConfigOptionInts* proj_nozzle_map = this->project_config.opt("filament_nozzle_map"); + ConfigOptionInts* proj_volume_map = this->project_config.opt("filament_volume_map"); + const size_t old_colour_count = (proj_colour != nullptr) ? proj_colour->values.size() : 0; + // Grow-only: an already-larger project vector is left untouched (the receiver's + // slot count never shrinks below its own setup). + if (proj_colour && proj_colour->values.size() < target_slots) + proj_colour->resize(target_slots); + if (proj_multi_colour && proj_multi_colour->values.size() < target_slots) + proj_multi_colour->values.resize(target_slots); + if (proj_colour_type && proj_colour_type->values.size() < target_slots) + proj_colour_type->values.resize(target_slots); + if (proj_map && proj_map->values.size() < target_slots) + proj_map->values.resize(target_slots, 1); + if (proj_nozzle_map && proj_nozzle_map->values.size() < target_slots) + proj_nozzle_map->values.resize(target_slots, 0); + if (proj_volume_map && proj_volume_map->values.size() < target_slots) + proj_volume_map->values.resize(target_slots, static_cast(NozzleVolumeType::nvtStandard)); + // The mixed-color project arrays are parallel per-slot like filament_colour; + // grow them in lockstep so a published mix slot has room for its definition. + // Defaults mirror set_num_filaments (false / empty string). + if (auto* opt = this->project_config.opt("filament_is_mixed")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, false); + if (auto* opt = this->project_config.opt("filament_mixed_components")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, std::string{}); + if (auto* opt = this->project_config.opt("filament_mixed_sublayer_ratios")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, std::string{}); + if (auto* opt = this->project_config.opt("filament_mixed_gradient")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, false); + if (auto* opt = this->project_config.opt("filament_mixed_gradient_range")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, std::string{}); + if (auto* opt = this->project_config.opt("filament_mixed_gradient_curve")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, std::string{}); + if (auto* opt = this->project_config.opt("filament_mixed_gradient_per_part")) + if (opt->values.size() < target_slots) + opt->values.resize(target_slots, false); + if (this->ams_multi_color_filment.size() < target_slots) + this->ams_multi_color_filment.resize(target_slots); + for (size_t slot = old_colour_count; slot < target_slots; ++slot) { + std::string seed; + for (const PublishedMaterialEntry& entry : published_config->material_keys) + if (entry.slot == static_cast(slot) && entry.publish_color && !entry.color.empty()) { + seed = entry.color; + break; + } + if (seed.empty()) { + if (const Preset* preset = this->filaments.find_preset(this->filament_presets[slot], false)) { + const ConfigOptionStrings* colours = preset->config.opt("filament_colour"); + if (colours != nullptr && !colours->values.empty()) + seed = colours->values.front(); + } + if (seed.empty()) { + // Fall back to the option's registered default instead of a + // duplicated literal; if the lookup fails the chip stays blank. + if (const ConfigOptionDef* colour_def = print_config_def.get("filament_colour")) + if (const auto* default_colours = dynamic_cast(colour_def->default_value.get())) + if (!default_colours->values.empty()) + seed = default_colours->values.front(); + } + } + if (proj_colour && slot < proj_colour->values.size()) + proj_colour->values[slot] = seed; + if (proj_multi_colour && slot < proj_multi_colour->values.size()) + proj_multi_colour->values[slot] = seed; + if (proj_colour_type && slot < proj_colour_type->values.size()) + proj_colour_type->values[slot] = "1"; // default colour type + } + // Rebuild the flush volumes for the grown slot count (as set_num_filaments does). + this->update_multi_material_filament_presets(); + + auto apply_slot_keys = [&](DynamicPrintConfig& preset_config, const std::vector& slot_keys, int author_slot, + const std::string& material_label) { + for (const std::string& key : slot_keys) { + const std::string base_key = publish_base_key(key); + if (structural_keys.count(base_key) != 0) + continue; + const ConfigOption* src_opt = config.option(base_key); + if (src_opt == nullptr || !src_opt->is_vector() || author_slot < 0 || + author_slot >= static_cast(static_cast(src_opt)->size())) { + skipped_keys.emplace_back("material:" + material_label + " (" + key + ")"); + continue; + } + ConfigOption* dst_opt = preset_config.option(base_key); + if (dst_opt == nullptr || !dst_opt->is_vector() || static_cast(dst_opt)->empty() || + dst_opt->type() != src_opt->type()) { + skipped_keys.emplace_back("material:" + material_label + " (" + key + ")"); + continue; + } + // Per-slot scalar copy: the receiver preset holds one value per key + // (vector of size 1), the file holds the per-slot vector. + static_cast(dst_opt)->set_at(src_opt, 0, author_slot); + material_applied = true; + } + }; + + // The slot's values are applied onto the slot's effective preset (the + // collection's edited layer when the slot references it, otherwise the stored + // preset in place); the per-entry type gate below may re-point the slot first. + // + // The edited layer only survives the load when the final re-select below stays + // off, i.e. slot 0's preset still matches the edited preset. Otherwise the + // re-select re-snapshots the edited layer from a stored preset and silently + // discards everything written there: route the overlay to the stored presets + // instead so the values survive. (Residual edge: a slot-0 type replacement + // re-pointing slot 0 mid-loop can still invalidate the layer after earlier + // slots wrote to it; that compound case is not chased.) + const bool edited_survives_load = this->filament_presets.empty() || + this->filament_presets.front() == this->filaments.get_edited_preset().name; + // Displaced receiver mixes (see the physical-first rebalance above): move their + // per-slot cells onto the grown tail slots and reset the vacated physical slots, + // so the incoming real filaments can claim them. Runs before mixed_final_slots is + // built, so the rebalanced layout is what the mix validation sees. + apply_receiver_mix_relocations(this->project_config, this->ams_multi_color_filment, receiver_mix_moves); + // Final layout for mix-definition validation: every slot that will hold a + // mixed definition once this load completes - the receiver's own virtual + // slots, each published mixed entry's final (possibly relocated) slot, and + // each capacity placeholder (they become mixes in the finalize below, before + // this loop's is_mixed_filament scan would see them). Mix components are + // 1-based slot numbers, so a component is valid only when the slot it names + // exists and does not itself hold a mixed filament. + std::set mixed_final_slots; + for (const PublishedMaterialEntry& mix_entry : published_config->material_keys) + if (mix_entry.slot >= 0 && (is_mixed_definition(mix_entry) || mix_entry.mixed_placeholder)) + mixed_final_slots.insert(mix_entry.slot); + for (size_t i = 0; i < this->filament_presets.size(); ++i) + if (this->is_mixed_filament(i)) + mixed_final_slots.insert(int(i)); + // Unpublished gap slots past the capacity are virtual too: count them in the + // final layout so a published mix whose components collide with one is caught. + for (int gap_slot : virtual_gap_slots) + mixed_final_slots.insert(gap_slot); + const size_t mixed_final_slot_count = this->filament_presets.size(); + // Finalize a slot as an empty mixed-filament placeholder: mark it virtual with + // an intentionally empty definition, and add it to the final-layout set so a + // later entry's components validate against the new state. Used by the capacity + // placeholders and by any mixed definition that has to be rejected after its + // slot was already grown and seeded - without it such a slot would keep the + // seeded preset and masquerade as a real filament. No definition is written: + // the placeholder carries no material of its own (the GUI flags the empty mix + // via check_mixed_filament_integrity and blocks slicing until components are + // assigned). The slot's colour was already seeded by the growth pass above. + auto finalize_mixed_placeholder = [&](size_t slot_idx) { + if (ConfigOptionBools* is_mixed_opt = this->project_config.opt("filament_is_mixed"); + is_mixed_opt != nullptr && slot_idx < is_mixed_opt->values.size()) + is_mixed_opt->values[slot_idx] = true; + mixed_final_slots.insert(int(slot_idx)); + material_applied = true; + }; + // Full Publish within-load dedup: identical Full materials (same setting_id + // + preset_name identity) share one created instance, so an author who + // pointed two slots at one preset yields one standalone copy here. + std::map published_full_dedup; + for (const PublishedMaterialEntry& entry : published_config->material_keys) { + if (entry.slot < 0 || size_t(entry.slot) >= this->filament_presets.size()) + continue; // out of range: nothing to do for this slot + const size_t slot = size_t(entry.slot); + + // Surplus published material beyond the printer's capacity: finalize the + // tail placeholder - mark the slot virtual with an intentionally empty + // definition. The GUI flags the empty mix (check_mixed_filament_integrity) + // and blocks slicing until the user assigns components from their own + // filaments. The entry's keys are deliberately not applied and no preset + // is detached: the placeholder carries no material of its own. The colour + // was already seeded into the project arrays by the growth pass above. + if (entry.mixed_placeholder) { + finalize_mixed_placeholder(slot); + continue; + } + + // Resolve the stored preset itself (real=true), never the edited snapshot: + // find_preset would return &m_edited_preset for the selected slot. The + // overlay target below decides between the edited layer and this preset. + Preset* recv = this->filaments.find_preset(this->filament_presets[slot], false, true); + if (recv == nullptr) { + // Defensive: the slot exists (grown and seeded above) but its preset + // could not be resolved. A mixed definition left unapplied on such a + // slot would keep the seeded preset and look like a real filament: + // finalize it as an empty placeholder instead, like a rejected + // definition below. + if (is_mixed_definition(entry) && !this->is_mixed_filament(slot)) { + finalize_mixed_placeholder(slot); + published_config->material_replacements.emplace_back("slot " + std::to_string(slot) + + ": mixed filament definition could not be imported"); + } + continue; + } + + const std::string material_label = entry.filament_id.empty() ? + (entry.publish_type_value.empty() ? entry.filament_type : + entry.publish_type_value) : + entry.filament_id; + + // Import-side validation of a mixed-filament definition: the publish + // dialog cannot produce a definition whose components reference slots + // that do not exist or hold other mixed filaments, so a broken one here + // means the payload itself is broken (hand-crafted or corrupt file). + // Report it through the same channel as every other rejected input and + // skip the entry, instead of shipping a mix the GUI integrity check + // (check_mixed_filament_integrity) would only flag later. A payload + // that omits the mixed arrays entirely is not an error here: the key + // routing below reports those per key as usual. + if (is_mixed_definition(entry)) { + std::string mix_error; + if (const ConfigOptionStrings* comp_opt = config.opt("filament_mixed_components"); + comp_opt != nullptr && entry.slot < static_cast(comp_opt->values.size())) { + const std::vector comps = parse_mixed_components(comp_opt->values[entry.slot]); + if (comps.size() < 2) + // An empty definition cell counts as broken too: applying it + // would ship a mix the sidebar would only flag later. + mix_error = "needs at least two components"; + else + for (unsigned int comp : comps) + if (comp < 1 || size_t(comp) > mixed_final_slot_count || + mixed_final_slots.count(int(comp) - 1) != 0) { + mix_error = "components reference missing slots"; + break; + } + } + if (!mix_error.empty()) { + skipped_keys.emplace_back("material:" + material_label + " (mixed filament definition: " + mix_error + ")"); + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": published 3MF mixed filament from slot " << entry.slot + << " rejected: " << mix_error; + // Degradation, not silent corruption: the slot was already grown and + // seeded by the pass above, so skipping the definition alone would + // leave it holding the seeded preset and looking like a real + // filament that carries the mix identity but no mix. + // - a slot that was NOT already a receiver mix is finalized as an + // empty mixed placeholder (consistent with the capacity + // placeholders; the user assigns components there); + // - a like-for-like override of the receiver's own mix leaves that + // valid definition untouched: nothing was applied, so the + // receiver's cells are intact and only the author's definition + // is reported as skipped above. + if (!this->is_mixed_filament(slot)) { + finalize_mixed_placeholder(slot); + published_config->material_replacements.emplace_back("slot " + std::to_string(slot) + + ": mixed filament definition could not be imported (" + + mix_error + ")"); + } + continue; + } + } + + // Full Publish: always create a standalone detached copy (even on exact + // identity match) as a "Preset Inside Project" (project-embedded: lives + // in this project only, never written to the library), universally + // compatible, named after the author's preset with its variant tail + // stripped ("(Published)" uniquification on collision). No existing + // preset is ever mutated. + if (entry.full) { + std::string dedup_key = entry.setting_id + std::string("\x1f") + entry.preset_name; + // Identity-less hand-crafted files (empty setting_id+name) must not + // collide: fall back to slot-scoped key so each slot gets its own copy + // unless the dedup above is meaningful. + if (dedup_key == std::string("\x1f")) + dedup_key = dedup_key + std::to_string(slot); + else if (!entry.filament_id.empty()) + dedup_key += std::string("\x1f") + entry.filament_id; + std::string new_name; + const auto dedup_it = published_full_dedup.find(dedup_key); + if (dedup_it != published_full_dedup.end()) { + new_name = dedup_it->second; + } else { + // Baseline: clone the receiver slot's stored preset config (schema- + // complete across versions), then overlay the published full_keys. + DynamicPrintConfig new_cfg = recv != nullptr ? recv->config : this->filaments.default_preset().config; + for (const std::string& key : entry.full_keys) { + const std::string base_key = publish_base_key(key); + if (structural_keys.count(base_key) != 0) + continue; + const ConfigOption* src_opt = config.option(base_key); + if (src_opt == nullptr || !src_opt->is_vector() || entry.slot < 0 || + entry.slot >= static_cast(static_cast(src_opt)->size())) + continue; + ConfigOption* dst_opt = new_cfg.option(base_key); + if (dst_opt == nullptr || !dst_opt->is_vector() || + static_cast(dst_opt)->empty() || dst_opt->type() != src_opt->type()) + continue; + static_cast(dst_opt)->set_at(src_opt, 0, entry.slot); + } + // The published colour is authoritative even for Full (the payload's + // filament_colour plus the explicit publish_color field). + if (entry.publish_color && !entry.color.empty()) { + if (ConfigOptionStrings* col = new_cfg.opt("filament_colour", true)) { + if (col->values.empty()) + col->values.emplace_back(); + col->values[0] = entry.color; + } + } + make_publish_universal(new_cfg); + // Naming: stripped variant tail ("Generic PLA @System" -> "Generic + // PLA"), then identity fallbacks; collisions uniquify with + // "(Published)" / "(Published N)" inside add_detached_preset. + std::string base_name = entry.preset_name.empty() ? std::string() : + publish_material_base_name(entry.preset_name); + if (base_name.empty()) { + base_name = !entry.filament_id.empty() ? + entry.filament_id : + (!entry.publish_type_value.empty() ? entry.publish_type_value : entry.filament_type); + if (base_name.empty()) + base_name = "Published Filament"; + } + new_name = this->filaments.add_detached_preset(base_name, std::move(new_cfg), entry.filament_id); + published_full_dedup.emplace(dedup_key, new_name); + } + const std::string old_name = this->filament_presets[slot]; + this->filament_presets[slot] = new_name; + material_applied = true; + published_config->material_replacements.emplace_back("slot " + std::to_string(slot) + ": " + old_name + " -> " + + new_name); + // Colour is slot-scoped and project-visible: sync into project_config + // (the copy already baked it, this makes the chips render). + if (entry.publish_color && !entry.color.empty()) { + if (ConfigOptionStrings* proj_colour = this->project_config.opt("filament_colour")) { + if (slot < proj_colour->values.size()) + proj_colour->values[slot] = entry.color; + } + if (ConfigOptionStrings* proj_multi_colour = this->project_config.opt( + "filament_multi_colour")) { + if (slot < proj_multi_colour->values.size()) + proj_multi_colour->values[slot] = entry.color; + } + } else if (proj_colour && new_name != old_name) { + // Fall back to the copy's colour so the chip is never blank: try + // the newly created preset's filament_colour, then the option default. + std::string seed; + if (const Preset* created = this->filaments.find_preset(new_name, false, true)) { + if (const ConfigOptionStrings* cols = created->config.opt("filament_colour")) + if (!cols->values.empty()) + seed = cols->values.front(); + } + if (seed.empty()) { + if (const ConfigOptionDef* colour_def = print_config_def.get("filament_colour")) + if (const auto* defaults = dynamic_cast(colour_def->default_value.get())) + if (!defaults->values.empty()) + seed = defaults->values.front(); + } + if (!seed.empty()) { + if (proj_colour && slot < proj_colour->values.size()) + proj_colour->values[slot] = seed; + if (proj_multi_colour && slot < proj_multi_colour->values.size()) + proj_multi_colour->values[slot] = seed; + } + } + if (proj_colour_type && slot < proj_colour_type->values.size()) + proj_colour_type->values[slot] = "1"; + continue; + } + + bool apply_slot = true; + // The gate compares against the slot's effective material type: the edited + // layer when the slot references the collection's edited preset and that + // layer will survive the load (see edited_survives_load below), the stored + // preset otherwise. + const DynamicPrintConfig& effective_config = (edited_survives_load && + recv->name == this->filaments.get_edited_preset().name) ? + this->filaments.get_edited_preset().config : + recv->config; + if (entry.publish_type && !entry.publish_type_value.empty()) { + // Null-guard: a malformed receiver preset may lack filament_type. + const ConfigOptionStrings* recv_types = effective_config.opt("filament_type"); + const std::string recv_type = (recv_types != nullptr && !recv_types->values.empty()) ? recv_types->get_at(0) : + std::string(); + if (normalize_filament_type(recv_type) != entry.publish_type_value) { + // Type mismatch: replace the slot with the best matching preset, + // scored by the published identity (exact preset name, then exact + // setting_id, exact filament_id, then vendor+type, then type only). + // A preset no other slot references wins on equal scores; a shared + // exact-material preset is taken even though mutating it also + // affects the other slot. Compatible presets are searched first: an + // incompatible preset is only picked when no compatible candidate + // exists at all. + auto find_best = [&](bool compatible_only) -> std::pair { + auto scan = [&](bool unreferenced_only) -> std::pair { + int best_score = -1; + std::string best_name; + const std::string resolved_name = resolved_name_for(entry.slot); + for (size_t i = first_candidate; i < this->filaments.size(); ++i) { + const Preset& candidate = this->filaments.preset(i); + if (compatible_only && !candidate.is_compatible) + continue; + const int score = candidate_score(candidate, entry, resolved_name); + if (score <= best_score) + continue; + // Lower tiers (vendor+type, type only) need a visible preset; + // an exact identity match (name / setting_id) wins even when + // the preset is hidden in the library. + if (score < 3 && !candidate.is_visible) + continue; + if (unreferenced_only) { + bool used = false; + for (size_t s = 0; s < this->filament_presets.size(); ++s) + if (s != slot && this->filament_presets[s] == candidate.name) { + used = true; + break; + } + if (used) + continue; + } + best_score = score; + best_name = candidate.name; + } + return {best_score, best_name}; + }; + const auto [strict_score, strict_name] = scan(true); + const auto [relaxed_score, relaxed_name] = scan(false); + if (relaxed_score > strict_score) + return {relaxed_score, relaxed_name}; + return {strict_score, strict_name}; + }; + const auto [compat_score, compat_name] = find_best(true); + const auto [any_score, any_name] = find_best(false); + int score = any_score; + std::string replacement = any_name; + if (compat_score >= 0) { + score = compat_score; + replacement = compat_name; + } + if (!replacement.empty()) { + const std::string old_name = recv->name; + this->filament_presets[slot] = replacement; + recv = this->filaments.find_preset(replacement, false, true); + material_applied = true; + BOOST_LOG_TRIVIAL(info) + << __FUNCTION__ << ": published 3MF slot " << slot << " material " << old_name << " -> " << replacement + << " (score " << score << ", preset_name \"" << entry.preset_name << "\", filament_id \"" + << entry.filament_id << "\", setting_id \"" << entry.setting_id << "\", type \"" + << entry.publish_type_value << "\")"; + std::string replacement_line = "slot " + std::to_string(slot) + ": " + old_name + " -> " + replacement; + // A pick that is not the exact published material is a substitute; + // an entry without identity fields cannot be judged, so it stays plain. + if (score < 2 && (!entry.filament_id.empty() || !entry.filament_vendor.empty())) + replacement_line += " (substitute)"; + published_config->material_replacements.emplace_back(std::move(replacement_line)); + } else { + // Partial publish with no replacement: keep the receiver's + // material and report the slot's keys as skipped. (Full entries + // never reach this gate: they detach above.) + for (const std::string& key : entry.keys) + skipped_keys.emplace_back("material:" + material_label + " (" + key + ")"); + apply_slot = false; + // A mixed definition left unapplied here would keep the grown + // slot's seeded preset and masquerade as a real filament: + // finalize it as an empty placeholder like a rejected + // definition (a like-for-like override of the receiver's own + // mix is left untouched). + if (is_mixed_definition(entry) && !this->is_mixed_filament(slot)) { + finalize_mixed_placeholder(slot); + published_config->material_replacements.emplace_back("slot " + std::to_string(slot) + + ": mixed filament definition could not be imported"); + } + } + } + } + + // The values below are applied onto the slot's effective preset: the edited + // layer when the slot references the collection's edited preset and that + // layer will survive the load (visible as a modification and revertible, + // preserving the user's unsaved edits), otherwise the stored preset it ended + // up on (original or partial type replacement), in place. Full entries never + // get here - they detach above. + DynamicPrintConfig& write_config = (edited_survives_load && recv->name == this->filaments.get_edited_preset().name) ? + this->filaments.get_edited_preset().config : + recv->config; + + // Colour is slot-scoped and independent of the type gate; it is also synced + // into project_config for GUI rendering. + if (entry.publish_color && !entry.color.empty()) { + // A mixed-definition entry carries the mix's blended colour for the + // swatch only: never write it into the slot's (possibly shared) preset + // config, only into the project-level colour arrays. + const bool is_mixed_entry = is_mixed_definition(entry); + if (!is_mixed_entry && recv != nullptr) { + // Create the key when the target preset lacks it: the colour is a + // requirement, not an override. + if (ConfigOptionStrings* colour = write_config.opt("filament_colour", true)) { + if (colour->values.empty()) + colour->values.emplace_back(); + colour->values[0] = entry.color; + material_applied = true; + } + } + if (ConfigOptionStrings* proj_colour = this->project_config.opt("filament_colour")) { + if (slot < proj_colour->values.size()) + proj_colour->values[slot] = entry.color; + } + if (ConfigOptionStrings* proj_multi_colour = this->project_config.opt( + "filament_multi_colour")) { + if (slot < proj_multi_colour->values.size()) + proj_multi_colour->values[slot] = entry.color; + } + } + + if (apply_slot && recv != nullptr) { + // Mixed-color definition keys live in project_config (parallel per-slot + // arrays), not in a filament preset; route them there. Normal keys keep + // the per-slot preset path below. + const std::set& mixed_keys = publish_mixed_keys(); + std::vector preset_keys; + for (const std::string& key : entry.keys) { + const std::string base_key = publish_base_key(key); + if (mixed_keys.count(base_key) != 0) { + const ConfigOption* src_opt = config.option(base_key); + if (src_opt != nullptr && src_opt->is_vector() && entry.slot >= 0 && + entry.slot < static_cast(static_cast(src_opt)->size())) { + if (ConfigOption* dst_opt = this->project_config.option(base_key)) { + if (dst_opt->is_vector() && dst_opt->type() == src_opt->type() && + slot < static_cast(dst_opt)->size()) { + static_cast(dst_opt)->set_at(src_opt, slot, entry.slot); + material_applied = true; + continue; + } + } + } + // The slot (or its arrays) could not be written: report rather + // than drop the mix silently. + skipped_keys.emplace_back("material:" + material_label + " (" + key + ")"); + continue; + } + preset_keys.emplace_back(key); + } + apply_slot_keys(write_config, preset_keys, entry.slot, material_label); + } + } + // Unpublished gap slots past the printer's physical capacity: the receiver grew + // them only to reach a published definition, so they must not become physical + // filaments (that would overflow the nozzle count). Finalize each as an empty + // mixed placeholder, exactly like the surplus-material placeholders above. + for (int gap_slot : virtual_gap_slots) + if (!this->is_mixed_filament(size_t(gap_slot))) { + finalize_mixed_placeholder(size_t(gap_slot)); + published_config->material_replacements.emplace_back( + "slot " + std::to_string(gap_slot) + + ": unassigned mixed filament (printer supports only " + std::to_string(physical_capacity) + " filaments)"); + } + } + } + + for (const std::string& key : published_config->published_keys) { + if (applied_keys.count(key) != 0) + continue; + const std::string base_key = publish_base_key(key); + // Structural keys are silently ignored, never reported as skipped: a hand-crafted + // 3MF must not trigger the "could not be applied" warning for them. + if (structural_keys.count(base_key) != 0) + continue; + // Printer-class keys outside the publishable allowlist are contract-excluded too. + if (contract_excluded_keys.count(base_key) != 0) + continue; + skipped_keys.emplace_back(key); + } + published_config->skipped_keys = std::move(skipped_keys); + + // The overlay lands on the edited layer only when it survives the load (slot 0's preset + // still matches the edited preset; see edited_survives_load) and on the stored presets + // otherwise. The edited preset is a snapshot taken when the preset was last selected, so + // a slot-0 preset change (type replacement) still needs a re-select to surface; when + // slot 0 is unchanged the edited layer already carries the overlay, and re-selecting + // would discard both it and the user's unsaved edits. + if (material_applied && !this->filament_presets.empty() && + this->filament_presets.front() != this->filaments.get_edited_preset().name) + this->filaments.select_preset_by_name(this->filament_presets.front(), false); + } + + //BBS //const std::string &physical_printer = config.option("physical_printer_settings_id", true)->value; const std::string physical_printer; - if (this->printers.get_edited_preset().is_external || physical_printer.empty()) { - this->physical_printers.unselect_printer(); - } else { - // Activate the physical printer profile if possible. - PhysicalPrinter *pp = this->physical_printers.find_printer(physical_printer, true); - if (pp != nullptr && std::find(pp->preset_names.begin(), pp->preset_names.end(), this->printers.get_edited_preset().name) != pp->preset_names.end()) - this->physical_printers.select_printer(pp->name, this->printers.get_edited_preset().name); - else + if (!is_published) { + if (this->printers.get_edited_preset().is_external || physical_printer.empty()) { this->physical_printers.unselect_printer(); + } else { + // Activate the physical printer profile if possible. + PhysicalPrinter *pp = this->physical_printers.find_printer(physical_printer, true); + if (pp != nullptr && std::find(pp->preset_names.begin(), pp->preset_names.end(), this->printers.get_edited_preset().name) != pp->preset_names.end()) + this->physical_printers.select_printer(pp->name, this->printers.get_edited_preset().name); + else + this->physical_printers.unselect_printer(); + } } //BBS: add config related logs BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": finished"); @@ -5280,9 +6756,11 @@ std::string PresetBundle::load_vendor_preset( return reason; } - auto file_path = (boost::filesystem::path(data_dir()) /PRESET_SYSTEM_DIR/ vendor_name / entry.sub_path).make_preferred(); - if(validation_mode) + auto file_path = (boost::filesystem::path(data_dir()) / PRESET_SYSTEM_DIR / vendor_name / entry.sub_path).make_preferred(); + if (validation_mode) file_path = (boost::filesystem::path(data_dir()) / vendor_name / entry.sub_path).make_preferred(); + if (m_preserve_vendor_source_paths) + file_path = (boost::filesystem::path(path) / vendor_name / entry.sub_path).make_preferred(); // Load the preset into the list of presets, save it to disk. Preset &loaded = presets_collection->load_preset(file_path.string(), preset_name, std::move(config), false); @@ -5348,7 +6826,8 @@ std::string PresetBundle::load_vendor_preset( //BBS: Load a config bundle file from json std::pair PresetBundle::load_vendor_configs_from_json( - const std::string &dir, const std::string &vendor_name, LoadConfigBundleAttributes flags, ForwardCompatibilitySubstitutionRule compatibility_rule, const PresetBundle* base_bundle) + const std::string &dir, const std::string &vendor_name, LoadConfigBundleAttributes flags, + ForwardCompatibilitySubstitutionRule compatibility_rule, const PresetBundle* base_bundle, bool allow_cache) { // Enable substitutions for user config bundle, throw an exception when loading a system profile. ConfigSubstitutionContext substitution_context { compatibility_rule }; @@ -5366,7 +6845,7 @@ std::pair PresetBundle::load_vendor_configs_ // Orca: only a whole-vendor load has a cache — the vendor-only and filament-only // scans want a slice of one. Validation reads the JSONs whatever is cached. const boost::filesystem::path dir_path(dir); - const bool cacheable = flags.has(LoadConfigBundleAttribute::LoadSystem) && ! flags.has(LoadConfigBundleAttribute::LoadFilamentOnly); + const bool cacheable = allow_cache && flags.has(LoadConfigBundleAttribute::LoadSystem) && ! flags.has(LoadConfigBundleAttribute::LoadFilamentOnly); if (cacheable && ! validation_mode && this->load_vendor_cache(dir_path, vendor_name, base_bundle)) { size_t presets_loaded = 0; for (const PresetCollection* coll : std::initializer_list{ diff --git a/src/libslic3r/PresetBundle.hpp b/src/libslic3r/PresetBundle.hpp index b927c5ee6f..353b6dc07d 100644 --- a/src/libslic3r/PresetBundle.hpp +++ b/src/libslic3r/PresetBundle.hpp @@ -4,9 +4,11 @@ #include "Preset.hpp" #include "PresetCacheFormat.hpp" #include "AppConfig.hpp" +#include "PublishSettings.hpp" #include "enum_bitmask.hpp" #include +#include #include #include #include @@ -168,6 +170,30 @@ struct PresetBundleMetadata } }; +// A "published" 3MF project: keeps the user's currently-selected presets and overlays only the +// author-selected published keys onto the edited presets. +struct PublishedConfig +{ + bool published = false; + std::vector published_keys; + // Per-slot published material keys, applied positionally (author slot N -> receiver slot N). + // Partial entries are gated by the author's optional type requirement and written onto the + // slot's stored preset in place; full entries instead detach (see PublishedMaterialEntry in + // PublishSettings.hpp). + std::vector material_keys; + // Keys that could not be applied (missing on the user's machine or vector size mismatch), + // filled in by load_config_file_config for notification purposes. + std::vector skipped_keys; + // Human-readable notices of the slot material replacements performed while loading a + // published project, for the load notification. + std::vector material_replacements; + // Mixed-filament entries that had to be moved off their authored slot on load (a real, + // physical filament occupied it): maps the author's zero-based slot number to its final + // zero-based slot. Consumers (e.g. model extruder/color-painting remapping) use this to + // keep geometry references pointing at the relocated definitions. + std::map mixed_slot_relocations; +}; + // Bundle of Print + Filament + Printer presets. class PresetBundle { @@ -230,7 +256,22 @@ public: // Load selections (current print, current filaments, current printer) from config.ini // select preferred presets, if any exist PresetsConfigSubstitutions load_presets(AppConfig &config, ForwardCompatibilitySubstitutionRule rule, - const PresetPreferences& preferred_selection = PresetPreferences()); + const PresetPreferences& preferred_selection = PresetPreferences(), + std::string *errors = nullptr, bool read_only = false); + + // Resolve an explicitly named source file through a canonical flattened + // preset. Exact loaded-file identity is preferred; otherwise a manifest- + // backed vendor tree is loaded from that source root without using caches. + bool resolve_preset_config(DynamicPrintConfig &config, Preset::Type type, + const std::string &source_file, + ForwardCompatibilitySubstitutionRule compatibility_rule, + std::string &error, bool allow_source_manifest = true); + // Resolve a source file whose JSON omits `type`. Succeeds only when exactly + // one FFF preset collection owns the file and returns that collection's type. + bool resolve_preset_config_type(DynamicPrintConfig &config, Preset::Type &type, + const std::string &source_file, + ForwardCompatibilitySubstitutionRule compatibility_rule, + std::string &error, bool allow_source_manifest = true); // Load selections (current print, current filaments, current printer) from config.ini // This is done just once on application start up. @@ -238,7 +279,7 @@ public: void load_selections(AppConfig &config, const PresetPreferences& preferred_selection = PresetPreferences()); // BBS Load user presets - PresetsConfigSubstitutions load_user_presets(std::string user, ForwardCompatibilitySubstitutionRule rule); + PresetsConfigSubstitutions load_user_presets(std::string user, ForwardCompatibilitySubstitutionRule rule, bool read_only = false); PresetsConfigSubstitutions load_user_presets(AppConfig &config, std::map>& my_presets, ForwardCompatibilitySubstitutionRule rule); // Orca: Import subscribed bundle presets (load and save to disk in one operation), handles one bundle at a time PresetsConfigSubstitutions update_subscribed_presets(AppConfig& config, @@ -449,8 +490,8 @@ public: // Load configuration that comes from a model file containing configuration, such as 3MF et al. // This method is called by the Plater. - void load_config_model(const std::string &name, DynamicPrintConfig config, Semver file_version = Semver()) - { this->load_config_file_config(name, true, std::move(config), file_version); } + void load_config_model(const std::string &name, DynamicPrintConfig config, Semver file_version = Semver(), PublishedConfig *published_config = nullptr) + { this->load_config_file_config(name, true, std::move(config), file_version, false, published_config); } // Load an external config file containing the print, filament and printer presets. // Instead of a config file, a G-code may be loaded containing the full set of parameters. @@ -481,10 +522,13 @@ public: //Orca: load config bundle from json, pass the base bundle to support cross vendor inheritance // Orca: `dir` is where the vendor is looked for — its own directory, whether or // not the profile JSONs are still there. A whole-vendor load comes from the - // vendor's preset cache whenever one covers the profile on disk, and is parsed - // from the JSONs in `dir` only when none does. Nothing here reads resources. + // vendor's preset cache whenever one covers the profile on disk and allow_cache + // is true, and is parsed from the JSONs in `dir` otherwise. Nothing here reads + // resources implicitly. std::pair load_vendor_configs_from_json( - const std::string &dir, const std::string &vendor_name, LoadConfigBundleAttributes flags, ForwardCompatibilitySubstitutionRule compatibility_rule, const PresetBundle* base_bundle = nullptr); + const std::string &dir, const std::string &vendor_name, LoadConfigBundleAttributes flags, + ForwardCompatibilitySubstitutionRule compatibility_rule, const PresetBundle* base_bundle = nullptr, + bool allow_cache = true); // Export a config bundle file containing all the presets and the names of the active presets. //void export_configbundle(const std::string &path, bool export_system_settings = false, bool export_physical_printers = false); @@ -606,6 +650,7 @@ private: // Whether to (re)write a per-vendor cache after a JSON parse. bool m_generate_vendor_caches { false }; + bool m_preserve_vendor_source_paths { false }; // Orca: validation only - flag any printer with two or more compatible // filament presets sharing one filament_id (ambiguous AMS subtype match). @@ -613,7 +658,7 @@ private: //std::pair load_system_presets(ForwardCompatibilitySubstitutionRule compatibility_rule); //BBS: add json related logic - std::pair load_system_presets_from_json(ForwardCompatibilitySubstitutionRule compatibility_rule); + std::pair load_system_presets_from_json(ForwardCompatibilitySubstitutionRule compatibility_rule, bool allow_cache = true); // Update the multicolor information for filaments. void update_filament_multi_color(); // Update renamed_from and alias maps of system profiles. @@ -627,7 +672,7 @@ private: // Load print, filament & printer presets from a config. If it is an external config, then the name is extracted from the external path. // and the external config is just referenced, not stored into user profile directory. // If it is not an external config, then the config will be stored into the user profile directory. - void load_config_file_config(const std::string &name_or_path, bool is_external, DynamicPrintConfig &&config, Semver file_version = Semver(), bool selected = false); + void load_config_file_config(const std::string &name_or_path, bool is_external, DynamicPrintConfig &&config, Semver file_version = Semver(), bool selected = false, PublishedConfig *published_config = nullptr); /*ConfigSubstitutions load_config_file_config_bundle( const std::string &path, const boost::property_tree::ptree &tree, ForwardCompatibilitySubstitutionRule compatibility_rule);*/ diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index aee50db534..60f747ce04 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -20,6 +20,7 @@ #include "GCode.hpp" #include "GCode/WipeTower.hpp" #include "GCode/WipeTower2.hpp" +#include "GCode/WipeTowerEstimate.hpp" #include "Utils.hpp" #include "PrintConfig.hpp" #include "MaterialType.hpp" @@ -1031,20 +1032,21 @@ static StringObjectException layered_print_cleareance_valid(const Print &print, //BBS: add the wipe tower check logic const PrintConfig & config = print.config(); - int filaments_count = print.extruders().size(); + // Custom G-code tool changes (MultiAsSingle) build a real tower on a plate whose objects + // all use one filament, so they have to be counted or the hull below collapses to a point. + int filaments_count = print.extruders(true).size(); int plate_index = print.get_plate_index(); const Vec3d plate_origin = print.get_plate_origin(); float x = config.wipe_tower_x.get_at(plate_index) + plate_origin(0); float y = config.wipe_tower_y.get_at(plate_index) + plate_origin(1); - float width = config.prime_tower_width.value; float a = config.wipe_tower_rotation_angle.value; //float v = config.wiping_volume.value; - float depth = print.wipe_tower_data(filaments_count).depth; - //float brim_width = print.wipe_tower_data(filaments_count).brim_width; - - if (config.wipe_tower_wall_type.value == WipeTowerWallType::wtwRib) - width = depth; + // The estimate resolves the effective width (a rib wall squares the tower). + const WipeTowerData &wipe_tower_estimate = print.wipe_tower_data(filaments_count); + float width = wipe_tower_estimate.width; + float depth = wipe_tower_estimate.depth; + float brim_width = wipe_tower_estimate.brim_width; Polygons convex_hulls_temp; if (print.has_wipe_tower()) { @@ -1066,36 +1068,54 @@ static StringObjectException layered_print_cleareance_valid(const Print &print, convex_hulls_temp.push_back(wipe_tower_polygon); } } + // Post-generation the mesh bottom already carries the brim. Pre-generation the body grows + // by the brim only when its width is explicit; the auto brim and a Type2 cone base depend on + // the tower height, exact only once generated, so they only warn here - the exact footprint + // is re-checked in _make_wipe_tower. + const bool exact_footprint = print.is_step_done(psWipeTower); + Polygons tower_polys_checked = (!exact_footprint && config.prime_tower_brim_width.value >= 0) ? + offset(convex_hulls_temp, float(scale_(brim_width))) : + convex_hulls_temp; + Polygons tower_polys_estimated; + if (!exact_footprint && !convex_hulls_temp.empty()) { + double max_height = 0.; + for (const PrintObject *object : print.objects()) + max_height = std::max(max_height, unscale_(object->size().z())); + Polygon base = estimate_wipe_tower_first_layer_outline(config, print.wipe_tower_type(), width, depth, max_height); + base.rotate(Geometry::deg2rad(a)); + base.translate(Point(scale_(x), scale_(y))); + tower_polys_estimated = offset(base, float(scale_(brim_width))); + } + // Object proximity stays a body-only warning: brim near-misses would newly warn on + // many setups that print fine. if (!intersection(convex_hulls_other, convex_hulls_temp).empty()) { if (warning) { warning->string += L("Prime Tower") + L(" is too close to others, and collisions may be caused.\n"); } } - if (!intersection(exclude_polys, convex_hulls_temp).empty()) { - /*if (warning) { - warning->string += L("Prime Tower is too close to exclusion area, there may be collisions when printing.\n"); - }*/ + if (!intersection(exclude_polys, tower_polys_checked).empty()) { return {L("Prime Tower") + L(" is too close to an exclusion area, and collisions will be caused.\n")}; } - if (print_config.enable_wrapping_detection.value && !intersection({wrapping_poly}, convex_hulls_temp).empty()) { + if (print_config.enable_wrapping_detection.value && !intersection({wrapping_poly}, tower_polys_checked).empty()) { return {L("Prime Tower") + L(" is too close to clumping detection area, and collisions will be caused.\n")}; } - // Skip the containment check for towers that will never be printed (single-filament - // prints without smooth timelapse keep the config's tower position but emit nothing). - // Pre-generation only the body square is tested — the auto-brim estimate can overshoot - // the generated brim by several mm and must not hard-fail a print that physically fits. - // Post-generation the mesh bottom already includes the real brim, so the exact - // footprint is tested. - if (filaments_count > 1 || print.enable_timelapse_print()) { - // The shared printable polygon is plate-local, while the tower polygons above are - // already shifted by the plate origin. - Polygons printable_polys = print.get_extruder_shared_printable_polygon(); - const Point plate_shift(scale_(plate_origin.x()), scale_(plate_origin.y())); - for (Polygon &p : printable_polys) - p.translate(plate_shift); - if (!diff(convex_hulls_temp, printable_polys).empty()) - return {L("Prime Tower") + L(" is partially outside the printable area, and it cannot be printed.\n")}; + if (warning && !intersection(exclude_polys, tower_polys_estimated).empty()) { + warning->string += L("Prime Tower") + L(" is too close to exclusion area, there may be collisions when printing.") + "\n"; } + if (warning && print_config.enable_wrapping_detection.value && !intersection({wrapping_poly}, tower_polys_estimated).empty()) { + warning->string += L("Prime Tower") + L(" is too close to clumping detection area, there may be collisions when printing.") + "\n"; + } + // No gate on "is there a tower": one that is not printed estimates to zero, so the hulls + // are degenerate and every check passes. Re-deriving it here missed the wrapping-detection + // tower on a single-filament plate. + Polygons printable_polys = print.get_extruder_shared_printable_polygon(); + const Point plate_shift(scale_(plate_origin.x()), scale_(plate_origin.y())); + for (Polygon &p : printable_polys) + p.translate(plate_shift); + if (!diff(tower_polys_checked, printable_polys).empty()) + return {L("Prime Tower") + L(" is partially outside the printable area, and it cannot be printed.\n")}; + if (warning && !diff(tower_polys_estimated, printable_polys).empty()) + warning->string += L("Prime Tower") + L(" is partially outside the printable area, and it cannot be printed.\n"); return {}; } @@ -3997,74 +4017,25 @@ bool Print::has_wipe_tower() const const WipeTowerData &Print::wipe_tower_data(size_t filaments_cnt) const { - // If the wipe tower wasn't created yet, make sure the depth and brim_width members are set to default. - double max_height = 0; - for (size_t obj_idx = 0; obj_idx < m_objects.size(); obj_idx++) { - double object_z = (double) m_objects[obj_idx]->size().z(); - max_height = std::max(unscale_(object_z), max_height); + // Until the tower is generated, size it with the estimate the GUI/CLI placement uses, so + // validation cannot reject a position the clamp just accepted. + if (is_step_done(psWipeTower) || filaments_cnt == 0) + return m_wipe_tower_data; + + double max_height = 0.; + double layer_height = std::numeric_limits::max(); + for (const PrintObject *object : m_objects) { + max_height = std::max(max_height, unscale_(double(object->size().z()))); + layer_height = std::min(layer_height, object->config().layer_height.value); } - if (max_height < EPSILON) return m_wipe_tower_data; + if (max_height < EPSILON) + return m_wipe_tower_data; - double layer_height = 0.08f; // hard code layer height - layer_height = m_objects.front()->config().layer_height.value; - - auto timelapse_type = config().option>("timelapse_type"); - bool need_wipe_tower = (timelapse_type ? (timelapse_type->value == TimelapseType::tlSmooth) : false) | (m_config.wipe_tower_wall_type.value == WipeTowerWallType::wtwRib); - double extra_spacing = config().option("prime_tower_infill_gap")->getFloat() / 100.; - double rib_width = config().option("wipe_tower_rib_width")->getFloat(); - - double filament_change_volume = 0.; - { - std::vector filament_change_lengths; - auto filament_change_lengths_opt = config().option("filament_change_length"); - if (filament_change_lengths_opt) filament_change_lengths = filament_change_lengths_opt->values; - double length = filament_change_lengths.empty() ? 0 : *std::max_element(filament_change_lengths.begin(), filament_change_lengths.end()); - double diameter = 1.75; - std::vector diameters; - auto filament_diameter_opt = config().option("filament_diameter"); - if (filament_diameter_opt) diameters = filament_diameter_opt->values; - diameter = diameters.empty() ? diameter : *std::max_element(diameters.begin(), diameters.end()); - filament_change_volume = length * PI * diameter * diameter / 4.; - } - - - if (! is_step_done(psWipeTower) && filaments_cnt !=0) { - double wipe_volume = m_config.prime_volume; - int filament_depth_count = m_config.nozzle_diameter.values.size() == 2 ? filaments_cnt : filaments_cnt - 1; - if (filaments_cnt == 1 && enable_timelapse_print()) filament_depth_count = 1; - double volume = wipe_volume * filament_depth_count; - if (m_config.nozzle_diameter.values.size() == 2) volume += filament_change_volume * (int) (filaments_cnt / 2); - - // Sizing should take into account currently set wiping volumes. - // For a long time, the initial preview would just use 900/width per toolchange (15mm on a 60mm wide tower) - // and it worked well enough. Let's try to do slightly better by accounting for the purging volumes. - const bool semm_flush = m_config.purge_in_prime_tower && m_config.single_extruder_multi_material; - if (semm_flush) volume = WipeTower2::estimate_semm_flush_volume(m_config, filaments_cnt); - - if (m_config.wipe_tower_wall_type.value == WipeTowerWallType::wtwRib) { - double depth = std::sqrt(volume / layer_height * extra_spacing); - if (need_wipe_tower || filaments_cnt > 1) { - float min_wipe_tower_depth = WipeTower::get_limit_depth_by_height(max_height); - depth = std::max((double) min_wipe_tower_depth, depth); - depth += rib_width / std::sqrt(2) + config().wipe_tower_extra_rib_length.value; - const_cast(this)->m_wipe_tower_data.depth = depth; - const_cast(this)->m_wipe_tower_data.brim_width = m_config.prime_tower_brim_width; - } - } - else { - double width = m_config.prime_tower_width; - double depth = volume / (layer_height * width); - // The flush volumes already hold the spacing between wipes. - if (!semm_flush) depth *= extra_spacing; - if (need_wipe_tower || depth > EPSILON) { - float min_wipe_tower_depth = WipeTower::get_limit_depth_by_height(max_height); - depth = std::max((double) min_wipe_tower_depth, depth); - } - const_cast(this)->m_wipe_tower_data.depth = depth; - const_cast(this)->m_wipe_tower_data.brim_width = m_config.prime_tower_brim_width; - } - if (m_config.prime_tower_brim_width < 0) const_cast(this)->m_wipe_tower_data.brim_width = WipeTower::get_auto_brim_by_height(max_height); - } + const WipeTowerFootprint footprint = estimate_wipe_tower_footprint(m_config, this->wipe_tower_type(), this->extruders(true), layer_height, max_height); + WipeTowerData &data = const_cast(this)->m_wipe_tower_data; + data.depth = float(footprint.depth); + data.width = float(footprint.width); + data.brim_width = float(footprint.brim_width); return m_wipe_tower_data; } @@ -4290,6 +4261,7 @@ void Print::_make_wipe_tower() m_wipe_tower_data.tool_changes.reserve(m_wipe_tower_data.tool_ordering.layer_tools().size()); wipe_tower.generate_new(m_wipe_tower_data.tool_changes); m_wipe_tower_data.depth = wipe_tower.get_depth(); + m_wipe_tower_data.width = wipe_tower.width(); m_wipe_tower_data.brim_width = wipe_tower.get_brim_width(); m_wipe_tower_data.bbx = wipe_tower.get_bbx(); m_wipe_tower_data.rib_offset = wipe_tower.get_rib_offset(); @@ -4403,6 +4375,7 @@ void Print::_make_wipe_tower() m_wipe_tower_data.tool_changes.reserve(m_wipe_tower_data.tool_ordering.layer_tools().size()); wipe_tower.generate(m_wipe_tower_data.tool_changes); m_wipe_tower_data.depth = wipe_tower.get_depth(); + m_wipe_tower_data.width = wipe_tower.width(); m_wipe_tower_data.z_and_depth_pairs = wipe_tower.get_z_and_depth_pairs(); m_wipe_tower_data.brim_width = wipe_tower.get_brim_width(); m_wipe_tower_data.height = wipe_tower.get_wipe_tower_height(); @@ -4438,7 +4411,9 @@ void Print::_make_wipe_tower() wipe_tower.get_wipe_tower_height(), wipe_tower.get_brim_width(), config().wipe_tower_wall_type.value == WipeTowerWallType::wtwRib, wipe_tower.get_rib_width(), wipe_tower.get_rib_length(), - config().wipe_tower_fillet_wall.value); + config().wipe_tower_fillet_wall.value, + config().wipe_tower_wall_type.value == WipeTowerWallType::wtwCone ? + (float) config().wipe_tower_cone_angle.value : 0.f); const Vec3d origin = Vec3d::Zero(); // FakeWipeTower::pos is a bed-frame translation applied after rotation // (getFakeExtrusionPathsFromWipeTower2 rotates about the local origin), so the @@ -4451,6 +4426,28 @@ void Print::_make_wipe_tower() config().wipe_tower_rotation_angle, config().wipe_tower_cone_angle, {scale_(origin.x()), scale_(origin.y())}); } + + // The clamps and checks above work from estimates; re-test the exact generated footprint + // so an off-plate tower fails with a clear error instead of exporting unprintable G-code + // (validate() only sees the mesh on its next run). + if (m_wipe_tower_data.wipe_tower_mesh_data) { + Polygon footprint = m_wipe_tower_data.wipe_tower_mesh_data->bottom; // includes brim and rib offset + footprint.rotate(Geometry::deg2rad(m_config.wipe_tower_rotation_angle.value)); + footprint.translate(Point(scale_(m_config.wipe_tower_x.get_at(m_plate_index)), + scale_(m_config.wipe_tower_y.get_at(m_plate_index)))); + const Polygons printable_polys = this->get_extruder_shared_printable_polygon(); + if (!printable_polys.empty() && !diff(Polygons{footprint}, printable_polys).empty()) { + const BoundingBox fp = get_extents(footprint); + const BoundingBox pr = get_extents(printable_polys); + BOOST_LOG_TRIVIAL(error) << boost::format("wipe tower footprint [%1%,%2%]-[%3%,%4%] leaves printable [%5%,%6%]-[%7%,%8%]") % + unscaled(fp.min.x()) % unscaled(fp.min.y()) % unscaled(fp.max.x()) % unscaled(fp.max.y()) % + unscaled(pr.min.x()) % unscaled(pr.min.y()) % unscaled(pr.max.x()) % unscaled(pr.max.y()); + throw Slic3r::SlicingError(L("Prime Tower") + L(" is partially outside the printable area, and it cannot be printed.\n")); + } + // The cutter/purge corner is a physical obstacle — the brim must stay out like the body. + if (!intersection(get_bed_excluded_area(m_config), Polygons{footprint}).empty()) + throw Slic3r::SlicingError(L("Prime Tower") + L(" is too close to an exclusion area, and collisions will be caused.\n")); + } } // Generate a recommended G-code output file name based on the format template, default extension, and template parameters @@ -5999,17 +5996,26 @@ ExtrusionLayers FakeWipeTower::getTrueExtrusionLayersFromWipeTower() const } return wtels; } -void WipeTowerData::construct_mesh(float width, float depth, float height, float brim_width, bool is_rib_wipe_tower, float rib_width, float rib_length,bool fillet_wall) +void WipeTowerData::construct_mesh(float width, float depth, float height, float brim_width, bool is_rib_wipe_tower, float rib_width, float rib_length,bool fillet_wall, float cone_angle) { wipe_tower_mesh_data = WipeTowerMeshData{}; float first_layer_height=0.08; //brim height if (width < EPSILON || depth < EPSILON || height < EPSILON) return; - if (!is_rib_wipe_tower || rib_length < EPSILON) { + if (cone_angle > EPSILON && (!is_rib_wipe_tower || rib_length < EPSILON)) { + // Cone tower: the base bulges past the body box; this bottom polygon feeds the + // containment checks, so it must carry the bulge and the brim (cone not lofted). + wipe_tower_mesh_data->real_wipe_tower_mesh = make_cube(width, depth, height); + wipe_tower_mesh_data->bottom = WipeTower2::cone_base_polygon(width, depth, height, cone_angle); + auto brim_bottom = offset(wipe_tower_mesh_data->bottom, scaled(brim_width)); + if (!brim_bottom.empty()) + wipe_tower_mesh_data->bottom = brim_bottom.front(); + wipe_tower_mesh_data->real_brim_mesh = WipeTower::its_make_rib_brim(wipe_tower_mesh_data->bottom, first_layer_height); + } else if (!is_rib_wipe_tower || rib_length < EPSILON) { wipe_tower_mesh_data->real_wipe_tower_mesh = make_cube(width, depth, height); wipe_tower_mesh_data->real_brim_mesh = make_cube(width + 2 * brim_width, depth + 2 * brim_width, first_layer_height); wipe_tower_mesh_data->real_brim_mesh.translate({-brim_width, -brim_width, 0}); - wipe_tower_mesh_data->bottom = {scaled(Vec2f{-brim_width, -brim_width}), scaled(Vec2f{width + brim_width, 0}), scaled(Vec2f{width + brim_width, depth + brim_width}), - scaled(Vec2f{0, depth})}; + wipe_tower_mesh_data->bottom = {scaled(Vec2f{-brim_width, -brim_width}), scaled(Vec2f{width + brim_width, -brim_width}), + scaled(Vec2f{width + brim_width, depth + brim_width}), scaled(Vec2f{-brim_width, depth + brim_width})}; } else { wipe_tower_mesh_data->real_wipe_tower_mesh = WipeTower::its_make_rib_tower(width, depth, height, rib_length, rib_width, fillet_wall); wipe_tower_mesh_data->bottom = WipeTower::rib_section(width, depth, rib_length, rib_width, fillet_wall); diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index 9e20061501..9822c5520c 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -782,6 +782,9 @@ struct WipeTowerData // Depth of the wipe tower to pass to GLCanvas3D for exact bounding box: float depth; + // Effective width (a rib wall squares the tower): the estimate until generation, then the + // generated width, so it never disagrees with depth. + float width; std::vector> z_and_depth_pairs; float brim_width; float height; @@ -795,12 +798,13 @@ struct WipeTowerData used_filament.clear(); number_of_toolchanges = -1; depth = 0.f; + width = 0.f; brim_width = 0.f; height = 0.f; rib_offset = Vec2f::Zero(); wipe_tower_mesh_data = std::nullopt; } - void construct_mesh(float width, float depth, float height, float brim_width, bool is_rib_wipe_tower, float rib_width, float rib_length, bool fillet_wall); + void construct_mesh(float width, float depth, float height, float brim_width, bool is_rib_wipe_tower, float rib_width, float rib_length, bool fillet_wall, float cone_angle = 0.f); private: // Only allow the WipeTowerData to be instantiated internally by Print, diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 1db476eede..4c27995ba0 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -5430,7 +5430,7 @@ void PrintConfigDef::init_fff_params() def->mode = comAdvanced; def->readonly = false; def->nullable = true; - def->set_default_value(new ConfigOptionFloatsNullable { {0.0} }); + def->set_default_value(new ConfigOptionFloatsNullable { 0.0 }); def = this->add("cooling_tube_retraction", coFloat); def->label = L("Cooling tube position"); diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index a228bb7436..54378b3b16 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -21,9 +21,11 @@ #include "TriangleMeshSlicer.hpp" #include "Utils.hpp" #include "Fill/FillAdaptive.hpp" +#include "Fill/Fill.hpp" #include "Fill/FillLightning.hpp" #include "Format/STL.hpp" #include "format.hpp" +#include "AABBTreeIndirect.hpp" #include "AABBTreeLines.hpp" #include @@ -672,6 +674,98 @@ void PrintObject::prepare_infill() } // for each region #endif /* SLIC3R_DEBUG_SLICE_PROCESSING */ + // Orca: precompute the object's 3D connected bodies for separated infills / per-model + // centering. Two islands belong to the same body when their slices overlap on adjacent + // layers; islands that only overlap in top-down projection but never touch (e.g. interleaved + // chain links) stay separate, matching "split to objects". Each layer island then records + // the full bounding box of its body, so its infill is centered on that body as if it were + // sliced alone. Compute this before bridges so anchors and extrusion share the same origin. + bool needs_separated_components = false; + for (size_t i = 0; i < this->num_printing_regions(); ++ i) { + const PrintRegionConfig &rc = this->printing_region(i).config(); + if (rc.separated_infills || rc.center_of_surface_pattern == CenterOfSurfacePattern::Each_Model) { + needs_separated_components = true; + break; + } + } + // Orca: Fast path: the feature only changes anything when the object is made of more than one + // connected body. Detect that cheaply the same way as "Split to objects" — more than one + // model part, or a single part whose mesh is splittable (is_splittable() is cached). A single + // body already shares the object center, i.e. the default, so skip the connectivity pass. + if (needs_separated_components) { + int parts = 0; + const ModelVolume *first_part = nullptr; + for (const ModelVolume *v : this->model_object()->volumes) + if (v->is_model_part()) { ++ parts; first_part = v; } + if (parts <= 1 && ! (first_part != nullptr && first_part->is_splittable())) + needs_separated_components = false; + } + for (Layer *layer : m_layers) + layer->lslices_separated_component_bboxes.clear(); + if (needs_separated_components) { + const size_t nl = m_layers.size(); + std::vector offset(nl + 1, 0); // Orca: flat index of the first island of each layer + for (size_t i = 0; i < nl; ++ i) + offset[i + 1] = offset[i] + m_layers[i]->lslices.size(); + const size_t nreg = offset[nl]; + // Orca: Union-find over every (layer, island). + std::vector parent(nreg); + for (size_t i = 0; i < nreg; ++ i) parent[i] = i; + auto find = [&parent](size_t x) { + while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } + return x; + }; + auto unite = [&](size_t a, size_t b) { a = find(a); b = find(b); if (a != b) parent[a] = b; }; + // Orca: Index the smaller of two consecutive layers instead of scanning every + // pair of islands. The tree prunes distant boxes on fragmented models; exact + // polygon intersections still decide connectivity for the remaining candidates. + for (size_t i = 0; i + 1 < nl; ++ i) { + m_print->throw_if_canceled(); + size_t layer_a = i, layer_b = i + 1; + if (m_layers[layer_a]->lslices.size() < m_layers[layer_b]->lslices.size()) + std::swap(layer_a, layer_b); + const Layer *la = m_layers[layer_a], *lb = m_layers[layer_b]; + if (lb->lslices.empty()) + continue; + + using IslandTree = AABBTreeIndirect::Tree<2, coord_t>; + std::vector bboxes; + bboxes.reserve(lb->lslices.size()); + for (size_t b = 0; b < lb->lslices.size(); ++ b) + bboxes.emplace_back(b, lb->lslices_bboxes[b]); + IslandTree tree; + tree.build_modify_input(bboxes); + for (size_t a = 0; a < la->lslices.size(); ++ a) { + const IslandTree::BoundingBox query(la->lslices_bboxes[a].min, la->lslices_bboxes[a].max); + AABBTreeIndirect::traverse(tree, + [&query](const IslandTree::Node &node) { return node.bbox.intersects(query); }, + [&](const IslandTree::Node &node) { + const size_t b = node.idx; + // Orca: Tree boxes include an epsilon, so retain the original box + // filter. Already-connected islands cannot change the partition + // and need no further polygon intersection. + if (la->lslices_bboxes[a].overlap(lb->lslices_bboxes[b]) && + find(offset[layer_a] + a) != find(offset[layer_b] + b) && + ! intersection_ex(la->lslices[a], lb->lslices[b]).empty()) + unite(offset[layer_a] + a, offset[layer_b] + b); + return true; + }); + } + } + // Orca: Full bounding box of each body, indexed by its union-find root. + std::vector body_bbox(nreg); + for (size_t i = 0; i < nl; ++ i) + for (size_t a = 0; a < m_layers[i]->lslices.size(); ++ a) + body_bbox[find(offset[i] + a)].merge(m_layers[i]->lslices_bboxes[a]); + // Orca: Store the body bbox for every island. + for (size_t i = 0; i < nl; ++ i) { + Layer *layer = m_layers[i]; + layer->lslices_separated_component_bboxes.resize(layer->lslices.size()); + for (size_t a = 0; a < layer->lslices.size(); ++ a) + layer->lslices_separated_component_bboxes[a] = body_bbox[find(offset[i] + a)]; + } + } + // the following step needs to be done before combination because it may need // to remove only half of the combined infill this->bridge_over_infill(); @@ -706,71 +800,6 @@ void PrintObject::infill() if (this->set_started(posInfill)) { m_print->set_status(35, L("Generating infill toolpath")); - // Orca: precompute the object's 3D connected bodies for separated infills / per-model - // centering. Two islands belong to the same body when their slices overlap on adjacent - // layers; islands that only overlap in top-down projection but never touch (e.g. interleaved - // chain links) stay separate, matching "split to objects". Each layer island then records - // the full bounding box of its body, so its infill is centered on that body as if it were - // sliced alone. Done once here, before the parallel fill, and only when a region needs it. - bool needs_separated_components = false; - for (size_t i = 0; i < this->num_printing_regions(); ++ i) { - const PrintRegionConfig &rc = this->printing_region(i).config(); - if (rc.separated_infills || rc.center_of_surface_pattern == CenterOfSurfacePattern::Each_Model) { - needs_separated_components = true; - break; - } - } - // Fast path: the feature only changes anything when the object is made of more than one - // connected body. Detect that cheaply the same way as "Split to objects" — more than one - // model part, or a single part whose mesh is splittable (is_splittable() is cached). A single - // body already shares the object center, i.e. the default, so skip the connectivity pass. - if (needs_separated_components) { - int parts = 0; - const ModelVolume *first_part = nullptr; - for (const ModelVolume *v : this->model_object()->volumes) - if (v->is_model_part()) { ++ parts; first_part = v; } - if (parts <= 1 && ! (first_part != nullptr && first_part->is_splittable())) - needs_separated_components = false; - } - for (Layer *layer : m_layers) - layer->lslices_separated_component_bboxes.clear(); - if (needs_separated_components) { - const size_t nl = m_layers.size(); - std::vector offset(nl + 1, 0); // flat index of the first island of each layer - for (size_t i = 0; i < nl; ++ i) - offset[i + 1] = offset[i] + m_layers[i]->lslices.size(); - const size_t nreg = offset[nl]; - // Union-find over every (layer, island). - std::vector parent(nreg); - for (size_t i = 0; i < nreg; ++ i) parent[i] = i; - auto find = [&parent](size_t x) { - while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } - return x; - }; - auto unite = [&](size_t a, size_t b) { a = find(a); b = find(b); if (a != b) parent[a] = b; }; - // Join islands that overlap between two consecutive layers. - for (size_t i = 0; i + 1 < nl; ++ i) { - const Layer *la = m_layers[i], *lb = m_layers[i + 1]; - for (size_t a = 0; a < la->lslices.size(); ++ a) - for (size_t b = 0; b < lb->lslices.size(); ++ b) - if (la->lslices_bboxes[a].overlap(lb->lslices_bboxes[b]) && - ! intersection_ex(la->lslices[a], lb->lslices[b]).empty()) - unite(offset[i] + a, offset[i + 1] + b); - } - // Full bounding box of each body, indexed by its union-find root. - std::vector body_bbox(nreg); - for (size_t i = 0; i < nl; ++ i) - for (size_t a = 0; a < m_layers[i]->lslices.size(); ++ a) - body_bbox[find(offset[i] + a)].merge(m_layers[i]->lslices_bboxes[a]); - // Store the body bbox for every island. - for (size_t i = 0; i < nl; ++ i) { - Layer *layer = m_layers[i]; - layer->lslices_separated_component_bboxes.resize(layer->lslices.size()); - for (size_t a = 0; a < layer->lslices.size(); ++ a) - layer->lslices_separated_component_bboxes[a] = body_bbox[find(offset[i] + a)]; - } - } - const auto& adaptive_fill_octree = this->m_adaptive_fill_octrees.first; const auto& support_fill_octree = this->m_adaptive_fill_octrees.second; @@ -1401,8 +1430,6 @@ bool PrintObject::invalidate_state_by_config_options( || opt_key == "infill_anchor_max" || opt_key == "top_surface_line_width" || opt_key == "bottom_surface_density" - || opt_key == "center_of_surface_pattern" - || opt_key == "separated_infills" || opt_key == "initial_layer_line_width" || opt_key == "small_area_infill_flow_compensation" || opt_key == "lateral_lattice_angle_1" @@ -1410,6 +1437,10 @@ bool PrintObject::invalidate_state_by_config_options( || opt_key == "infill_overhang_angle") { steps.emplace_back(posInfill); } else if (opt_key == "sparse_infill_pattern" + // Orca: Body centering now also determines bridge anchors during preparation. + // Invalidating preparation also invalidates infill, including top/bottom surfaces. + || opt_key == "center_of_surface_pattern" + || opt_key == "separated_infills" || opt_key == "sparse_infill_smooth_factor" || opt_key == "symmetric_infill_y_axis" || opt_key == "infill_shift_step" @@ -1480,13 +1511,9 @@ bool PrintObject::invalidate_state_by_config_options( steps.emplace_back(posPerimeters); steps.emplace_back(posSupportMaterial); } else if (opt_key == "bridge_flow" || opt_key == "internal_bridge_flow") { - if (m_config.support_top_z_distance > 0.) { - // Only invalidate due to bridging if bridging is enabled. - // If later "support_top_z_distance" is modified, the complete PrintObject is invalidated anyway. - steps.emplace_back(posPerimeters); - steps.emplace_back(posInfill); - steps.emplace_back(posSupportMaterial); - } + steps.emplace_back(posPerimeters); + steps.emplace_back(posInfill); + steps.emplace_back(posSupportMaterial); } else if ( opt_key == "wall_generator" || opt_key == "wall_transition_length" @@ -1604,7 +1631,9 @@ bool PrintObject::invalidate_step(PrintObjectStep step) bool PrintObject::invalidate_all_steps() { // First call the "invalidate" functions, which may cancel background processing. - bool result = Inherited::invalidate_all_steps() | m_print->invalidate_all_steps(); + const bool inherited_invalidated = Inherited::invalidate_all_steps(); + const bool print_invalidated = m_print->invalidate_all_steps(); + bool result = inherited_invalidated || print_invalidated; // Then reset some of the depending values. m_slicing_params.valid = false; return result; @@ -3009,21 +3038,12 @@ void PrintObject::bridge_over_infill() return diff(layers_sparse_infill, not_sparse_infill); }; - // LAMBDA do determine optimal bridging angle - auto determine_bridging_angle = [](const Polygons &bridged_area, const Lines &anchors, InfillPattern dominant_pattern, double infill_direction) { + // Orca: Derive the fallback bridge direction from the supplied anchor geometry. + // Pattern-specific angle selection belongs at the call site, where the supporting + // layer and region are known; this helper must not override it with a base config angle. + auto determine_bridging_angle = [](const Polygons &bridged_area, const Lines &anchors) { AABBTreeLines::LinesDistancer lines_tree(anchors); - // Orca: since 3D Honeycomb was "fixed" by forcing coordf_t layerHeight = scale_(1.0), this is no longer needed. - // CorssHatch also does not need fixed angle. - // - // Check it the infill that require a fixed infill angle. - //switch (dominant_pattern) { - //case ip3DHoneycomb: - //case ipCrossHatch: - // return (infill_direction + 45.0) * 2.0 * M_PI / 360.; - //default: break; - //} - std::map counted_directions; for (const Polygon &p : bridged_area) { double acc_distance = 0; @@ -3089,18 +3109,15 @@ void PrintObject::bridge_over_infill() if (bridging_angle == 0) { bridging_angle = 0.001; } - switch (dominant_pattern) { - case ipHilbertCurve: bridging_angle += 0.25 * PI; break; - case ipOctagramSpiral: bridging_angle += (1.0 / 16.0) * PI; break; - default: break; - } return bridging_angle; }; - // LAMBDA that will fill given polygons with lines, exapand the lines to the nearest anchor, and reconstruct polygons from the newly - // generated lines - auto construct_anchored_polygon = [](Polygons bridged_area, Lines anchors, const Flow &bridging_flow, double bridging_angle) { + // Orca: Extend scan sections to the nearest anchors and reconstruct the bridge area. + // scan_spacing controls boundary sampling independently of the extrusion spacing; + // anchoring overlap and smoothing thresholds still use the physical bridging flow. + auto construct_anchored_polygon = [](Polygons bridged_area, Lines anchors, const Flow &bridging_flow, double bridging_angle, + coord_t scan_spacing, bool restore_anchors = false) { auto lines_rotate = [](Lines &lines, double cos_angle, double sin_angle) { for (Line &l : lines) { double ax = double(l.a.x()); @@ -3127,12 +3144,12 @@ void PrintObject::bridge_over_infill() BoundingBox bb_x = get_extents(bridged_area); BoundingBox bb_y = get_extents(anchors); - const size_t n_vlines = (bb_x.max.x() - bb_x.min.x() + bridging_flow.scaled_spacing() - 1) / bridging_flow.scaled_spacing(); + const size_t n_vlines = (bb_x.max.x() - bb_x.min.x() + scan_spacing - 1) / scan_spacing; std::vector vertical_lines(n_vlines); for (size_t i = 0; i < n_vlines; i++) { - // Orca: Make sure the line is placed in the middle of the extrusion - // coord_t x = bb_x.min.x() + i * bridging_flow.scaled_spacing(); - coord_t x = bb_x.min.x() + (i + 0.5) * bridging_flow.scaled_spacing(); + // Orca: Sample the center of each reconstructed strip. Its edges lie + // half a scan step away, even when the sampling is finer than extrusion. + coord_t x = bb_x.min.x() + (i + 0.5) * scan_spacing; coord_t y_min = bb_y.min.y() - bridging_flow.scaled_spacing(); coord_t y_max = bb_y.max.y() + bridging_flow.scaled_spacing(); vertical_lines[i].a = Point{x, y_min}; @@ -3155,7 +3172,11 @@ void PrintObject::bridge_over_infill() auto anchors_intersections = anchors_and_walls_tree.intersections_with_line(vertical_lines[i]); for (Line §ion : polygon_sections[i]) { - auto maybe_below_anchor = std::upper_bound(anchors_intersections.rbegin(), anchors_intersections.rend(), section.a, + // Orca: A repaired boundary may already overlap its anchor by one flow width. + // Include that overlap in the search so restoring rounded corners does not + // extend every already anchored section into the next sparse infill cell. + const coord_t overlap = restore_anchors ? bridging_flow.scaled_width() + SCALED_EPSILON : 0; + auto maybe_below_anchor = std::upper_bound(anchors_intersections.rbegin(), anchors_intersections.rend(), section.a + Point{0, overlap}, [](const Point &a, const std::pair &b) { return a.y() > b.first.y(); }); @@ -3164,7 +3185,7 @@ void PrintObject::bridge_over_infill() section.a.y() -= bridging_flow.scaled_width() * (0.5 + 0.5); } - auto maybe_upper_anchor = std::upper_bound(anchors_intersections.begin(), anchors_intersections.end(), section.b, + auto maybe_upper_anchor = std::upper_bound(anchors_intersections.begin(), anchors_intersections.end(), section.b - Point{0, overlap}, [](const Point &a, const std::pair &b) { return a.y() < b.first.y(); }); @@ -3194,7 +3215,9 @@ void PrintObject::bridge_over_infill() }); } - // reconstruct polygon from polygon sections + // Orca: Reconstruct the polygon from scan sections. At discontinuities and + // strip starts/ends, use half the scan step for the X offsets; using half an + // extrusion spacing would overlap the finer strips and distort curved anchors. struct TracedPoly { Points lows; @@ -3220,8 +3243,8 @@ void PrintObject::bridge_over_infill() 36.0 * double(bridging_flow.scaled_spacing()) * bridging_flow.scaled_spacing()) { traced_poly.lows.push_back(candidate->a); } else { - traced_poly.lows.push_back(traced_poly.lows.back() + Point{bridging_flow.scaled_spacing() / 2, 0}); - traced_poly.lows.push_back(candidate->a - Point{bridging_flow.scaled_spacing() / 2, 0}); + traced_poly.lows.push_back(traced_poly.lows.back() + Point{scan_spacing / 2, 0}); + traced_poly.lows.push_back(candidate->a - Point{scan_spacing / 2, 0}); traced_poly.lows.push_back(candidate->a); } @@ -3229,8 +3252,8 @@ void PrintObject::bridge_over_infill() 36.0 * double(bridging_flow.scaled_spacing()) * bridging_flow.scaled_spacing()) { traced_poly.highs.push_back(candidate->b); } else { - traced_poly.highs.push_back(traced_poly.highs.back() + Point{bridging_flow.scaled_spacing() / 2, 0}); - traced_poly.highs.push_back(candidate->b - Point{bridging_flow.scaled_spacing() / 2, 0}); + traced_poly.highs.push_back(traced_poly.highs.back() + Point{scan_spacing / 2, 0}); + traced_poly.highs.push_back(candidate->b - Point{scan_spacing / 2, 0}); traced_poly.highs.push_back(candidate->b); } segment_added = true; @@ -3238,9 +3261,9 @@ void PrintObject::bridge_over_infill() } if (!segment_added) { - // Zero overlapping segments, we just close this polygon - traced_poly.lows.push_back(traced_poly.lows.back() + Point{bridging_flow.scaled_spacing() / 2, 0}); - traced_poly.highs.push_back(traced_poly.highs.back() + Point{bridging_flow.scaled_spacing() / 2, 0}); + // Orca: No section continues this strip; close at its right edge. + traced_poly.lows.push_back(traced_poly.lows.back() + Point{scan_spacing / 2, 0}); + traced_poly.highs.push_back(traced_poly.highs.back() + Point{scan_spacing / 2, 0}); Polygon &new_poly = expanded_bridged_area.emplace_back(std::move(traced_poly.lows)); new_poly.points.insert(new_poly.points.end(), traced_poly.highs.rbegin(), traced_poly.highs.rend()); traced_poly.lows.clear(); @@ -3255,9 +3278,9 @@ void PrintObject::bridge_over_infill() for (const auto &segment : polygon_slice) { if (used_segments.find(&segment) == used_segments.end()) { TracedPoly &new_tp = current_traced_polys.emplace_back(); - new_tp.lows.push_back(segment.a - Point{bridging_flow.scaled_spacing() / 2, 0}); + new_tp.lows.push_back(segment.a - Point{scan_spacing / 2, 0}); new_tp.lows.push_back(segment.a); - new_tp.highs.push_back(segment.b - Point{bridging_flow.scaled_spacing() / 2, 0}); + new_tp.highs.push_back(segment.b - Point{scan_spacing / 2, 0}); new_tp.highs.push_back(segment.b); } } @@ -3364,7 +3387,10 @@ void PrintObject::bridge_over_infill() total_fill_area = closing(total_fill_area, float(SCALED_EPSILON)); expansion_area = closing(expansion_area, float(SCALED_EPSILON)); expansion_area = intersection(expansion_area, deep_infill_area); - Polylines anchors = intersection_pl(infill_lines[lidx - 1], shrink(expansion_area, spacing)); + // Orca: Preserve the real lower-layer anchors for every candidate in this + // layer. Replacing this shared set for one pattern also changes later regions, + // and synthetic straight lines can claim support where no infill is printed. + const Polylines anchors = intersection_pl(infill_lines[lidx - 1], shrink(expansion_area, spacing)); Polygons internal_unsupported_area = shrink(deep_infill_area, spacing * 4.5); #ifdef DEBUG_BRIDGE_OVER_INFILL @@ -3375,6 +3401,9 @@ void PrintObject::bridge_over_infill() std::vector expanded_surfaces; expanded_surfaces.reserve(surfaces_by_layer[lidx].size()); for (const CandidateSurface &candidate : surfaces_by_layer[lidx]) { + const auto ®ion_config = candidate.region->region().config(); + const bool turning_pattern = region_config.sparse_infill_pattern == ipHilbertCurve || + region_config.sparse_infill_pattern == ipOctagramSpiral; const Flow &flow = candidate.region->bridging_flow(frSolidInfill, true); Polygons area_to_be_bridge = expand(candidate.new_polys, flow.scaled_spacing()); area_to_be_bridge = intersection(area_to_be_bridge, deep_infill_area); @@ -3403,20 +3432,40 @@ void PrintObject::bridge_over_infill() to_lines(area_to_be_bridge), to_lines(boundary_plines), to_lines(anchors), to_lines(expansion_area)); #endif - double bridging_angle = 0; - if (!anchors.empty()) { - bridging_angle = determine_bridging_angle(area_to_be_bridge, to_lines(anchors), - candidate.region->region().config().sparse_infill_pattern.value, - candidate.region->region().config().infill_direction.value); - } else { - // use expansion boundaries as anchors. - // Also, use Infill pattern that is neutral for angle determination, since there are no infill lines. - bridging_angle = determine_bridging_angle(area_to_be_bridge, to_lines(boundary_plines), InfillPattern::ipLine, 0); + double bridging_angle = -1.; + if (!anchors.empty() && turning_pattern) { + // Orca: Keep adjacent bridges over Hilbert/Octagram aligned despite + // their many local turning directions. Use the lower layer's rotation, + // since that is the infill supporting the bridge, not the current layer's. + for (const LayerRegion *lower_region : layer->lower_layer->regions()) { + // Orca: Apply the configured direction only if the same region has + // sparse infill below this bridge. A height modifier may put another + // pattern underneath, requiring the geometry-based fallback below. + if (&lower_region->region() != &candidate.region->region() || + intersection(area_to_be_bridge, to_polygons(lower_region->fill_surfaces.filter_by_type(stInternal))).empty()) + continue; + bridging_angle = calculate_infill_rotation_angle(po, layer->lower_layer->id(), region_config.infill_direction.value, + region_config.sparse_infill_rotate_template.value) + 0.5 * PI; + // Orca: Apply model alignment as infill generation does, then normalize + // the undirected bridge angle to [0, PI), including negative rotations. + if (region_config.align_infill_direction_to_model) { + const auto &m = po->trafo().matrix(); + bridging_angle += std::atan2(double(m(1, 0)), double(m(0, 0))); + } + bridging_angle = std::fmod(bridging_angle, PI); + if (bridging_angle < 0.) + bridging_angle += PI; + break; + } } + // Orca: A different region below (e.g. a height modifier) needs the actual anchor + // directions. When there are no sparse anchors, use the expansion boundaries. + if (bridging_angle < 0.) + bridging_angle = determine_bridging_angle(area_to_be_bridge, to_lines(anchors.empty() ? boundary_plines : anchors)); - // ORCA: Internal bridge angle override + // Orca: Preserve the user's absolute or relative internal bridge angle + // override after automatic direction selection. if (candidate.region->region().config().internal_bridge_angle.value > 0) { - const auto ®ion_config = candidate.region->region().config(); const double custom_angle_rad = Geometry::deg2rad(region_config.internal_bridge_angle.value); if (region_config.relative_bridge_angle.value) bridging_angle += custom_angle_rad; @@ -3429,11 +3478,19 @@ void PrintObject::bridge_over_infill() } } + // Orca: Changing the bridge direction must not change its physical supports. + // Extend to actual sparse infill or the existing boundary anchors, never to + // a synthetic grid that merely has the same nominal angle and spacing. boundary_plines.insert(boundary_plines.end(), anchors.begin(), anchors.end()); if (!lightning_area.empty() && !intersection(area_to_be_bridge, lightning_area).empty()) { boundary_plines = intersection_pl(boundary_plines, expand(area_to_be_bridge, scale_(10))); } - Polygons bridging_area = construct_anchored_polygon(area_to_be_bridge, to_lines(boundary_plines), flow, bridging_angle); + // Orca: Use four samples per extrusion spacing for Hilbert/Octagram so the + // reconstructed boundary follows rounded anchors instead of cutting corners. + // Keep the original step for other patterns and at least one coordinate unit + // after integer division. This changes boundary accuracy, not infill density. + const coord_t scan_spacing = std::max(coord_t(1), flow.scaled_spacing() / (turning_pattern ? 4 : 1)); + Polygons bridging_area = construct_anchored_polygon(area_to_be_bridge, to_lines(boundary_plines), flow, bridging_angle, scan_spacing); // Check collision with other expanded surfaces { @@ -3447,7 +3504,9 @@ void PrintObject::bridge_over_infill() } } if (reconstruct) { - bridging_area = construct_anchored_polygon(area_to_be_bridge, to_lines(boundary_plines), flow, bridging_angle); + // Orca: Retain the same sampling accuracy when matching a nearby + // bridge's direction; rebuilding must not lose the curved supports. + bridging_area = construct_anchored_polygon(area_to_be_bridge, to_lines(boundary_plines), flow, bridging_angle, scan_spacing); } } @@ -3455,6 +3514,13 @@ void PrintObject::bridge_over_infill() // bridging_area = opening(bridging_area, flow.scaled_spacing()); bridging_area = opening(bridging_area, flow.scaled_spacing() * 0.75); bridging_area = closing(bridging_area, flow.scaled_spacing()); + // Orca: Opening/closing can pull rounded bridge ends away from their real + // supports. Restore those contacts after smoothing, preserving the cleaned + // area and the selected angle; do not smooth the restored contacts again. + if (turning_pattern && !bridging_area.empty()) { + bridging_area = union_(bridging_area, construct_anchored_polygon(bridging_area, to_lines(boundary_plines), flow, + bridging_angle, scan_spacing, true)); + } bridging_area = intersection(bridging_area, limiting_area); bridging_area = intersection(bridging_area, total_fill_area); bridging_area = diff(bridging_area, total_top_area); diff --git a/src/libslic3r/PublishSettings.cpp b/src/libslic3r/PublishSettings.cpp new file mode 100644 index 0000000000..6d28cc0af3 --- /dev/null +++ b/src/libslic3r/PublishSettings.cpp @@ -0,0 +1,315 @@ +#include "PublishSettings.hpp" + +#include "PresetBundle.hpp" +#include "Preset.hpp" +#include "PrintConfig.hpp" +#include "MaterialType.hpp" + +#include +#include + +#include +#include + +namespace Slic3r { + +std::string publish_base_key(const std::string &key) +{ + const size_t pos = key.find('#'); + return pos == std::string::npos ? key : key.substr(0, pos); +} + +// Parse the trailing "#N" variant index ("retraction_length#2" -> 2). Returns -1 when the key +// carries no '#' separator or its suffix is malformed; mirrors the importer's strict parse +// (PresetBundle.cpp) so the export side rejects the same variants the receiver would skip. +static int publish_variant_index(const std::string &key, const std::string &base_key) +{ + if (key.size() <= base_key.size() || key.compare(0, base_key.size(), base_key) != 0 || key[base_key.size()] != '#') + return -1; + const std::string suffix = key.substr(base_key.size() + 1); + if (suffix.empty()) + return -1; + int idx = 0; + for (const char c : suffix) { + if (c < '0' || c > '9') + return -1; + idx = idx * 10 + (c - '0'); + if (idx > 1000000) // overflow guard; real vector sizes are tiny + return -1; + } + return idx; +} + +std::string normalize_filament_type(const std::string& type) +{ + if (type.empty()) + return type; + if (MaterialType::find(type) != nullptr) + return type; + // "PLA High Speed" -> "PLA": strip a space-separated modifier, but keep dash-separated + // types like "PA-CF" / "PETG-CF" intact (they are distinct materials, not modifiers). + const size_t sep = type.find(' '); + if (sep != std::string::npos) { + const std::string base = type.substr(0, sep); + if (MaterialType::find(base) != nullptr) + return base; + } + return type; +} + +void make_publish_universal(DynamicPrintConfig &config) +{ + // Lists: empty => compatible with every printer / every print preset. Conditions: + // empty so a leftover expression left behind by the baseline clone can never + // re-narrow the match (see is_compatible_with_printer, Preset.cpp:840). All four + // keys exist on filament presets; nil-guard for hand-crafted future schemas. + if (auto *opt = config.opt("compatible_printers", false)) + opt->values.clear(); + if (auto *opt = config.opt("compatible_prints", false)) + opt->values.clear(); + if (auto *opt = config.opt("compatible_printers_condition", false)) + opt->value.clear(); + if (auto *opt = config.opt("compatible_prints_condition", false)) + opt->value.clear(); +} + +std::string publish_material_base_name(const std::string &preset_name) +{ + if (preset_name.empty()) + return preset_name; + const size_t at = preset_name.find('@'); + std::string base = (at == std::string::npos) ? preset_name : preset_name.substr(0, at); + boost::trim_right(base); + return base; +} + +const std::set& publish_structural_keys() +{ + // Non-publishable keys: the *_settings_id keys are also in PresetCollection::skipped_in_dirty + // (Preset.cpp) / stripped from configs (profile_print_params_same); publishing them would + // rewrite the user's preset inheritance/structure. + static const std::set structural_keys = { + "printer_settings_id", "filament_settings_id", "print_settings_id", + "sla_print_settings_id", "sla_material_settings_id", + "compatible_printers", "compatible_prints", + "compatible_printers_condition", "compatible_prints_condition", + "default_filament_profile", "default_print_profile", + "default_sla_print_profile", "default_sla_material_profile", + "extruder_count", "bed_shape", + "inherits", "inherits_group", + "printer_technology", "printer_model", "printer_variant", + "physical_printer_settings_id", "filament_ids", + "different_settings_to_system" + }; + return structural_keys; +} + +const std::set& publish_mixed_keys() +{ + // Must match PresetBundle's s_project_options mixed-color group (PresetBundle.cpp): these + // are project-level parallel per-slot arrays, not filament-preset options, so the import + // material pass applies them into project_config instead of a filament preset config. + static const std::set mixed_keys = { + "filament_is_mixed", + "filament_mixed_components", + "filament_mixed_sublayer_ratios", + "filament_mixed_gradient", + "filament_mixed_gradient_range", + "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" + }; + return mixed_keys; +} + +// The printer tab's "Retraction" optgroup (TabPrinter::build_fff, Tab.cpp), in tab order. +// KEEP IN SYNC with that optgroup: the published-3MF printer allowlist is built from these +// lists, so any key shown there must be publishable here (and vice versa). +const std::vector& publishable_printer_retraction_options() +{ + static const std::vector options = { + { "retraction_length", "printer_extruder_retraction#length" }, + { "retract_restart_extra", "printer_extruder_retraction#extra-length-on-restart" }, + { "retraction_speed", "printer_extruder_retraction#retraction-speed" }, + { "deretraction_speed", "printer_extruder_retraction#deretraction-speed" }, + { "retraction_minimum_travel", "printer_extruder_retraction#travel-distance-threshold" }, + { "retract_when_changing_layer", "printer_extruder_retraction#retract-on-layer-change" }, + { "wipe", "printer_extruder_retraction#wipe-while-retracting" }, + { "wipe_distance", "printer_extruder_retraction#wipe-distance" }, + { "retract_before_wipe", "printer_extruder_retraction#retract-amount-before-wipe" }, + { "retract_after_wipe", "printer_extruder_retraction#retract-amount-after-wipe" }, + }; + return options; +} + +// The printer tab's "Z-Hop" optgroup (TabPrinter::build_fff, Tab.cpp), in tab order. KEEP IN +// SYNC with that optgroup, same as publishable_printer_retraction_options(). +const std::vector& publishable_printer_z_hop_options() +{ + static const std::vector options = { + { "retract_lift_enforce", "printer_extruder_z_hop#on-surfaces" }, + { "z_hop_types", "printer_extruder_z_hop#z-hop-type" }, + { "z_hop", "printer_extruder_z_hop#z-hop-height" }, + { "travel_slope", "printer_extruder_z_hop#traveling-angle" }, + { "retract_lift_above", "printer_extruder_z_hop#only-lift-z-above" }, + { "retract_lift_below", "printer_extruder_z_hop#only-lift-z-below" }, + }; + return options; +} + +const std::set& publishable_printer_keys() +{ + // Union of the two optgroups; "Retraction when switching material" keys are excluded + // (toolchange retraction is device/profile territory, not a publishable behavior tweak). + static const std::set printer_keys = [] { + std::set keys; + for (const PublishablePrinterOption &opt : publishable_printer_retraction_options()) + keys.insert(opt.key); + for (const PublishablePrinterOption &opt : publishable_printer_z_hop_options()) + keys.insert(opt.key); + return keys; + }(); + return printer_keys; +} + +std::vector collect_dirty_settings_keys(const PresetBundle& bundle) +{ + std::set keys; + + // Union the dirty keys of each collection's edited preset (filaments may span multiple + // slots); feeds only the Publish dialog's pre-check. + for (const std::string& key : bundle.prints.current_dirty_options(true)) + keys.insert(key); + for (const std::string& key : bundle.printers.current_dirty_options(true)) + keys.insert(key); + for (const std::string& key : bundle.filaments.current_dirty_options(true)) + keys.insert(key); + + return std::vector(keys.begin(), keys.end()); +} + +DynamicPrintConfig filter_published_config( + const DynamicPrintConfig &full_config, + const std::vector &published_keys, + const std::vector &material_keys) +{ + DynamicPrintConfig filtered; + + std::set base_keys_to_include; + // Never masked (whole-vector serialization): identity, plate geometry, process keys and + // printer keys without a "#N" variant. + std::set mask_exempt_keys; + // Material entries: base key -> author slots whose values must survive; other slots are + // masked to their defaults so a publish (partial or full) does not leak unrelated slot + // data. + std::map> slot_mask_map; + + // 1. Mandatory material identity & slot count keys for 3MF validation/normalization + // (filament_ids: exported for validation, denylisted on apply - see publish_structural_keys). + static const std::vector s_material_identity_keys = { + "filament_colour", + "filament_type", + "filament_vendor", + "filament_ids", + "filament_diameter", + "filament_self_index", + "filament_extruder_variant" + }; + for (const std::string &key : s_material_identity_keys) { + base_keys_to_include.insert(key); + mask_exempt_keys.insert(key); + } + + // 2. Published plate / bed geometry keys (wipe tower positioning) + static const std::vector s_plate_geometry_keys = { + "wipe_tower_x", + "wipe_tower_y", + "wipe_tower_rotation_angle" + }; + for (const std::string &key : s_plate_geometry_keys) { + base_keys_to_include.insert(key); + mask_exempt_keys.insert(key); + } + + // 3. Process and printer published keys. Printer per-extruder keys carry a "#N" variant + // (e.g. retraction_length#2): mask the base to the author's extruder index so a partial + // publish does not serialize every extruder's value (same slot-masking as the material side). + const std::set &printer_keys = publishable_printer_keys(); + for (const std::string &key : published_keys) { + const std::string base_key = publish_base_key(key); + if (base_key.empty()) + continue; + base_keys_to_include.insert(base_key); + if (printer_keys.count(base_key) != 0) { + const int variant_idx = publish_variant_index(key, base_key); + if (variant_idx >= 0) + slot_mask_map[base_key].insert(variant_idx); + else + mask_exempt_keys.insert(base_key); // bare printer key or malformed variant: whole vector + } else { + mask_exempt_keys.insert(base_key); // process key: whole vector + } + } + + // 4. Material-specific published keys. Both partial (entry.keys) and full-publish + // (entry.full_keys) entries mask to the author's slot on export (see the copy loop below); + // slot-less entries (hand-crafted files) stay unmasked (whole vector). + for (const PublishedMaterialEntry &entry : material_keys) { + for (const std::string &key : entry.keys) { + const std::string base_key = publish_base_key(key); + if (base_key.empty()) + continue; + base_keys_to_include.insert(base_key); + if (entry.slot >= 0) + slot_mask_map[base_key].insert(entry.slot); + } + for (const std::string &key : entry.full_keys) { + const std::string base_key = publish_base_key(key); + if (base_key.empty()) + continue; + base_keys_to_include.insert(base_key); + if (entry.slot >= 0) + slot_mask_map[base_key].insert(entry.slot); + } + } + + // Masking restores every non-published slot of a vector option with the option default, so + // a partial publish does not leak unrelated slot data. can_mask_slots reports whether a key + // is maskable at all (vector option plus a registered default of the same type); an + // unmaskable key is dropped from the payload entirely instead of shipping the author's + // whole vector. + auto can_mask_slots = [](const ConfigOption &opt, const ConfigOptionDef *def) -> bool { + if (def == nullptr || !def->default_value || def->default_value->type() != opt.type()) + return false; + const auto *vec = dynamic_cast(&opt); + const auto *default_vec = dynamic_cast(def->default_value.get()); + return vec != nullptr && vec->size() > 0 && default_vec != nullptr && !default_vec->empty(); + }; + auto mask_slots = [](ConfigOption &opt, const ConfigOptionDef *def, const std::set &keep_slots) { + auto *vec = dynamic_cast(&opt); + for (size_t idx = 0; idx < vec->size(); ++idx) + if (keep_slots.count(static_cast(idx)) == 0) + vec->set_at(def->default_value.get(), idx, 0); + }; + + // Copy the selected options from full_config into the filtered config. + for (const std::string &key : base_keys_to_include) { + const ConfigOption *opt = full_config.option(key); + if (opt == nullptr) + continue; + const auto mask_it = slot_mask_map.find(key); + const bool needs_masking = mask_exempt_keys.count(key) == 0 && mask_it != slot_mask_map.end() && !mask_it->second.empty(); + if (needs_masking && !can_mask_slots(*opt, print_config_def.get(key))) { + BOOST_LOG_TRIVIAL(warning) << "publish: dropping unmaskable key \"" << key + << "\" from the published payload (no usable option default)"; + continue; + } + ConfigOption *cloned = opt->clone(); + if (needs_masking) + mask_slots(*cloned, print_config_def.get(key), mask_it->second); + filtered.set_key_value(key, cloned); + } + + return filtered; +} + +} // namespace Slic3r diff --git a/src/libslic3r/PublishSettings.hpp b/src/libslic3r/PublishSettings.hpp new file mode 100644 index 0000000000..60ea9a8354 --- /dev/null +++ b/src/libslic3r/PublishSettings.hpp @@ -0,0 +1,99 @@ +#pragma once +#include +#include +#include + +namespace Slic3r { +class PresetBundle; + +// Strip a trailing "#N" variant suffix ("retraction_length#2" -> "retraction_length"). +std::string publish_base_key(const std::string &key); + +// Structural keys that are never applied onto the receiver's presets when loading a published +// 3MF (single source of truth for the denylist); applying them would rewrite the user's preset +// inheritance/structure. filament_ids is still exported via the identity list (3MF validation +// needs it) - exported, never applied. +const std::set& publish_structural_keys(); + +// The mixed-color filament project keys (parallel per-slot arrays, see PresetBundle's +// s_project_options). Import applies them into project_config, not a filament preset. +const std::set& publish_mixed_keys(); + +// One row of the printer tab's "Retraction" / "Z-Hop" optgroups (config key + tab icon id). +struct PublishablePrinterOption { + const char *key; // config key, e.g. "retraction_length" + const char *icon; // tab icon id, e.g. "printer_extruder_retraction#length" +}; + +// The printer tab's "Retraction" / "Z-Hop" optgroup options, in tab order. +const std::vector& publishable_printer_retraction_options(); +const std::vector& publishable_printer_z_hop_options(); + +// Union of the two optgroup option lists; printer keys apply on import only if their base +// key is in this allowlist. +const std::set& publishable_printer_keys(); + +// Union of setting keys differing from the base/system preset across the current print, +// printer and filament presets (feeds the Publish dialog's pre-check). +std::vector collect_dirty_settings_keys(const PresetBundle& bundle); + +// Per-slot published material keys, applied positionally (author slot N -> receiver slot N). +// The identity fields drive the created copy's naming and grouping on Full entries, the +// notification labels, and the partial type gate (publish_type) is the author's explicit +// opt-in for requiring a material type. +struct PublishedMaterialEntry { + std::string filament_type; // material family, e.g. "PLA" (may be empty) + std::string filament_vendor; // e.g. "Generic", "Bambu" (may be empty) + std::string filament_id; // stable material id, e.g. "GFL99" (may be empty) + // Unique preset id of the author's slot preset (e.g. Orca Filament Library "setting_id"). + // Not matched against the receiver's library; carried so identical Full entries within one + // load share one created instance (within-load dedup key). + std::string setting_id; + // Canonical name of the author's slot preset (e.g. "Generic PLA @System"). On Full import + // it names the created copy after its "@variant" tail is stripped; never matched against + // the receiver's library. + std::string preset_name; + // 0-based author filament slot; -1 (hand-crafted files) is skipped. + int slot{-1}; + std::vector keys; + // "Full Publish": the whole filament preset (full_keys) is published. On the receiver Full + // Publish always creates a standalone parentless copy (libslic3r's "Detach from parent"), + // universally compatible and project-embedded only - never written to the user's library. + // Identical Full entries within one load share one created instance (within-load dedup). + bool full{false}; + // All non-structural filament keys of the author's slot preset; values travel in the file + // config, masked to the author's slot index. + std::vector full_keys; + // Vendor-agnostic (MaterialType) filament type the author requires for this slot; on a + // partial entry's mismatch the slot is replaced with a same-type filament. Full entries + // consult no gate. + bool publish_type{false}; + std::string publish_type_value; + // Required filament colour, applied on load regardless of the type match. + bool publish_color{false}; + std::string color; + // Import-side only, never serialized: the authored slot sits past the receiver's physical + // capacity, so the entry is appended as an empty mixed-filament placeholder (virtual tail + // slot; the GUI flags it for the user to assign components). + bool mixed_placeholder{false}; +}; + +// "PLA High Speed" -> "PLA" (strip a space modifier); dash types like "PA-CF" are kept intact. +std::string normalize_filament_type(const std::string& type); + +class DynamicPrintConfig; +// Clear the compatibility lists/conditions on a filament config so it is universally +// compatible once detached (empty lists + empty conditions = compatible with everything). +void make_publish_universal(DynamicPrintConfig &config); + +// Naming base for a detached published-material copy: "Generic PLA @System" -> "Generic PLA" +// (truncate/right-trim at the first '@' tail). Empty result means "fall back to identity". +std::string publish_material_base_name(const std::string &preset_name); + +// Minimal DynamicPrintConfig for a published 3MF export: only the selected published keys, +// material keys, identity fields and plate geometry keys. +DynamicPrintConfig filter_published_config( + const DynamicPrintConfig &full_config, + const std::vector &published_keys, + const std::vector &material_keys); +} diff --git a/src/libslic3r/SLAPrint.cpp b/src/libslic3r/SLAPrint.cpp index cdefd3e10e..eb37ca578c 100644 --- a/src/libslic3r/SLAPrint.cpp +++ b/src/libslic3r/SLAPrint.cpp @@ -1007,7 +1007,9 @@ bool SLAPrintObject::invalidate_step(SLAPrintObjectStep step) bool SLAPrintObject::invalidate_all_steps() { - return Inherited::invalidate_all_steps() | m_print->invalidate_all_steps(); + const bool inherited_invalidated = Inherited::invalidate_all_steps(); + const bool print_invalidated = m_print->invalidate_all_steps(); + return inherited_invalidated || print_invalidated; } double SLAPrintObject::get_elevation() const { diff --git a/src/libslic3r/Support/SupportMaterial.cpp b/src/libslic3r/Support/SupportMaterial.cpp index 12ac275592..a955eb5ca7 100644 --- a/src/libslic3r/Support/SupportMaterial.cpp +++ b/src/libslic3r/Support/SupportMaterial.cpp @@ -333,8 +333,7 @@ PrintObjectSupportMaterial::PrintObjectSupportMaterial(const PrintObject *object m_print_config (&object->print()->config()), m_object_config (&object->config()), m_slicing_params (slicing_params), - m_support_params (*object), - m_object (object) + m_support_params (*object) { } diff --git a/src/libslic3r/Support/SupportMaterial.hpp b/src/libslic3r/Support/SupportMaterial.hpp index 50b8256c4a..6d5355dab1 100644 --- a/src/libslic3r/Support/SupportMaterial.hpp +++ b/src/libslic3r/Support/SupportMaterial.hpp @@ -86,7 +86,6 @@ private: */ // Following objects are not owned by SupportMaterial class. - const PrintObject *m_object; const PrintConfig *m_print_config; const PrintObjectConfig *m_object_config; // Pre-calculated parameters shared between the object slicer and the support generator, diff --git a/src/libslic3r/Support/TreeSupport.cpp b/src/libslic3r/Support/TreeSupport.cpp index 2b06f11244..519b6e826e 100644 --- a/src/libslic3r/Support/TreeSupport.cpp +++ b/src/libslic3r/Support/TreeSupport.cpp @@ -2846,7 +2846,9 @@ void TreeSupport::drop_nodes() const MinimumSpanningTree& mst = spanning_trees[group_index]; //In the first pass, merge all nodes that are close together. std::vector> nodes_vec(nodes_this_part.begin(), nodes_this_part.end()); - tbb::parallel_for_each(nodes_vec.begin(), nodes_vec.end(), [&](const std::pair& entry) { + // Sequential: nodes merge into and invalidate each other in place, so parallel execution + // makes the merge order (and thus the result) depend on thread scheduling. + std::for_each(nodes_vec.begin(), nodes_vec.end(), [&](const std::pair& entry) { SupportNode* p_node = entry.second; SupportNode& node = *p_node; if (!p_node->valid) @@ -2934,7 +2936,32 @@ void TreeSupport::drop_nodes() ); //In the second pass, move all middle nodes. - tbb::parallel_for_each(nodes_vec.begin(), nodes_vec.end(), [&](const std::pair& entry) { + // Still parallel: this pass only reads other nodes. Side effects (invalidation, new + // nodes, contact_nodes/unsupported_branch_leaves updates) are recorded per node and + // applied afterwards in node order. Node creation must be deferred too, since + // SupportNode's constructor writes `parent->child = this` on other nodes. + struct PendingNode { + Point position; + int distance_to_top = 0; + int support_roof_layers_below = 0; + bool to_buildplate = false; + SupportNode *parent = nullptr; + bool zero_max_move = false; + bool has_overhang = false; + ExPolygon overhang; + bool clamp_radius = false; + coordf_t parent_radius = 0; + double dist_to_outer = 0; + }; + struct PassTwoResult { + bool invalidate = false; + bool unsupported_leaf = false; + std::vector pending; + }; + std::vector pass2_results(nodes_vec.size()); + auto pass2_body = [&](size_t node_idx) { + const std::pair& entry = nodes_vec[node_idx]; + PassTwoResult& pass2_out = pass2_results[node_idx]; SupportNode* p_node = entry.second; const SupportNode& node = *p_node; @@ -2949,14 +2976,16 @@ void TreeSupport::drop_nodes() ExPolygons overhangs_next = diff_clipped({ node.overhang }, get_collision(0, obj_layer_nr_next)); for(auto& overhang:overhangs_next) { Point next_pt = overhang.contour.centroid(); - SupportNode *next_node = m_ts_data->create_node(next_pt, p_node->distance_to_top + 1, obj_layer_nr_next, - p_node->support_roof_layers_below - (p_node->distance_to_top >= 0 ? 1 : 0), - to_buildplate, p_node, print_z_next, height_next); - next_node->max_move_dist = 0; - next_node->overhang = std::move(overhang); - m_ts_data->m_mutex.lock(); - contact_nodes[layer_nr_next].emplace_back(next_node); - m_ts_data->m_mutex.unlock(); + PendingNode pending; + pending.position = next_pt; + pending.distance_to_top = p_node->distance_to_top + 1; + pending.support_roof_layers_below = p_node->support_roof_layers_below - (p_node->distance_to_top >= 0 ? 1 : 0); + pending.to_buildplate = to_buildplate; + pending.parent = p_node; + pending.zero_max_move = true; + pending.has_overhang = true; + pending.overhang = std::move(overhang); + pass2_out.pending.emplace_back(std::move(pending)); } return; @@ -2973,17 +3002,17 @@ void TreeSupport::drop_nodes() { if (support_on_buildplate_only) { - unsupported_branch_leaves.push_front({ layer_nr, p_node }); + pass2_out.unsupported_leaf = true; } else { - p_node->valid = false; + pass2_out.invalidate = true; } return; } // if the link between parent and current is cut by contours, mark current as bottom contact node if (p_node->parent && intersection_ln({p_node->position, p_node->parent->position}, layer_contours).empty()==false) { - p_node->valid = false; + pass2_out.invalidate = true; return; } } @@ -3096,20 +3125,47 @@ void TreeSupport::drop_nodes() } auto next_collision = get_collision(0, obj_layer_nr_next); const bool to_buildplate = !is_inside_ex(m_ts_data->m_layer_outlines[obj_layer_nr_next], next_layer_vertex); - SupportNode * next_node = m_ts_data->create_node(next_layer_vertex, node.distance_to_top + 1, obj_layer_nr_next, - node.support_roof_layers_below - (node.distance_to_top >= 0 ? 1 : 0), - to_buildplate, p_node, print_z_next, height_next); // don't increase radius if next node will collide partially with the object (STUDIO-7883) - to_outside = projection_onto(next_collision, next_node->position); + to_outside = projection_onto(next_collision, next_layer_vertex); direction_to_outer = to_outside - node.position; double dist_to_outer = unscale_(direction_to_outer.cast().norm()); - next_node->radius = std::max(node.radius, std::min(next_node->radius, dist_to_outer)); - get_max_move_dist(next_node); - m_ts_data->m_mutex.lock(); - contact_nodes[layer_nr_next].push_back(next_node); - m_ts_data->m_mutex.unlock(); + PendingNode pending; + pending.position = next_layer_vertex; + pending.distance_to_top = node.distance_to_top + 1; + pending.support_roof_layers_below = node.support_roof_layers_below - (node.distance_to_top >= 0 ? 1 : 0); + pending.to_buildplate = to_buildplate; + pending.parent = p_node; + pending.clamp_radius = true; + pending.parent_radius = node.radius; + pending.dist_to_outer = dist_to_outer; + pass2_out.pending.emplace_back(std::move(pending)); + }; + tbb::parallel_for(tbb::blocked_range(0, nodes_vec.size()), + [&pass2_body](const tbb::blocked_range& node_range) { + for (size_t node_idx = node_range.begin(); node_idx < node_range.end(); ++ node_idx) + pass2_body(node_idx); + }); + // Apply the recorded side effects in node order. + for (size_t node_idx = 0; node_idx < nodes_vec.size(); ++ node_idx) { + PassTwoResult& pass2_out = pass2_results[node_idx]; + for (PendingNode& pending : pass2_out.pending) { + SupportNode* next_node = m_ts_data->create_node(pending.position, pending.distance_to_top, obj_layer_nr_next, + pending.support_roof_layers_below, pending.to_buildplate, pending.parent, print_z_next, height_next); + if (pending.zero_max_move) + next_node->max_move_dist = 0; + if (pending.has_overhang) + next_node->overhang = std::move(pending.overhang); + if (pending.clamp_radius) { + next_node->radius = std::max(pending.parent_radius, std::min(next_node->radius, pending.dist_to_outer)); + get_max_move_dist(next_node); + } + contact_nodes[layer_nr_next].push_back(next_node); + } + if (pass2_out.unsupported_leaf) + unsupported_branch_leaves.push_front({ layer_nr, nodes_vec[node_idx].second }); + if (pass2_out.invalidate) + nodes_vec[node_idx].second->valid = false; } - ); } #ifdef SUPPORT_TREE_DEBUG_TO_SVG diff --git a/src/libslic3r/Support/TreeSupport.hpp b/src/libslic3r/Support/TreeSupport.hpp index 61e030ef86..bd5a154e26 100644 --- a/src/libslic3r/Support/TreeSupport.hpp +++ b/src/libslic3r/Support/TreeSupport.hpp @@ -432,7 +432,6 @@ private: size_t m_highest_overhang_layer = 0; std::vector> m_spanning_trees; std::vector< std::unordered_map> m_mst_line_x_layer_contour_caches; - float DO_NOT_MOVER_UNDER_MM = 0.0; coordf_t base_radius = 0.0; const coordf_t MAX_BRANCH_RADIUS = 10.0; const coordf_t MIN_BRANCH_RADIUS = 0.4; diff --git a/src/libslic3r/Support/TreeSupport3D.cpp b/src/libslic3r/Support/TreeSupport3D.cpp index 29131502d2..3cd3f4110d 100644 --- a/src/libslic3r/Support/TreeSupport3D.cpp +++ b/src/libslic3r/Support/TreeSupport3D.cpp @@ -2382,13 +2382,10 @@ static void merge_influence_areas( size_t num_buckets_initial; { // How many buckets per first merge iteration? - const size_t num_threads = tbb::this_task_arena::max_concurrency(); - // 4 buckets per thread if possible, - const size_t num_buckets_min = (input_size + 2) / 4; - // 2 buckets per thread otherwise. - const size_t num_buckets_max = input_size / 2; - num_buckets_initial = num_buckets_min >= num_threads ? num_buckets_min : num_buckets_max; - const size_t bucket_size = num_buckets_min >= num_threads ? 4 : 2; + // Fixed at 4: merging is not associative, so sizing buckets off max_concurrency() made + // results depend on the core count of the slicing machine. + const size_t bucket_size = 4; + num_buckets_initial = (input_size + 2) / 4; // Fill in the buckets. SupportElementMerging *it = influence_areas.data(); // Reserve one more bucket to keep a single influence area which will not be merged in the first iteration. diff --git a/src/libslic3r/Thread.cpp b/src/libslic3r/Thread.cpp index 3030b6d194..edd7c2a3d0 100644 --- a/src/libslic3r/Thread.cpp +++ b/src/libslic3r/Thread.cpp @@ -30,6 +30,11 @@ static HMODULE s_hKernel32 = nullptr; static SetThreadDescriptionType s_fnSetThreadDescription = nullptr; static GetThreadDescriptionType s_fnGetThreadDescription = nullptr; +// Convert the FARPROC from GetProcAddress to Fn through a generic function pointer. +template static Fn load_proc(HMODULE module, const char* name) { + return reinterpret_cast(reinterpret_cast(::GetProcAddress(module, name))); +} + static bool WindowsGetSetThreadNameAPIInitialize() { if (! s_SetGetThreadDescriptionInitialized) { @@ -37,8 +42,8 @@ static bool WindowsGetSetThreadNameAPIInitialize() // to initialize s_hKernel32 = LoadLibraryW(L"Kernel32.dll"); if (s_hKernel32) { - s_fnSetThreadDescription = (SetThreadDescriptionType)::GetProcAddress(s_hKernel32, "SetThreadDescription"); - s_fnGetThreadDescription = (GetThreadDescriptionType)::GetProcAddress(s_hKernel32, "GetThreadDescription"); + s_fnSetThreadDescription = load_proc(s_hKernel32, "SetThreadDescription"); + s_fnGetThreadDescription = load_proc(s_hKernel32, "GetThreadDescription"); } s_SetGetThreadDescriptionInitialized = true; } diff --git a/src/libslic3r/TriangleMeshSlicer.cpp b/src/libslic3r/TriangleMeshSlicer.cpp index 417ca354d3..4ff18165cb 100644 --- a/src/libslic3r/TriangleMeshSlicer.cpp +++ b/src/libslic3r/TriangleMeshSlicer.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -607,6 +608,17 @@ static inline std::vector slice_make_lines( } } ); + // Facet processing above is parallel, so per-layer line order depends on thread scheduling, + // and make_loops() derives island order and loop start vertices from it. Sort canonically; + // edge_type and flags only break ties, std::sort being unstable. + tbb::parallel_for(tbb::blocked_range(0, lines.size()), + [&lines](const tbb::blocked_range &range) { + for (size_t i = range.begin(); i < range.end(); ++ i) + std::sort(lines[i].begin(), lines[i].end(), [](const IntersectionLine &l, const IntersectionLine &r) { + return std::make_tuple(l.edge_a_id, l.edge_b_id, l.a_id, l.b_id, l.a.x(), l.a.y(), l.b.x(), l.b.y(), l.edge_type, l.flags) < + std::make_tuple(r.edge_a_id, r.edge_b_id, r.a_id, r.b_id, r.a.x(), r.a.y(), r.b.x(), r.b.y(), r.edge_type, r.flags); + }); + }); return lines; } diff --git a/src/libslic3r/Utils.hpp b/src/libslic3r/Utils.hpp index 55d9b716cf..797894442a 100644 --- a/src/libslic3r/Utils.hpp +++ b/src/libslic3r/Utils.hpp @@ -255,6 +255,10 @@ extern bool is_gallery_file(const std::string& path, char const* type); extern bool is_shapes_dir(const std::string& dir); //BBS: add json support extern bool is_json_file(const std::string& path); +// True if rel_path is relative, has no ".." component and, joined to root, still resolves inside it. +// Both '/' and '\\' are treated as separators on every platform, so an archive rejected on one OS +// is rejected on all of them. +extern bool is_path_within_root(const std::string &rel_path, const boost::filesystem::path &root); // Orca: custom protocal support utils inline bool is_orca_open(const std::string& url) { return boost::starts_with(url, "orcaslicer://open"); } diff --git a/src/libslic3r/libslic3r.h b/src/libslic3r/libslic3r.h index 6584566f40..c339da566a 100644 --- a/src/libslic3r/libslic3r.h +++ b/src/libslic3r/libslic3r.h @@ -93,6 +93,9 @@ static constexpr double INSET_OVERLAP_TOLERANCE = 0.4; static constexpr double EXTERNAL_INFILL_MARGIN = 3; static constexpr double BRIDGE_INFILL_MARGIN = 1; static constexpr double WIPE_TOWER_MARGIN = 1.; +// Margin for system placement of the wipe tower (defaults, re-placement, CLI). Positions +// within WIPE_TOWER_MARGIN stay valid: a user drag down to that limit is respected. +static constexpr double WIPE_TOWER_AUTO_MARGIN = 15.; //FIXME Better to use an inline function with an explicit return type. //inline coord_t scale_(coordf_t v) { return coord_t(floor(v / SCALING_FACTOR + 0.5f)); } #define scale_(val) ((val) / SCALING_FACTOR) diff --git a/src/libslic3r/utils.cpp b/src/libslic3r/utils.cpp index 5f4baac951..58323b29ce 100644 --- a/src/libslic3r/utils.cpp +++ b/src/libslic3r/utils.cpp @@ -961,7 +961,7 @@ CopyFileResult copy_file(const std::string &from, const std::string &to, std::st BOOL result = CopyFileW(src_wstr, dst_wstr, FALSE); if (!result) { DWORD errCode = GetLastError(); - error_message = "Error: " + errCode; + error_message = "Error: " + std::to_string(errCode); ret = FAIL_COPY_FILE; goto __finished; } @@ -1088,6 +1088,30 @@ bool is_json_file(const std::string& path) return boost::iends_with(path, ".json"); } +bool is_path_within_root(const std::string &rel_path, const boost::filesystem::path &root) +{ + auto is_separator = [](char c) { return c == '/' || c == '\\'; }; + if (rel_path.empty() || is_separator(rel_path.front()) || (rel_path.size() > 1 && rel_path[1] == ':')) + return false; + for (size_t start = 0; start <= rel_path.size();) { + size_t end = start; + while (end < rel_path.size() && !is_separator(rel_path[end])) + ++end; + if (rel_path.compare(start, end - start, "..") == 0) + return false; + start = end + 1; + } + // Resolve against the canonical root so a symlink inside it cannot lead back out. + try { + const std::string root_str = boost::filesystem::weakly_canonical(root).string(); + const std::string full_str = boost::filesystem::weakly_canonical(root / rel_path).string(); + return full_str.compare(0, root_str.size(), root_str) == 0 && + (full_str.size() == root_str.size() || full_str[root_str.size()] == boost::filesystem::path::preferred_separator); + } catch (const boost::filesystem::filesystem_error &) { + return false; + } +} + bool is_img_file(const std::string &path) { return boost::iends_with(path, ".png") || boost::iends_with(path, ".svg"); diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 7c25792bb3..691675a452 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -97,6 +97,8 @@ set(SLIC3R_GUI_SOURCES GUI/CloneDialog.hpp GUI/ConfigManipulation.cpp GUI/ConfigManipulation.hpp + GUI/ConfigValueFormatter.cpp + GUI/ConfigValueFormatter.hpp GUI/ConfigWizard.cpp GUI/ConfigWizard.hpp GUI/ConfigWizard_private.hpp @@ -451,6 +453,8 @@ set(SLIC3R_GUI_SOURCES GUI/Project.hpp GUI/PublishDialog.cpp GUI/PublishDialog.hpp + GUI/PublishSettingsDialog.cpp + GUI/PublishSettingsDialog.hpp GUI/PurgeModeDialog.cpp GUI/PurgeModeDialog.hpp GUI/RammingChart.cpp diff --git a/src/slic3r/GUI/3DScene.cpp b/src/slic3r/GUI/3DScene.cpp index f65c0e3532..6dbea1272a 100644 --- a/src/slic3r/GUI/3DScene.cpp +++ b/src/slic3r/GUI/3DScene.cpp @@ -20,6 +20,8 @@ #include "libslic3r/AppConfig.hpp" #include "libslic3r/PresetBundle.hpp" #include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/GCode/WipeTower.hpp" +#include "libslic3r/GCode/WipeTowerEstimate.hpp" #include "libslic3r/Tesselate.hpp" #include "libslic3r/PrintConfig.hpp" @@ -919,6 +921,21 @@ int GLVolumeCollection::load_wipe_tower_preview( GUI::PartPlateList& ppl = GUI::wxGetApp().plater()->get_partplate_list(); std::vector plate_extruders = ppl.get_plate(plate_idx)->get_extruders(true); TriangleMesh wipe_tower_shell = make_cube(width, depth, height); + // The brim is part of the printed footprint: draw it and fold it into the shell so the + // outside-bed shader and the drag clamp react to the true first-layer extent. + const bool show_brim = brim_width > 0.f; + const float brim_height = 0.2f; // one first layer, visual only + TriangleMesh brim_slab; + if (show_brim) { + // The brim follows the real first-layer outline: a Type2 cone-wall tower's base bulges + // past the body box. The wall type and angle are print settings, the planner a printer one. + const DynamicPrintConfig &print_cfg = GUI::wxGetApp().preset_bundle->prints.get_edited_preset().config; + const DynamicPrintConfig &printer_cfg = GUI::wxGetApp().preset_bundle->printers.get_edited_preset().config; + const Polygon outline = estimate_wipe_tower_first_layer_outline(print_cfg, resolve_wipe_tower_type(printer_cfg), width, depth, height); + const Polygons brim_outline = offset(outline, scaled(brim_width)); + brim_slab = WipeTower::its_make_rib_brim(brim_outline.empty() ? outline : brim_outline.front(), brim_height); + wipe_tower_shell.merge(brim_slab); + } for (int extruder_id : plate_extruders) { if (extruder_id <= extruder_colors.size()) colors.push_back(extruder_colors[extruder_id - 1]); @@ -929,14 +946,19 @@ int GLVolumeCollection::load_wipe_tower_preview( // Orca: make it transparent for(auto& color : colors) color.a(0.66f); + const size_t slab_count = colors.size(); // per-filament body slabs; the brim part comes after + if (show_brim && !colors.empty()) + colors.push_back(colors.front()); volumes.emplace_back(new GLWipeTowerVolume(colors)); GLWipeTowerVolume& v = *dynamic_cast(volumes.back()); v.model_per_colors.resize(colors.size()); - for (int i = 0; i < colors.size(); i++) { - TriangleMesh color_part = make_cube(width, depth / colors.size(), height); - color_part.translate({ 0.f, depth * i / colors.size(), 0. }); + for (size_t i = 0; i < slab_count; i++) { + TriangleMesh color_part = make_cube(width, depth / slab_count, height); + color_part.translate({ 0.f, depth * i / slab_count, 0. }); v.model_per_colors[i].init_from(color_part); } + if (show_brim && !colors.empty()) + v.model_per_colors[slab_count].init_from(brim_slab); v.model.init_from(wipe_tower_shell); v.mesh_raycaster = std::make_unique(std::make_shared(wipe_tower_shell)); v.set_convex_hull(wipe_tower_shell); diff --git a/src/slic3r/GUI/AMSDryControl.cpp b/src/slic3r/GUI/AMSDryControl.cpp index c191e24eac..eb5ddb5d4e 100644 --- a/src/slic3r/GUI/AMSDryControl.cpp +++ b/src/slic3r/GUI/AMSDryControl.cpp @@ -1,6 +1,7 @@ #include "AMSDryControl.hpp" #include "slic3r/GUI/DeviceCore/DevFilaSystem.h" #include "GUI_App.hpp" +#include "slic3r/Utils/NetworkAgent.hpp" #include "I18N.hpp" #include "slic3r/GUI/DeviceCore/DevExtruderSystem.h" diff --git a/src/slic3r/GUI/AMSDryControl.hpp b/src/slic3r/GUI/AMSDryControl.hpp index 46699650d6..6c5df86849 100644 --- a/src/slic3r/GUI/AMSDryControl.hpp +++ b/src/slic3r/GUI/AMSDryControl.hpp @@ -14,6 +14,7 @@ //Previous defintions class wxGrid; +class ProgressBar; namespace Slic3r { @@ -97,12 +98,6 @@ private: wxSimplebook* m_main_simplebook{nullptr}; wxPanel* m_original_page{nullptr}; - wxWindow* m_amswin{nullptr}; - wxBoxSizer* m_sizer_ams_items{nullptr}; - wxScrolledWindow* m_panel_prv_left {nullptr}; - wxScrolledWindow* m_panel_prv_right{nullptr}; - wxBoxSizer* m_sizer_prv_left{nullptr}; - wxBoxSizer* m_sizer_prv_right{nullptr}; // left panel related members ScalableBitmap m_humidity_image; diff --git a/src/slic3r/GUI/AMSMaterialsSetting.cpp b/src/slic3r/GUI/AMSMaterialsSetting.cpp index 68f1b44212..f0fdf950a0 100644 --- a/src/slic3r/GUI/AMSMaterialsSetting.cpp +++ b/src/slic3r/GUI/AMSMaterialsSetting.cpp @@ -2,6 +2,8 @@ #include "ExtrusionCalibration.hpp" #include "MsgDialog.hpp" #include "GUI_App.hpp" +#include "GUI.hpp" +#include "slic3r/Utils/NetworkAgent.hpp" #include "libslic3r/Preset.hpp" #include "I18N.hpp" #include diff --git a/src/slic3r/GUI/AmsMappingPopup.cpp b/src/slic3r/GUI/AmsMappingPopup.cpp index 3e745b0a64..22ffaef034 100644 --- a/src/slic3r/GUI/AmsMappingPopup.cpp +++ b/src/slic3r/GUI/AmsMappingPopup.cpp @@ -11,6 +11,7 @@ #include "MainFrame.hpp" #include "format.hpp" #include "Widgets/ProgressDialog.hpp" +#include #include "Widgets/RoundedRectangle.hpp" #include "Widgets/StaticBox.hpp" diff --git a/src/slic3r/GUI/AmsMappingPopup.hpp b/src/slic3r/GUI/AmsMappingPopup.hpp index fc3f89fa69..21a3e38080 100644 --- a/src/slic3r/GUI/AmsMappingPopup.hpp +++ b/src/slic3r/GUI/AmsMappingPopup.hpp @@ -457,7 +457,6 @@ private: ScalableBitmap close_img; wxStaticBitmap* curr_humidity_img; - wxStaticBitmap* m_img; Label* m_staticText;; Label* m_staticText_note; diff --git a/src/slic3r/GUI/AmsMappingPopupUpdate.cpp b/src/slic3r/GUI/AmsMappingPopupUpdate.cpp index 7d8215f55d..3e2853ad1a 100644 --- a/src/slic3r/GUI/AmsMappingPopupUpdate.cpp +++ b/src/slic3r/GUI/AmsMappingPopupUpdate.cpp @@ -406,7 +406,7 @@ void AmsMapingPopup::update_ams_data_multi_machines() int ams_type = 1; int nozzle_id = 0; - if (ams_type >= 1 || ams_type <= 3) { // 1:ams 2:ams-lite 3:n3f + if (ams_type >= 1 && ams_type <= 3) { // 1:ams 2:ams-lite 3:n3f auto sizer_mapping_list = new wxBoxSizer(wxHORIZONTAL); auto ams_mapping_item_container = new MappingContainer(nozzle_id == 0 ? m_right_marea_panel : m_left_marea_panel, "AMS-1", 4); diff --git a/src/slic3r/GUI/BBLTopbar.hpp b/src/slic3r/GUI/BBLTopbar.hpp index e60a95f64f..2745490d00 100644 --- a/src/slic3r/GUI/BBLTopbar.hpp +++ b/src/slic3r/GUI/BBLTopbar.hpp @@ -93,7 +93,6 @@ private: CenteredTitle* m_title_ctrl { nullptr }; wxString m_titleText; - wxAuiToolBarItem* m_model_store_item; //wxAuiToolBarItem *m_publish_item; wxAuiToolBarItem* m_undo_item; diff --git a/src/slic3r/GUI/BackgroundSlicingProcess.cpp b/src/slic3r/GUI/BackgroundSlicingProcess.cpp index 64c52c6e72..f795b41999 100644 --- a/src/slic3r/GUI/BackgroundSlicingProcess.cpp +++ b/src/slic3r/GUI/BackgroundSlicingProcess.cpp @@ -848,7 +848,9 @@ void BackgroundSlicingProcess::finalize_gcode() case CopyFileResult::SUCCESS: break; // no error case CopyFileResult::FAIL_COPY_FILE: throw Slic3r::ExportError(GUI::format( - _L("Copying of the temporary G-code to the output G-code failed. Maybe the SD card is write locked?\nError message: %1%"), + m_export_path_on_removable_media ? + _L("Copying of the temporary G-code to the output G-code failed. Maybe the SD card is write locked?\nError message: %1%") : + _L("Copying of the temporary G-code to the output G-code failed.\nError message: %1%"), error_message)); break; case CopyFileResult::FAIL_FILES_DIFFERENT: diff --git a/src/slic3r/GUI/BaseTransparentDPIFrame.hpp b/src/slic3r/GUI/BaseTransparentDPIFrame.hpp index 35ed51ddfe..7dc83b4d46 100644 --- a/src/slic3r/GUI/BaseTransparentDPIFrame.hpp +++ b/src/slic3r/GUI/BaseTransparentDPIFrame.hpp @@ -5,8 +5,10 @@ #include #include "GUI_App.hpp" #include "GUI_Utils.hpp" +#include class Button; +class Label; class CheckBox; namespace Slic3r { namespace GUI { class CapsuleButton; diff --git a/src/slic3r/GUI/BindDialog.hpp b/src/slic3r/GUI/BindDialog.hpp index b809bc523b..8d21c1be5e 100644 --- a/src/slic3r/GUI/BindDialog.hpp +++ b/src/slic3r/GUI/BindDialog.hpp @@ -65,18 +65,10 @@ private: wxPanel* request_bind_panel; wxPanel* binding_panel; - wxScrolledWindow* m_sw_bind_failed_info; - Label* m_bind_failed_info; - Label* m_st_txt_error_code{ nullptr }; - Label* m_st_txt_error_desc{ nullptr }; - Label* m_st_txt_extra_info{ nullptr }; - HyperLink* m_link_network_state{ nullptr }; wxString m_result_info; wxString m_result_extra; wxString m_ping_code_wiki; - bool m_show_error_info_state = true; - int m_result_code; std::shared_ptr m_status_bar; public: @@ -110,7 +102,6 @@ private: wxBitmap m_bitmap_show_error_close; wxBitmap m_bitmap_show_error_open; wxScrolledWindow* m_sw_bind_failed_info; - Label* m_bind_failed_info; Label* m_st_txt_error_code{ nullptr }; Label* m_st_txt_error_desc{ nullptr }; Label* m_st_txt_extra_info{ nullptr }; diff --git a/src/slic3r/GUI/CalibrationPanel.hpp b/src/slic3r/GUI/CalibrationPanel.hpp index eafb380c79..1f6b097af9 100644 --- a/src/slic3r/GUI/CalibrationPanel.hpp +++ b/src/slic3r/GUI/CalibrationPanel.hpp @@ -70,11 +70,7 @@ public: private: int m_my_devices_count{ 0 }; - int m_other_devices_count{ 0 }; bool m_dismiss{ false }; - wxWindow* m_placeholder_panel { nullptr }; - wxWindow* m_panel_body{ nullptr }; - wxBoxSizer* m_sizer_body{ nullptr }; wxBoxSizer* m_sizer_my_devices{ nullptr }; wxScrolledWindow* m_scrolledWindow{ nullptr }; wxTimer* m_refresh_timer{ nullptr }; diff --git a/src/slic3r/GUI/CalibrationWizard.cpp b/src/slic3r/GUI/CalibrationWizard.cpp index 7496d59a51..f80562578d 100644 --- a/src/slic3r/GUI/CalibrationWizard.cpp +++ b/src/slic3r/GUI/CalibrationWizard.cpp @@ -1,6 +1,7 @@ #include "CalibrationWizard.hpp" #include "I18N.hpp" #include "GUI_App.hpp" +#include "slic3r/Utils/NetworkAgent.hpp" #include "MsgDialog.hpp" #include "CalibrationWizardPage.hpp" #include "../../libslic3r/calib.hpp" diff --git a/src/slic3r/GUI/CalibrationWizardPresetPage.cpp b/src/slic3r/GUI/CalibrationWizardPresetPage.cpp index 5267715439..7a83d39dd3 100644 --- a/src/slic3r/GUI/CalibrationWizardPresetPage.cpp +++ b/src/slic3r/GUI/CalibrationWizardPresetPage.cpp @@ -1,5 +1,7 @@ #include #include "CalibrationWizardPresetPage.hpp" +#include "GUI.hpp" +#include "slic3r/Utils/NetworkAgent.hpp" #include "I18N.hpp" #include "Widgets/Label.hpp" #include "MsgDialog.hpp" @@ -360,7 +362,7 @@ void CaliPresetCustomRangePanel::create_panel(wxWindow* parent) int max_decimal_length; if (i <= 1) max_decimal_length = 3; - else if (i >= 2) + else max_decimal_length = 4; if (decimal_number > max_decimal_length) { int allowed_length = number.length() - decimal_number + max_decimal_length; diff --git a/src/slic3r/GUI/CalibrationWizardSavePage.cpp b/src/slic3r/GUI/CalibrationWizardSavePage.cpp index f7699cfab0..427f022d1c 100644 --- a/src/slic3r/GUI/CalibrationWizardSavePage.cpp +++ b/src/slic3r/GUI/CalibrationWizardSavePage.cpp @@ -1,4 +1,5 @@ #include "CalibrationWizardSavePage.hpp" +#include "GUI.hpp" #include "I18N.hpp" #include "Widgets/Label.hpp" #include "MsgDialog.hpp" diff --git a/src/slic3r/GUI/CameraPopup.hpp b/src/slic3r/GUI/CameraPopup.hpp index dbdb81a9cb..f45ebb6e42 100644 --- a/src/slic3r/GUI/CameraPopup.hpp +++ b/src/slic3r/GUI/CameraPopup.hpp @@ -72,8 +72,10 @@ private: SwitchButton* m_switch_recording; wxStaticText* m_text_vcamera; SwitchButton* m_switch_vcamera; +#if !BBL_RELEASE_TO_PUBLIC wxStaticText* m_text_liveview_retry; SwitchButton* m_switch_liveview_retry; +#endif //BBL_RELEASE_TO_PUBLIC wxStaticText* m_custom_camera_hint; TextInput* m_custom_camera_input; Button* m_custom_camera_input_confirm; diff --git a/src/slic3r/GUI/CapsuleButton.cpp b/src/slic3r/GUI/CapsuleButton.cpp index 8afe39889e..8d71f9911e 100644 --- a/src/slic3r/GUI/CapsuleButton.cpp +++ b/src/slic3r/GUI/CapsuleButton.cpp @@ -1,5 +1,6 @@ #include "GUI_App.hpp" #include "CapsuleButton.hpp" +#include "Widgets/StateColor.hpp" #include #include "wx/graphics.h" #include "Widgets/Label.hpp" diff --git a/src/slic3r/GUI/ColorDecomposeSupport.cpp b/src/slic3r/GUI/ColorDecomposeSupport.cpp index 6621b97059..ea67564208 100644 --- a/src/slic3r/GUI/ColorDecomposeSupport.cpp +++ b/src/slic3r/GUI/ColorDecomposeSupport.cpp @@ -1,4 +1,5 @@ #include "ColorDecomposeSupport.hpp" +#include "slic3r/Utils/NetworkAgent.hpp" #include "MixedFilamentDialog.hpp" #include "GUI_App.hpp" #include "MsgDialog.hpp" diff --git a/src/slic3r/GUI/ConfigManipulation.cpp b/src/slic3r/GUI/ConfigManipulation.cpp index 8eab1c785c..5bd74e107d 100644 --- a/src/slic3r/GUI/ConfigManipulation.cpp +++ b/src/slic3r/GUI/ConfigManipulation.cpp @@ -2,6 +2,7 @@ #include "ConfigManipulation.hpp" #include "I18N.hpp" #include "GUI_App.hpp" +#include "DeviceCore/DevConfigUtil.h" #include "format.hpp" #include "libslic3r/Config.hpp" #include "libslic3r/Model.hpp" diff --git a/src/slic3r/GUI/ConfigValueFormatter.cpp b/src/slic3r/GUI/ConfigValueFormatter.cpp new file mode 100644 index 0000000000..6f9128841b --- /dev/null +++ b/src/slic3r/GUI/ConfigValueFormatter.cpp @@ -0,0 +1,245 @@ +#include "ConfigValueFormatter.hpp" + +#include +#include +#include +#include + +#include +#include + +#include "libslic3r/Config.hpp" +#include "libslic3r/PrintConfig.hpp" + +#include "I18N.hpp" +#include "GUI.hpp" +#include "Field.hpp" + +namespace Slic3r { +namespace GUI { + +std::string get_pure_opt_key(const std::string& opt_key) +{ + std::string pure_key = opt_key; + const int pos = pure_key.find("#"); + if (pos > 0) + boost::erase_tail(pure_key, pure_key.size() - pos); + return pure_key; +} + +wxString get_string_from_enum(const std::string& opt_key, const DynamicPrintConfig& config, bool is_infill, int idx) +{ + const ConfigOptionDef& def = config.def()->options.at(opt_key); + const std::vector& names = def.enum_labels;//ConfigOptionEnum::get_enum_names(); + int val = 0; + + if (idx >= 0) + val = dynamic_cast(config.option(opt_key))->get_at(idx); + else + val = config.option(opt_key)->getInt(); + + // Each infill doesn't use all list of infill declared in PrintConfig.hpp. + // So we should "convert" val to the correct one + if (is_infill) { + for (auto key_val : *def.enum_keys_map) + if (int(key_val.second) == val) { + auto it = std::find(def.enum_values.begin(), def.enum_values.end(), key_val.first); + if (it == def.enum_values.end()) + return ""; + return from_u8(_utf8(names[it - def.enum_values.begin()])); + } + return _L("Undefined"); + } + return from_u8(_utf8(names[val])); +} + +wxString get_full_label(const std::string& opt_key, const DynamicPrintConfig& config) +{ + const std::string pure_key = get_pure_opt_key(opt_key); + auto option = config.option(pure_key); + + if (!option || option->is_nil()) + return _L("N/A"); + + const ConfigOptionDef* opt = config.def()->get(pure_key); + return opt->full_label.empty() ? opt->label : opt->full_label; +} + +wxString get_string_value(const std::string& opt_key, const DynamicPrintConfig& config) +{ + int orig_opt_idx = -1; + int opt_idx = -1; + int pos = opt_key.find("#"); + std::string temp_str = opt_key; + if (pos > 0) { + boost::erase_head(temp_str, pos + 1); + orig_opt_idx = std::atoi(temp_str.c_str()); + } + opt_idx = orig_opt_idx >= 0 ? orig_opt_idx : 0; + const std::string pure_key = get_pure_opt_key(opt_key); + auto option = config.option(pure_key); + if (!option) { + return _L("N/A"); + } + auto opt_vector = dynamic_cast(option); + + if ((option->is_scalar() && option->is_nil()) || + (option->is_vector() && opt_vector && opt_idx >= 0 && opt_idx < opt_vector->size() && opt_vector->is_nil(opt_idx))) + return _L("N/A"); + + wxString out; + + const ConfigOptionDef* opt = config.def()->get(pure_key); + bool is_nullable = opt->nullable; + + switch (opt->type) { + case coInt: + return from_u8((boost::format("%1%") % config.opt_int(pure_key)).str()); + case coInts: { + if (is_nullable) { + auto values = config.opt(pure_key); + if (opt_idx < values->size()) + return from_u8((boost::format("%1%") % values->get_at(opt_idx)).str()); + } + else { + auto values = config.opt(pure_key); + if (orig_opt_idx >= 0 && orig_opt_idx < values->size()) { + return from_u8((boost::format("%1%") % values->get_at(opt_idx)).str()); + } + else { + std::string value_str; + for (int i = 0; i < values->size(); i++) { + value_str += std::to_string(values->get_at(i)); + if (i != values->size() - 1) { + value_str += ","; + } + } + return from_u8(value_str); + } + } + return _L("Undefined"); + } + case coBool: + return config.opt_bool(pure_key) ? "true" : "false"; + case coBools: { + if (is_nullable) { + auto values = config.opt(pure_key); + if (opt_idx < values->size()) + return values->get_at(opt_idx) ? "true" : "false"; + } + else { + auto values = config.opt(pure_key); + if (opt_idx < values->size()) + return values->get_at(opt_idx) ? "true" : "false"; + } + return _L("Undefined"); + } + case coPercent: + return from_u8((boost::format("%1%%%") % int(config.optptr(pure_key)->getFloat())).str()); + case coPercents: { + if (is_nullable) { + auto values = config.opt(pure_key); + if (opt_idx < values->size()) + return from_u8((boost::format("%1%%%") % values->get_at(opt_idx)).str()); + } + else { + auto values = config.opt(pure_key); + if (opt_idx < values->size()) + return from_u8((boost::format("%1%%%") % values->get_at(opt_idx)).str()); + } + return _L("Undefined"); + } + case coFloat: + return double_to_string(config.opt_float(pure_key)); + case coFloats: { + if (is_nullable) { + auto values = config.opt(pure_key); + if (opt_idx < values->size()) + return double_to_string(values->get_at(opt_idx)); + } + else { + auto values = config.opt(pure_key); + if (values && opt_idx < values->size()) + return double_to_string(values->get_at(opt_idx)); + } + return _L("Undefined"); + } + case coString: + return from_u8(config.opt_string(pure_key)); + case coStrings: { + const ConfigOptionStrings* strings = config.opt(pure_key); + if (strings) { + if (pure_key == "compatible_printers" || pure_key == "compatible_prints") { + if (strings->empty()) + return _L("All"); + for (size_t id = 0; id < strings->size(); id++) + out += from_u8(strings->get_at(id)) + "\n"; + out.RemoveLast(1); + return out; + } + if (!strings->empty() && opt_idx < strings->values.size()) + return from_u8(strings->get_at(opt_idx)); + } + break; + } + case coFloatOrPercent: { + const ConfigOptionFloatOrPercent* opt = config.opt(pure_key); + if (opt) + out = double_to_string(opt->value) + (opt->percent ? "%" : ""); + return out; + } + case coEnum: { + return get_string_from_enum(pure_key, config, + pure_key == "top_surface_pattern" || + pure_key == "bottom_surface_pattern" || + pure_key == "internal_solid_infill_pattern" || + pure_key == "sparse_infill_pattern" || + pure_key == "ironing_pattern" || + pure_key == "support_ironing_pattern" || + pure_key == "support_pattern" || + pure_key == "support_interface_pattern") + ; + } + case coEnums: { + return get_string_from_enum(pure_key, config, + pure_key == "top_surface_pattern" || + pure_key == "bottom_surface_pattern" || + pure_key == "internal_solid_infill_pattern" || + pure_key == "sparse_infill_pattern" || + pure_key == "ironing_pattern" || + pure_key == "support_ironing_pattern" || + pure_key == "support_pattern" || + pure_key == "support_interface_pattern" + , opt_idx); + } + case coPoint: { + Vec2d val = config.opt(pure_key)->value; + return from_u8((boost::format("[%1%]") % ConfigOptionPoint(val).serialize()).str()); + } + case coPoints: { + //BBS: add bed_exclude_area + if (pure_key == "printable_area" || pure_key == "thumbnails") { + ConfigOptionPoints points = *config.option(pure_key); + //BuildVolume build_volume = {points.values, 0.}; + return get_thumbnails_string(points.values); + } + else if (pure_key == "bed_exclude_area") { + return get_thumbnails_string(config.option(pure_key)->values); + } + else if (pure_key == "head_wrap_detect_zone") { + return get_thumbnails_string(config.option(pure_key)->values); + } + else if (pure_key == "wrapping_exclude_area") { + return get_thumbnails_string(config.option(pure_key)->values); + } + Vec2d val = config.opt(pure_key)->get_at(opt_idx); + return from_u8((boost::format("[%1%]") % ConfigOptionPoint(val).serialize()).str()); + } + default: + break; + } + return out; +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/ConfigValueFormatter.hpp b/src/slic3r/GUI/ConfigValueFormatter.hpp new file mode 100644 index 0000000000..67b16f9326 --- /dev/null +++ b/src/slic3r/GUI/ConfigValueFormatter.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include + +namespace Slic3r { + +class DynamicPrintConfig; + +namespace GUI { + +// Human-readable value of opt_key (may carry a "#" suffix) in config. +wxString get_string_value(const std::string& opt_key, const DynamicPrintConfig& config); + +// Full label of opt_key; "N/A" when the option is not set. +wxString get_full_label(const std::string& opt_key, const DynamicPrintConfig& config); + +// Strip the "#" suffix (if any) from the option key. +std::string get_pure_opt_key(const std::string& opt_key); + +// Localized label of the currently selected value of an enum option. +wxString get_string_from_enum(const std::string& opt_key, const DynamicPrintConfig& config, bool is_infill = false, int idx = -1); + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/CreatePresetsDialog.hpp b/src/slic3r/GUI/CreatePresetsDialog.hpp index 701779b1bf..07811aa144 100644 --- a/src/slic3r/GUI/CreatePresetsDialog.hpp +++ b/src/slic3r/GUI/CreatePresetsDialog.hpp @@ -74,7 +74,6 @@ private: std::unordered_set m_system_filament_types_set; std::set m_visible_printers; CreateType m_create_type; - Button * m_button_cancel = nullptr; ComboBox * m_filament_vendor_combobox = nullptr; ::CheckBox * m_can_not_find_vendor_checkbox = nullptr; ComboBox * m_filament_type_combobox = nullptr; diff --git a/src/slic3r/GUI/DailyTips.cpp b/src/slic3r/GUI/DailyTips.cpp index f585425130..894c0316bb 100644 --- a/src/slic3r/GUI/DailyTips.cpp +++ b/src/slic3r/GUI/DailyTips.cpp @@ -1,4 +1,5 @@ #include "DailyTips.hpp" +#include "slic3r/GUI/Widgets/Label.hpp" #ifndef IMGUI_DEFINE_MATH_OPERATORS #define IMGUI_DEFINE_MATH_OPERATORS @@ -245,7 +246,6 @@ DailyTipsPanel::DailyTipsPanel(bool can_expand, DailyTipsLayout layout) m_width(0), m_height(0), m_can_expand(can_expand), - m_layout(layout), m_uid(DailyTipsPanel::uid++), m_dailytips_renderer(std::make_unique(layout)) { diff --git a/src/slic3r/GUI/DailyTips.hpp b/src/slic3r/GUI/DailyTips.hpp index 508a8ad7e9..537fed4952 100644 --- a/src/slic3r/GUI/DailyTips.hpp +++ b/src/slic3r/GUI/DailyTips.hpp @@ -51,7 +51,6 @@ private: int m_uid; bool m_first_enter{ false }; bool m_is_dark{ false }; - DailyTipsLayout m_layout{ DailyTipsLayout::Vertical }; float m_fade_opacity{ 1.0f }; }; diff --git a/src/slic3r/GUI/DeviceCore/DevCalib.cpp b/src/slic3r/GUI/DeviceCore/DevCalib.cpp index cf7ee1d90e..ddf71e0c62 100644 --- a/src/slic3r/GUI/DeviceCore/DevCalib.cpp +++ b/src/slic3r/GUI/DeviceCore/DevCalib.cpp @@ -1,5 +1,7 @@ #include #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/I18N.hpp" +#include "slic3r/GUI/DeviceManager.hpp" #include "slic3r/GUI/UserNotification.hpp" #include "libslic3r/PrintConfig.hpp" diff --git a/src/slic3r/GUI/DeviceCore/DevConfig.h b/src/slic3r/GUI/DeviceCore/DevConfig.h index 449b5a7253..b0cdcb936e 100644 --- a/src/slic3r/GUI/DeviceCore/DevConfig.h +++ b/src/slic3r/GUI/DeviceCore/DevConfig.h @@ -54,7 +54,7 @@ public: void ParseCalibrationConfig(const json& print_json); //cali private: - MachineObject* m_obj; + [[maybe_unused]] MachineObject* m_obj; /*configure vals*/ // chamber diff --git a/src/slic3r/GUI/DeviceCore/DevExtensionTool.h b/src/slic3r/GUI/DeviceCore/DevExtensionTool.h index 02f0b6a213..c3b9c5b0b7 100644 --- a/src/slic3r/GUI/DeviceCore/DevExtensionTool.h +++ b/src/slic3r/GUI/DeviceCore/DevExtensionTool.h @@ -31,7 +31,7 @@ protected: DevExtensionTool(MachineObject* obj); private: - MachineObject* m_owner = nullptr; + [[maybe_unused]] MachineObject* m_owner = nullptr; enum MountState { diff --git a/src/slic3r/GUI/DeviceCore/DevFilaAmsSetting.h b/src/slic3r/GUI/DeviceCore/DevFilaAmsSetting.h index efdad308b2..ca59b9fd3e 100644 --- a/src/slic3r/GUI/DeviceCore/DevFilaAmsSetting.h +++ b/src/slic3r/GUI/DeviceCore/DevFilaAmsSetting.h @@ -28,7 +28,7 @@ public: void SetAutoRefillEnabled(bool enable) { m_enable_auto_refill = enable; } private: - DevFilaSystem* m_owner = nullptr; + [[maybe_unused]] DevFilaSystem* m_owner = nullptr; std::optional m_enable_detect_on_insert = false; bool m_enable_detect_on_powerup = false; diff --git a/src/slic3r/GUI/DeviceCore/DevFilaBlackList.cpp b/src/slic3r/GUI/DeviceCore/DevFilaBlackList.cpp index b59499d9d9..7ae990730a 100644 --- a/src/slic3r/GUI/DeviceCore/DevFilaBlackList.cpp +++ b/src/slic3r/GUI/DeviceCore/DevFilaBlackList.cpp @@ -4,6 +4,8 @@ #include #include "DevFilaBlackList.h" +#include "slic3r/Utils/NetworkAgent.hpp" +#include "slic3r/GUI/DeviceManager.hpp" #include "DevFilaSystem.h" #include "DevManager.h" #include "DevConfigUtil.h" diff --git a/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp b/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp index e0a230969b..881aa75d20 100644 --- a/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp +++ b/src/slic3r/GUI/DeviceCore/DevFilaSystem.cpp @@ -1,5 +1,6 @@ #include #include "DevFilaSystem.h" +#include "slic3r/Utils/NetworkAgent.hpp" #include "DevNozzleSystem.h" // DevNozzle / DevNozzleSystem for GetNozzleFlowStringByAmsId // TODO: remove this include diff --git a/src/slic3r/GUI/DeviceCore/DevHMS.h b/src/slic3r/GUI/DeviceCore/DevHMS.h index 205cad77b3..f72d899187 100644 --- a/src/slic3r/GUI/DeviceCore/DevHMS.h +++ b/src/slic3r/GUI/DeviceCore/DevHMS.h @@ -21,7 +21,7 @@ public: const std::vector& GetHMSItems() const { return m_hms_list; }; private: - MachineObject* m_object = nullptr; + [[maybe_unused]] MachineObject* m_object = nullptr; // all hms for this machine std::vector m_hms_list; diff --git a/src/slic3r/GUI/DeviceCore/DevInfo.h b/src/slic3r/GUI/DeviceCore/DevInfo.h index 66224888e6..259c277e46 100644 --- a/src/slic3r/GUI/DeviceCore/DevInfo.h +++ b/src/slic3r/GUI/DeviceCore/DevInfo.h @@ -34,7 +34,7 @@ private: //std::string m_connect_type; //std::string m_bind_state; - MachineObject* m_owner = nullptr; + [[maybe_unused]] MachineObject* m_owner = nullptr; }; } // namespace Slic3r \ No newline at end of file diff --git a/src/slic3r/GUI/DeviceCore/DevManager.cpp b/src/slic3r/GUI/DeviceCore/DevManager.cpp index 8844303793..feb6301df3 100644 --- a/src/slic3r/GUI/DeviceCore/DevManager.cpp +++ b/src/slic3r/GUI/DeviceCore/DevManager.cpp @@ -872,7 +872,7 @@ namespace Slic3r obj->m_is_online = elem["dev_online"].get(); if (elem.contains("dev_model_name") && !elem["dev_model_name"].is_null()) { auto printer_type = elem["dev_model_name"].get(); - for (const std::pair> &pair : device_subseries) { + for (const auto &pair : device_subseries) { auto it = std::find(pair.second.begin(), pair.second.end(), printer_type); if (it != pair.second.end()) { diff --git a/src/slic3r/GUI/DeviceCore/DevMapping.cpp b/src/slic3r/GUI/DeviceCore/DevMapping.cpp index 165492c9f6..0040bb05f2 100644 --- a/src/slic3r/GUI/DeviceCore/DevMapping.cpp +++ b/src/slic3r/GUI/DeviceCore/DevMapping.cpp @@ -1,3 +1,5 @@ +#include + #include #include "DevMapping.h" #include "DevFilaSystem.h" @@ -270,7 +272,7 @@ namespace Slic3r std::set picked_tar; for (int k = 0; k < distance_map.size(); k++) { - float min_val = INT_MAX; + float min_val = std::numeric_limits::max(); int picked_src_idx = -1; int picked_tar_idx = -1; for (int i = 0; i < distance_map.size(); i++) diff --git a/src/slic3r/GUI/DeviceCore/DevStatus.h b/src/slic3r/GUI/DeviceCore/DevStatus.h index 45f76d2239..4ed0d5c416 100644 --- a/src/slic3r/GUI/DeviceCore/DevStatus.h +++ b/src/slic3r/GUI/DeviceCore/DevStatus.h @@ -36,7 +36,7 @@ public: void ParseStatus(const nlohmann::json& print_jj); private: - MachineObject *m_owner = nullptr; + [[maybe_unused]] MachineObject *m_owner = nullptr; std::optional m_job_state; // could be nullopt for some old firmware }; diff --git a/src/slic3r/GUI/DeviceCore/DevStorage.h b/src/slic3r/GUI/DeviceCore/DevStorage.h index 537665f778..8ac53b7fea 100644 --- a/src/slic3r/GUI/DeviceCore/DevStorage.h +++ b/src/slic3r/GUI/DeviceCore/DevStorage.h @@ -31,7 +31,7 @@ public: bool is_timelapse_storage_low(const std::string& storage) const; private: - MachineObject *m_owner; + [[maybe_unused]] MachineObject *m_owner; SdcardState m_sdcard_state { NO_SDCARD }; // timelapse storage space info (from device push cam data) int tl_internal_free_kb{-1}; diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index 782574c220..fd60f80d25 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -1,5 +1,7 @@ #include "libslic3r/libslic3r.h" #include "DeviceManager.hpp" +#include "HMS.hpp" +#include "I18N.hpp" #include "libslic3r/Time.hpp" #include "libslic3r/Thread.hpp" #include "slic3r/Utils/NetworkAgent.hpp" diff --git a/src/slic3r/GUI/DeviceTab/wgtMsgPanel.cpp b/src/slic3r/GUI/DeviceTab/wgtMsgPanel.cpp index 64195d97a8..16ae8812be 100644 --- a/src/slic3r/GUI/DeviceTab/wgtMsgPanel.cpp +++ b/src/slic3r/GUI/DeviceTab/wgtMsgPanel.cpp @@ -1,6 +1,7 @@ #include "wgtMsgPanel.h" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/I18N.hpp" #include "slic3r/GUI/Widgets/Label.hpp" #include "slic3r/GUI/Widgets/StateColor.hpp" #include "slic3r/GUI/wxExtensions.hpp" diff --git a/src/slic3r/GUI/DragCanvas.cpp b/src/slic3r/GUI/DragCanvas.cpp index 04d51c0861..66a9acecbc 100644 --- a/src/slic3r/GUI/DragCanvas.cpp +++ b/src/slic3r/GUI/DragCanvas.cpp @@ -1,6 +1,7 @@ #include "DragCanvas.hpp" #include "wxExtensions.hpp" #include "GUI_App.hpp" +#include "Widgets/StateColor.hpp" namespace Slic3r { namespace GUI { diff --git a/src/slic3r/GUI/EncodedFilament.cpp b/src/slic3r/GUI/EncodedFilament.cpp index f9054e7f6d..0cab200386 100644 --- a/src/slic3r/GUI/EncodedFilament.cpp +++ b/src/slic3r/GUI/EncodedFilament.cpp @@ -1,7 +1,10 @@ #include "EncodedFilament.hpp" +#include #include "GUI_App.hpp" +using json = nlohmann::json; + namespace Slic3r { diff --git a/src/slic3r/GUI/ExportPresetBundleDialog.cpp b/src/slic3r/GUI/ExportPresetBundleDialog.cpp index 6d642ee1c1..9d2f26bf6a 100644 --- a/src/slic3r/GUI/ExportPresetBundleDialog.cpp +++ b/src/slic3r/GUI/ExportPresetBundleDialog.cpp @@ -1,4 +1,5 @@ #include "ExportPresetBundleDialog.hpp" +#include "slic3r/Utils/NetworkAgent.hpp" #include "GUI_App.hpp" #include "ConfigWizard.hpp" #include "I18N.hpp" @@ -12,7 +13,11 @@ #include #include #include +#include #include + +using json = nlohmann::json; + namespace Slic3r { namespace GUI { ExportPresetBundleDialog::ExportPresetBundleDialog( diff --git a/src/slic3r/GUI/ExtraRenderers.cpp b/src/slic3r/GUI/ExtraRenderers.cpp index 18811ef241..3abfdb82ff 100644 --- a/src/slic3r/GUI/ExtraRenderers.cpp +++ b/src/slic3r/GUI/ExtraRenderers.cpp @@ -1,6 +1,7 @@ #include "ExtraRenderers.hpp" #include "wxExtensions.hpp" #include "GUI.hpp" +#include "I18N.hpp" #include "BitmapComboBox.hpp" #include "Plater.hpp" #include "Widgets/ComboBox.hpp" diff --git a/src/slic3r/GUI/ExtrusionCalibration.cpp b/src/slic3r/GUI/ExtrusionCalibration.cpp index 933e2ac211..1f07823d61 100644 --- a/src/slic3r/GUI/ExtrusionCalibration.cpp +++ b/src/slic3r/GUI/ExtrusionCalibration.cpp @@ -1,5 +1,6 @@ #include "ExtrusionCalibration.hpp" #include "GUI_App.hpp" +#include "GUI.hpp" #include "MsgDialog.hpp" #include "libslic3r/Preset.hpp" #include diff --git a/src/slic3r/GUI/Field.cpp b/src/slic3r/GUI/Field.cpp index 142cf70522..74ef3f87c8 100644 --- a/src/slic3r/GUI/Field.cpp +++ b/src/slic3r/GUI/Field.cpp @@ -11,6 +11,7 @@ #include "libslic3r/PrintConfig.hpp" #include +#include #include #include #include @@ -540,51 +541,95 @@ void Field::get_value_by_opt_type(wxString& str, const bool check_value/* = true case coStrings: case coFloatOrPercent: case coFloatsOrPercents: { - if ((m_opt.type == coFloatOrPercent || m_opt.type == coFloatsOrPercents) && !str.IsEmpty() && str.Last() != '%') - { + if ((m_opt.type == coFloatOrPercent || m_opt.type == coFloatsOrPercents) && !str.IsEmpty() && + !(m_opt.nullable && str == m_na_value)) { + bool update_control = false; + wxString numeric_str = str; double val = 0.; + const char dec_sep = is_decimal_separator_point() ? '.' : ','; const char dec_sep_alt = dec_sep == '.' ? ',' : '.'; - // Replace the first incorrect separator in decimal number. - if (str.Replace(dec_sep_alt, dec_sep, false) != 0) - set_value(str, false); + // Orca: normalize the decimal separator and optional unit before + // detecting the percentage suffix and parsing the numeric part. + update_control |= numeric_str.Replace(dec_sep_alt, dec_sep, false) != 0; + update_control |= numeric_str.Replace(" ", "", true) != 0; + const bool has_literal_unit = numeric_str.EndsWith("mm"); + if (has_literal_unit) { + numeric_str.RemoveLast(2); + update_control = true; + } + bool is_percent = !numeric_str.IsEmpty() && numeric_str.Last() == '%'; + if (is_percent) + numeric_str.RemoveLast(); - - // remove space and "mm" substring, if any exists - str.Replace(" ", "", true); - str.Replace("m", "", true); - - if (!str.ToDouble(&val)) - { + if ((has_literal_unit && is_percent) || !numeric_str.ToDouble(&val) || !std::isfinite(val)) { if (!check_value) { m_value.clear(); break; } show_error(m_parent, _L("Invalid numeric.")); - set_value(double_to_string(val), true); - } - else if (((m_opt.sidetext.rfind("mm/s") != std::string::npos && val > m_opt.max) || - (m_opt.sidetext.rfind("mm ") != std::string::npos && val > /*1*/m_opt.max_literal)) && - (m_value.empty() || into_u8(str) != boost::any_cast(m_value))) - { - if (!check_value) { - m_value.clear(); - break; + numeric_str = double_to_string(std::clamp(0., double(m_opt.min), double(m_opt.max))); + is_percent = false; + update_control = true; + } else { + const bool looks_like_missing_percent = !is_percent && !has_literal_unit && + ((m_opt.sidetext.rfind("mm/s") != std::string::npos && val > m_opt.max) || + (m_opt.sidetext.rfind("mm ") != std::string::npos && val > m_opt.max_literal)); + // Orca: validate explicit percentages and literal values before + // asking whether an otherwise valid literal was meant as a percentage. + const bool out_of_range = !m_opt.is_value_valid(val); + if (out_of_range) { + if (!check_value) { + m_value.clear(); + break; + } + show_error(m_parent, _L("Value is out of range.")); + val = std::clamp(val, double(m_opt.min), double(m_opt.max)); + // Orca: retain the inferred percent unit when clamping a + // suspicious unitless value, so 2000 becomes 100%, not 100 mm. + is_percent |= looks_like_missing_percent; + numeric_str = double_to_string(val); + update_control = true; + } else { + const bool value_changed = m_value.empty() || into_u8(str) != boost::any_cast(m_value); + if (looks_like_missing_percent && value_changed) { + if (!check_value) { + m_value.clear(); + break; + } + + const std::string sidetext = m_opt.sidetext.rfind("mm/s") != std::string::npos ? "mm/s" : "mm"; + const wxString stVal = numeric_str; + const wxString msg_text = from_u8((boost::format(_utf8(L("Is it %s%% or %s %s?"))) % + stVal % stVal % sidetext).str()); + WarningDialog dialog(m_parent, msg_text, _L("Parameter validation") + ": " + m_opt_id, wxYES | wxNO); + dialog.SetButtonLabel(wxID_YES, stVal + _L("%")); + dialog.SetButtonLabel(wxID_NO, stVal + " " + _L(sidetext)); + dialog.GetSizer()->SetSizeHints(&dialog); + dialog.Fit(); + dialog.CenterOnParent(); + is_percent = dialog.ShowModal() == wxID_YES; + update_control = true; + } } - const std::string sidetext = m_opt.sidetext.rfind("mm/s") != std::string::npos ? "mm/s" : "mm"; - const wxString stVal = double_to_string(val, 2); - const wxString msg_text = from_u8((boost::format(_utf8(L("Is it %s%% or %s %s?\n" - "YES for %s%%, \n" - "NO for %s %s."))) % - stVal % stVal % sidetext % stVal % stVal % sidetext) - .str()); - WarningDialog dialog(m_parent, msg_text, _L("Parameter validation") + ": " + m_opt_id, wxYES | wxNO); - if ((val > 100) && dialog.ShowModal() == wxID_YES) { - set_value(from_u8((boost::format("%s%%") % stVal).str()), false /*true*/); - str += "%%"; - } else - set_value(stVal, false); // it's no needed but can be helpful, when inputted value contained "," instead of "." + // Orca: also enforce the literal limit after clamping an explicit mm input. + if (!is_percent && m_opt.sidetext.rfind("mm ") != std::string::npos && val > m_opt.max_literal) { + if (!check_value) { + m_value.clear(); + break; + } + if (!out_of_range) + show_error(m_parent, _L("Value is out of range.")); + val = m_opt.max_literal; + numeric_str = double_to_string(val); + update_control = true; + } + } + + if (update_control) { + str = numeric_str + (is_percent ? "%" : ""); + set_value(str, true); } } if (m_opt.opt_key == "thumbnails") { @@ -2154,6 +2199,7 @@ void PrinterAgentChoice::msw_rescale() void PluginField::BUILD() { auto* panel = new wxPanel(m_parent, wxID_ANY); + panel->SetBackgroundColour(*wxWHITE); wxGetApp().UpdateDarkUI(panel); window = panel; @@ -2196,9 +2242,8 @@ void PluginField::rebuild_ui() m_rows.clear(); m_standalone_add_btn = nullptr; - if (m_values.empty()) { - add_empty_state_row(); - } else { + add_empty_state_row(); + if (!m_values.empty()) { for (size_t i = 0; i < m_values.size(); ++i) add_plugin_row(display_name_for_value(m_values[i]), i == m_values.size() - 1); } @@ -2215,94 +2260,43 @@ void PluginField::rebuild_ui() void PluginField::add_empty_state_row() { - const auto button_size = wxSize(def_width_thinner() * m_em_unit, -1); - auto row_sizer = new wxBoxSizer(wxHORIZONTAL); - - wxTextCtrl* display = new wxTextCtrl(window, wxID_ANY, _L("No plugin selected"), - wxDefaultPosition, wxSize(def_width_wider() * m_em_unit, wxDefaultCoord), - wxTE_READONLY); - display->SetEditable(false); - wxGetApp().UpdateDarkUI(display); - display->SetToolTip(_L("No plugin selected")); - - auto add_btn = new ScalableButton(window, wxID_ANY, "param_add", wxEmptyString, - button_size, wxDefaultPosition, wxBU_EXACTFIT | wxNO_BORDER, true, 16); - wxGetApp().UpdateDarkUI(add_btn); - add_btn->SetToolTip(_L("Add plugin")); + auto add_btn = new Button(window, _L("Add plugin"), "param_add", 0, 16); + add_btn->SetStyle(ButtonStyle::Regular, ButtonType::Parameter); add_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_add_clicked(); }); - row_sizer->Add(display, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); - row_sizer->Add(add_btn, 0, wxALIGN_CENTER_VERTICAL); - m_main_sizer->Add(row_sizer, 0, wxEXPAND); - - PluginRow row; - row.display = display; - row.add_btn = add_btn; - row.sizer = row_sizer; - m_rows.push_back(row); + m_main_sizer->Add(add_btn, 0, wxEXPAND | wxBOTTOM, window->FromDIP(SidebarProps::ContentMarginV())); m_standalone_add_btn = add_btn; } void PluginField::add_plugin_row(const wxString& value, bool is_last) { - const auto button_size = wxSize(def_width_thinner() * m_em_unit, -1); auto row_sizer = new wxBoxSizer(wxHORIZONTAL); - ScalableButton* select_btn = new ScalableButton(window, wxID_ANY, "search", wxEmptyString, - button_size, wxDefaultPosition, wxBU_EXACTFIT | wxNO_BORDER, true, 16); - wxGetApp().UpdateDarkUI(select_btn); - select_btn->SetToolTip(_L("Select plugin")); - - wxTextCtrl* display = new wxTextCtrl(window, wxID_ANY, value, - wxDefaultPosition, wxSize(def_width_wider() * m_em_unit, wxDefaultCoord), - wxTE_READONLY); - display->SetEditable(false); - wxGetApp().UpdateDarkUI(display); + ComboBox* display = new ComboBox(window, wxID_ANY, value, wxDefaultPosition, wxDefaultSize, 0, NULL, wxCB_READONLY | CB_NO_DROP_ICON); + display->SetIcon("edit"); display->SetToolTip(get_tooltip_text(value)); - ScalableButton* remove_btn = nullptr; - if (!m_opt.readonly) { - remove_btn = new ScalableButton(window, wxID_ANY, "cross", wxEmptyString, - button_size, wxDefaultPosition, wxBU_EXACTFIT | wxNO_BORDER, true, 16); - wxGetApp().UpdateDarkUI(remove_btn); - remove_btn->SetToolTip(_L("Remove plugin")); - } + ScalableButton* remove_btn = new ScalableButton(window, wxID_ANY, "cross", wxEmptyString, + wxDefaultSize, wxDefaultPosition, wxBU_EXACTFIT | wxNO_BORDER, true, 16); + remove_btn->SetToolTip(_L("Remove plugin")); - ScalableButton* add_btn = nullptr; - if (is_last && !m_opt.readonly) { - add_btn = new ScalableButton(window, wxID_ANY, "param_add", wxEmptyString, - button_size, wxDefaultPosition, wxBU_EXACTFIT | wxNO_BORDER, true, 16); - wxGetApp().UpdateDarkUI(add_btn); - add_btn->SetToolTip(_L("Add plugin")); - add_btn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_add_clicked(); }); - } + if (m_opt.readonly) + remove_btn->Disable(); const size_t row_index = m_rows.size(); - select_btn->Bind(wxEVT_BUTTON, [this, row_index](wxCommandEvent&) { on_select_clicked(row_index); }); - if (remove_btn) - remove_btn->Bind(wxEVT_BUTTON, [this, row_index](wxCommandEvent&) { on_remove_clicked(row_index); }); + display->Bind(wxEVT_LEFT_DOWN, [this, row_index](wxMouseEvent& ) { on_select_clicked(row_index); }); + remove_btn->Bind(wxEVT_BUTTON, [this, row_index](wxCommandEvent&) { on_remove_clicked(row_index); }); - row_sizer->Add(select_btn, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); - row_sizer->Add(display, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); - if (remove_btn) - row_sizer->Add(remove_btn, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 4); - if (add_btn) - row_sizer->Add(add_btn, 0, wxALIGN_CENTER_VERTICAL); - else if (!m_opt.readonly) { - // Reserve space equal to the add button so all rows align. - row_sizer->Add(button_size.GetWidth(), button_size.GetHeight(), 0, wxALIGN_CENTER_VERTICAL); - } + row_sizer->Add(display , 1, wxALIGN_CENTER_VERTICAL); + row_sizer->Add(remove_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, window->FromDIP(SidebarProps::ElementSpacing())); - const int bottom_gap = is_last ? 0 : 4; - m_main_sizer->Add(row_sizer, 0, wxEXPAND | (bottom_gap > 0 ? wxBOTTOM : 0), bottom_gap); + m_main_sizer->Add(row_sizer, 0, wxEXPAND | wxBOTTOM, window->FromDIP(is_last ? SidebarProps::ContentMarginV() : 4)); PluginRow row; - row.select_btn = select_btn; row.display = display; row.remove_btn = remove_btn; - row.add_btn = add_btn; row.sizer = row_sizer; m_rows.push_back(row); } @@ -2354,9 +2348,9 @@ void PluginField::on_add_clicked() m_values.push_back(selected); m_value = m_values; - rebuild_ui(); - - on_change_field(); + // Defer: don't destroy the clicked button from inside its own handler. + if(window) + window->CallAfter([this]() {rebuild_ui(); on_change_field();}); } void PluginField::on_remove_clicked(size_t index) @@ -2367,8 +2361,9 @@ void PluginField::on_remove_clicked(size_t index) m_values.erase(m_values.begin() + index); m_value = m_values; - rebuild_ui(); - on_change_field(); + // Defer: don't destroy the clicked button from inside its own handler. + if(window) + window->CallAfter([this]() {rebuild_ui(); on_change_field();}); } wxString PluginField::get_row_value(size_t index) const @@ -2382,7 +2377,7 @@ void PluginField::set_row_value(size_t index, const wxString& value) { if (index >= m_rows.size() || !m_rows[index].display) return; - m_rows[index].display->ChangeValue(value); + m_rows[index].display->SetValue(value); m_rows[index].display->SetToolTip(get_tooltip_text(value)); } @@ -2425,14 +2420,10 @@ boost::any& PluginField::get_value() void PluginField::enable() { for (auto& row : m_rows) { - if (row.select_btn) - row.select_btn->Enable(); if (row.display) row.display->Enable(); if (row.remove_btn) row.remove_btn->Enable(); - if (row.add_btn) - row.add_btn->Enable(); } if (m_standalone_add_btn) m_standalone_add_btn->Enable(); @@ -2441,14 +2432,10 @@ void PluginField::enable() void PluginField::disable() { for (auto& row : m_rows) { - if (row.select_btn) - row.select_btn->Disable(); if (row.display) row.display->Disable(); if (row.remove_btn) row.remove_btn->Disable(); - if (row.add_btn) - row.add_btn->Disable(); } if (m_standalone_add_btn) m_standalone_add_btn->Disable(); @@ -2711,7 +2698,8 @@ void ColourPicker::set_value(const boost::any& value, bool change_event) auto field = dynamic_cast(window); #ifdef __WXMSW__ - wxColour clr = (clr_str.IsEmpty() || !clr.IsOk()) ? wxTransparentColour : clr_str; + const wxColour parsed_clr(clr_str); + wxColour clr = (clr_str.IsEmpty() || !parsed_clr.IsOk()) ? wxTransparentColour : parsed_clr; field->SetColour(clr); draw_bmp_btn(field, clr); #else @@ -2862,11 +2850,11 @@ void PointCtrl::BUILD() //temp->Add(static_text_y, 0, wxALIGN_CENTER_VERTICAL, 0); temp->Add(y_input); - x_textctrl->Bind(wxEVT_TEXT_ENTER, ([this](wxCommandEvent e) { propagate_value(x_textctrl); }), x_textctrl->GetId()); - y_textctrl->Bind(wxEVT_TEXT_ENTER, ([this](wxCommandEvent e) { propagate_value(y_textctrl); }), y_textctrl->GetId()); + x_textctrl->Bind(wxEVT_TEXT_ENTER, ([this](wxCommandEvent e) { propagate_input_value(x_textctrl); }), x_textctrl->GetId()); + y_textctrl->Bind(wxEVT_TEXT_ENTER, ([this](wxCommandEvent e) { propagate_input_value(y_textctrl); }), y_textctrl->GetId()); - x_textctrl->Bind(wxEVT_KILL_FOCUS, ([this](wxEvent& e) { e.Skip(); propagate_value(x_textctrl); }), x_textctrl->GetId()); - y_textctrl->Bind(wxEVT_KILL_FOCUS, ([this](wxEvent& e) { e.Skip(); propagate_value(y_textctrl); }), y_textctrl->GetId()); + x_textctrl->Bind(wxEVT_KILL_FOCUS, ([this](wxEvent& e) { e.Skip(); propagate_input_value(x_textctrl); }), x_textctrl->GetId()); + y_textctrl->Bind(wxEVT_KILL_FOCUS, ([this](wxEvent& e) { e.Skip(); propagate_input_value(y_textctrl); }), y_textctrl->GetId()); // // recast as a wxWindow to fit the calling convention window = dynamic_cast(x_input); @@ -2915,7 +2903,7 @@ bool PointCtrl::value_was_changed(wxTextCtrl* win) return boost::any_cast(m_value) != boost::any_cast(val); } -void PointCtrl::propagate_value(wxTextCtrl* win) +void PointCtrl::propagate_input_value(wxTextCtrl* win) { if (win->GetValue().empty()) on_kill_focus(); diff --git a/src/slic3r/GUI/Field.hpp b/src/slic3r/GUI/Field.hpp index 5d5d549427..3f55bf5c5a 100644 --- a/src/slic3r/GUI/Field.hpp +++ b/src/slic3r/GUI/Field.hpp @@ -25,6 +25,7 @@ #include "wxExtensions.hpp" #include "Widgets/SpinInput.hpp" #include "Widgets/TextInput.hpp" +#include "Widgets/ComboBox.hpp" #ifdef __WXMSW__ #define wxMSW true @@ -532,10 +533,8 @@ public: private: struct PluginRow { - ScalableButton* select_btn { nullptr }; - wxTextCtrl* display { nullptr }; + ComboBox* display { nullptr }; ScalableButton* remove_btn { nullptr }; - ScalableButton* add_btn { nullptr }; wxBoxSizer* sizer { nullptr }; }; @@ -553,7 +552,7 @@ private: wxBoxSizer* m_main_sizer { nullptr }; std::vector m_rows; std::vector m_values; - ScalableButton* m_standalone_add_btn { nullptr }; + Button* m_standalone_add_btn { nullptr }; std::function m_selector; }; @@ -628,8 +627,10 @@ private: void on_button_click(wxCommandEvent &WXUNUSED(ev)); void save_colors_to_config(); private: +#if !defined(__linux__) && !defined(__LINUX__) wxColourData* m_clrData{nullptr}; wxColourPickerWidget* m_picker_widget{nullptr}; +#endif }; class PointCtrl : public Field { @@ -649,7 +650,7 @@ public: void BUILD() override; bool value_was_changed(wxTextCtrl* win); // Propagate value from field to the OptionGroupe and Config after kill_focus/ENTER - void propagate_value(wxTextCtrl* win); + void propagate_input_value(wxTextCtrl* win); void set_value(const Vec2d& value, bool change_event = false); void set_value(const boost::any& value, bool change_event = false) override; boost::any& get_value() override; diff --git a/src/slic3r/GUI/FilamentBitmapUtils.cpp b/src/slic3r/GUI/FilamentBitmapUtils.cpp index 1f51fc79b3..3af3d1ad01 100644 --- a/src/slic3r/GUI/FilamentBitmapUtils.cpp +++ b/src/slic3r/GUI/FilamentBitmapUtils.cpp @@ -1,16 +1,66 @@ #include +#include #include +#include +#include #include #include +#include +#include +#include +#include #include "EncodedFilament.hpp" #include "FilamentBitmapUtils.hpp" #include "GUI_App.hpp" +#include "GuiColor.hpp" +#include "I18N.hpp" +#include "Widgets/Label.hpp" +#include "Widgets/StateColor.hpp" #include "libslic3r/FilamentMixer.hpp" #include "libslic3r/PrintConfig.hpp" namespace Slic3r { namespace GUI { +// Barycentric utilities for a ternary (triangle) ratio picker. +double tri_signed_area2(TriPoint a, TriPoint b, TriPoint c) +{ + return (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y); +} + +bool tri_contains(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2) +{ + double total = tri_signed_area2(v0, v1, v2); + if (std::abs(total) < 1e-9) return false; + double s0 = tri_signed_area2(p, v1, v2) / total; + double s1 = tri_signed_area2(v0, p, v2) / total; + double s2 = 1.0 - s0 - s1; + return s0 >= -0.001 && s1 >= -0.001 && s2 >= -0.001; +} + +void tri_barycentric(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2, + double& w0, double& w1, double& w2) +{ + double total = std::abs(tri_signed_area2(v0, v1, v2)); + if (total < 1e-9) { w0 = w1 = w2 = 1.0 / 3.0; return; } + w0 = std::abs(tri_signed_area2(p, v1, v2)) / total; + w1 = std::abs(tri_signed_area2(v0, p, v2)) / total; + w2 = 1.0 - w0 - w1; + w0 = std::clamp(w0, 0.0, 1.0); + w1 = std::clamp(w1, 0.0, 1.0); + w2 = std::clamp(w2, 0.0, 1.0); + double s = w0 + w1 + w2; + if (s > 0) { w0 /= s; w1 /= s; w2 /= s; } +} + +TriPoint tri_clamp(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2) +{ + double w0, w1, w2; + tri_barycentric(p, v0, v1, v2, w0, w1, w2); + return {w0 * v0.x + w1 * v1.x + w2 * v2.x, + w0 * v0.y + w1 * v1.y + w2 * v2.y}; +} + void fill_gradient_rect_east(wxDC& dc, const wxRect& rect, const wxColour& from, const wxColour& to) { if (rect.width <= 0 || rect.height <= 0) return; @@ -73,7 +123,7 @@ std::vector sample_gradient_ramp(const wxColour& first, // Resolve the curve a gradient slot is sampled with, mirroring the slicer's fallback in // ToolOrdering: a custom curve wins, otherwise a straight line between gradient_range's // endpoints, otherwise the 0.10 -> 0.90 default. -static Slic3r::GradientCurve mixed_gradient_curve(const Slic3r::DynamicPrintConfig& cfg, size_t slot) +Slic3r::GradientCurve mixed_gradient_curve(const Slic3r::DynamicPrintConfig& cfg, size_t slot) { const auto* curve_opt = cfg.option("filament_mixed_gradient_curve"); if (curve_opt && slot < curve_opt->values.size() && !curve_opt->values[slot].empty()) { @@ -449,4 +499,378 @@ void recompute_mixed_slot_colors(std::vector& colors, } } +namespace { + +// Layout ratios of the gradient plot rect, copied from GradientCurveEditor so the read-only +// preview and the interactive editor stay pixel-identical. Plot rect is square 1:1; the +// right/bottom margins host the axis arrows and labels. +constexpr double kPlotLeftRatio = 0.0316; +constexpr double kPlotRightRatio = 0.6766; +constexpr double kPlotTopRatio = 0.1529; +constexpr double kPlotBottomRatio = 0.8474; +constexpr int kGridDivisions = 9; // 10 grid lines including the outer borders. +constexpr int kStrokeAxis = 2; // axis line width (px, no DPI scaling) +constexpr int kAxisArrowHalf = 5; // half-base of the axis arrow triangle (DIP) +constexpr int kAxisArrowLen = 10; // length of the axis arrow triangle (DIP) +constexpr int kPointRadius = 4; // anchor outer radius (DIP) +constexpr float kBgSimilarThreshold = 15.0f; +constexpr int kOutlineExtraDip = 2; +constexpr double kTriangleMarginDip = 20.0; + +// Quadratic blend that never goes out of gamut, matching MixedFilamentDialog::blend_colors. +wxColour lerp_blend(const wxColour& a, const wxColour& b, double ratio_a) +{ + unsigned char r, g, bl; + Slic3r::filament_mixer_lerp(a.Red(), a.Green(), a.Blue(), + b.Red(), b.Green(), b.Blue(), + static_cast(1.0 - ratio_a), &r, &g, &bl); + return wxColour(r, g, bl); +} + +// DIP conversion for these free functions: unlike the wxWindow member FromDIP, it needs the +// window parameter explicitly; nullptr picks the app's default DPI like the Publish dialog does. +int dip_px(int v) { return wxWindow::FromDIP(v, nullptr); } + +} // namespace + +wxRect mixed_gradient_plot_rect(const wxSize& sz) +{ + const int x = static_cast(std::lround(sz.x * kPlotLeftRatio)); + const int y = static_cast(std::lround(sz.y * kPlotTopRatio)); + const int x2 = static_cast(std::lround(sz.x * kPlotRightRatio)); + const int y2 = static_cast(std::lround(sz.y * kPlotBottomRatio)); + const int side = std::max(1, std::min(x2 - x, y2 - y)); + return wxRect(x, y, side, side); +} + +void draw_mixed_gradient_plot(wxDC& raw_dc, const wxSize& canvas, + const std::vector& curves, + const std::vector& anchors, + const MixedGradientTheme& theme) +{ + // Draw into an internal opaque buffer so wxGCDC text/curves anti-alias against a solid + // background (never a transparent one), then blit the finished image onto the caller's + // buffered paint DC. wxGCDC cannot wrap a generic wxDC&, so the buffer is always a + // wxMemoryDC -- the one type wxGCDC accepts on every platform. + if (canvas.x <= 0 || canvas.y <= 0) + return; + const wxRect rc = mixed_gradient_plot_rect(canvas); + if (rc.width <= 0 || rc.height <= 0) + return; + + wxBitmap buf(canvas); + wxMemoryDC memdc(buf); + memdc.SetBackground(wxBrush(theme.background)); + memdc.Clear(); + wxGCDC dc(memdc); + wxGraphicsContext* gc = dc.GetGraphicsContext(); + + // 10x10 light grid (10 lines including outer borders, 9 equal divisions). + dc.SetPen(wxPen(theme.grid, 1)); + for (int i = 0; i <= kGridDivisions; ++i) { + const int x = rc.x + rc.width * i / kGridDivisions; + const int y = rc.y + rc.height * i / kGridDivisions; + dc.DrawLine(x, rc.y, x, rc.y + rc.height); + dc.DrawLine(rc.x, y, rc.x + rc.width, y); + } + + // Set the label font first so text width measurements drive arrow / label placement. + wxFont label_font = wxSystemSettings::GetFont(wxSYS_DEFAULT_GUI_FONT); + label_font.SetPointSize(std::max(7, label_font.GetPointSize() - 1)); + dc.SetFont(label_font); + + const wxString axis_y_title = _L("Material Ratio"); + const wxString axis_x_title = _L("Model Height"); + const wxString pct_text = wxT("100%"); + const wxSize x_title_sz = dc.GetTextExtent(axis_x_title); + const wxSize y_title_sz = dc.GetTextExtent(axis_y_title); + + wxFont strong_font = label_font; + strong_font.SetWeight(wxFONTWEIGHT_SEMIBOLD); + dc.SetFont(strong_font); + const wxSize pct_text_sz = dc.GetTextExtent(pct_text); + dc.SetFont(label_font); + + // Axes (grey 700) with filled triangle arrows. Y-axis extends above the plot top to the + // canvas top edge; X-axis extends past the plot right toward the canvas right edge. + const int arrow_half = dip_px(kAxisArrowHalf); + const int arrow_len = dip_px(kAxisArrowLen); + dc.SetPen(wxPen(theme.axis, kStrokeAxis)); + dc.SetBrush(wxBrush(theme.axis)); + + const int y_axis_x = rc.x; + const int y_title_pct_gap = dip_px(1); + const int y_title_bottom_pad = dip_px(2); + const int y_title_y = std::max(0, rc.y - y_title_sz.y - y_title_pct_gap - pct_text_sz.y - y_title_bottom_pad); + const int y_arrow_tip_y = y_title_y; + const int y_arrow_ty = y_arrow_tip_y + arrow_len; + dc.DrawLine(y_axis_x, y_arrow_ty, y_axis_x, rc.y + rc.height); + { + wxPoint tri[3] = { + wxPoint(y_axis_x, y_arrow_tip_y), + wxPoint(y_axis_x - arrow_half, y_arrow_ty), + wxPoint(y_axis_x + arrow_half, y_arrow_ty), + }; + dc.DrawPolygon(3, tri); + } + + const int x_axis_y = rc.y + rc.height; + const int x_label_gap = dip_px(4); + const int x_edge_pad = dip_px(6); + const int x_arrow_ideal = rc.x + rc.width + dip_px(10); + const int x_arrow_max = canvas.x - x_title_sz.x - x_label_gap - x_edge_pad - arrow_len; + const int x_arrow_tx = std::max(rc.x + rc.width + arrow_len, std::min(x_arrow_ideal, x_arrow_max)); + const int x_arrow_tip_x = x_arrow_tx + arrow_len; + const int x_title_x = x_arrow_tip_x + x_label_gap; + dc.DrawLine(rc.x, x_axis_y, x_arrow_tx, x_axis_y); + { + wxPoint tri[3] = { + wxPoint(x_arrow_tip_x, x_axis_y), + wxPoint(x_arrow_tx, x_axis_y - arrow_half), + wxPoint(x_arrow_tx, x_axis_y + arrow_half), + }; + dc.DrawPolygon(3, tri); + } + + // Labels: "Material Ratio" and the leading "100%" share the same left x; the trailing + // "Model Height" follows the X-axis arrow tip (already clamped to make room). + const int label_left_x = y_axis_x + dip_px(10); + dc.SetTextForeground(theme.label); + dc.DrawText(axis_y_title, label_left_x, y_title_y); + + dc.SetFont(strong_font); + dc.SetTextForeground(theme.label_strong); + dc.DrawText(pct_text, label_left_x, y_title_y + y_title_sz.y + y_title_pct_gap); + + dc.DrawText(pct_text, rc.x + rc.width - pct_text_sz.x, x_axis_y); + dc.SetFont(label_font); + dc.SetTextForeground(theme.label); + dc.DrawText(axis_x_title, x_title_x, x_axis_y - x_title_sz.y / 2); + + if (!gc) + return; + + // Outline only when the curve colour is perceptually close to the background; otherwise the + // plain filament colour reads fine and the extra stroke would look heavy. + auto needs_outline = [&](const wxColour& c) { + return calc_color_distance(c, theme.background) < kBgSimilarThreshold; + }; + + // Only the geometry goes through the graphics context: dc.DrawLines() takes integer wxPoint + // and would quantize the curve back to whole pixels. The pen is still set on the dc, which + // forwards it here while keeping its own cached state in sync for later dc drawing. + auto draw_polyline = [&](const MixedGradientCurve& curve) { + if (curve.points.size() < 2) + return; + dc.SetPen(wxPen(curve.colour, dip_px(curve.stroke_dip))); + gc->StrokeLines(curve.points.size(), curve.points.data()); + }; + + for (const MixedGradientCurve& curve : curves) { + if (needs_outline(curve.colour)) + draw_polyline({curve.points, theme.outline, curve.stroke_dip + kOutlineExtraDip}); + draw_polyline(curve); + } + + // Control points: hollow circle with axis-colour border, theme-aware fill, drawn with a + // sub-pixel centre so the ring stays centred on the curve. + if (!anchors.empty()) { + const double r = dip_px(kPointRadius); + dc.SetPen(wxPen(theme.axis, 1)); + dc.SetBrush(wxBrush(theme.point_fill)); + for (const wxPoint2DDouble& p : anchors) + gc->DrawEllipse(p.m_x - r, p.m_y - r, r * 2, r * 2); + } + + memdc.SelectObject(wxNullBitmap); + raw_dc.DrawBitmap(buf, 0, 0); +} + +void draw_mixed_ratio_blend_bar(wxDC& dc, const wxRect& rect, const wxColour& first, + const wxColour& second, double second_fraction) +{ + if (rect.width <= 0 || rect.height <= 0) + return; + for (int x = 0; x < rect.width; ++x) { + const double t = rect.width > 1 ? double(x) / rect.width : 0.0; + const wxColour c = lerp_blend(first, second, 1.0 - t); + dc.SetPen(wxPen(c)); + dc.DrawLine(rect.x + x, rect.y, rect.x + x, rect.y + rect.height); + } + + // Fixed in both themes, like the triangle picker's drag handle: the divider is drawn over + // blended filament colour, so it has to keep its contrast against data rather than chrome. + const int div_x = rect.x + static_cast(second_fraction * rect.width); + dc.SetPen(wxPen(wxColour(80, 80, 80), dip_px(4))); + dc.DrawLine(div_x, rect.y, div_x, rect.y + rect.height); + dc.SetPen(wxPen(*wxWHITE, dip_px(2))); + dc.DrawLine(div_x, rect.y, div_x, rect.y + rect.height); +} + +void draw_mixed_ratio_segments(wxDC& dc, const wxRect& rect, const std::vector& colours, + const std::vector& shares) +{ + const size_t n = std::min(colours.size(), shares.size()); + if (n == 0 || rect.width <= 0 || rect.height <= 0) + return; + std::vector norm = shares; + double total = 0.0; + for (double s : norm) + total += s; + if (total <= 0.0) { + norm.assign(n, 1.0 / n); + total = 1.0; + } + auto share_to_px = [&](double share_sum) { return rect.x + int(std::lround(share_sum / total * double(rect.width))); }; + int x0 = rect.x; + std::vector segs(n); + for (size_t i = 0; i < n; ++i) { + int x1 = rect.x + rect.width; + if (i + 1 < n) + x1 = share_to_px(std::accumulate(norm.begin(), norm.begin() + i + 1, 0.0)); + segs[i] = wxRect(x0, rect.y, std::max(1, x1 - x0), rect.height); + x0 = segs[i].GetRight() + 1; + } + for (size_t i = 0; i < n; ++i) { + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(colours[i])); + dc.DrawRectangle(segs[i]); + } + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.SetPen(wxPen(StateColor::darkModeColorFor(wxColour("#ACACAC")), 1)); + dc.DrawRectangle(rect); +} + +namespace { + +struct TriCacheKey +{ + int w, h; + int c0r, c0g, c0b, c1r, c1g, c1b, c2r, c2g, c2b; + int bg_r, bg_g, bg_b, ol_r, ol_g, ol_b; + bool operator<(const TriCacheKey& o) const + { + return std::tie(w, h, c0r, c0g, c0b, c1r, c1g, c1b, c2r, c2g, c2b, bg_r, bg_g, bg_b, ol_r, ol_g, ol_b) < + std::tie(o.w, o.h, o.c0r, o.c0g, o.c0b, o.c1r, o.c1g, o.c1b, o.c2r, o.c2g, o.c2b, o.bg_r, o.bg_g, o.bg_b, o.ol_r, o.ol_g, o.ol_b); + } +}; + +std::map& tri_cache() +{ + static std::map cache; + return cache; +} + +} // namespace + +std::array mixed_triangle_vertices(const wxSize& size, double margin_dip) +{ + const double pw = size.GetWidth(), ph = size.GetHeight(); + const double margin = dip_px(int(margin_dip)); + const double avail = std::min(pw, ph) - 2.0 * margin; + const double side = avail; + const double tri_h = side * std::sqrt(3.0) / 2.0; + const double cx = pw / 2.0; + const double top_y = (ph - tri_h) / 2.0; + return {{{cx, top_y}, {cx - side / 2.0, top_y + tri_h}, {cx + side / 2.0, top_y + tri_h}}}; +} + +void draw_mixed_triangle_picker(wxDC& dc, const wxSize& size, const std::array& colours, + const std::array& weights, const MixedTriangleTheme& theme) +{ + if (size.GetWidth() <= 0 || size.GetHeight() <= 0) + return; + const std::array v = mixed_triangle_vertices(size, kTriangleMarginDip); + + dc.SetBrush(wxBrush(theme.background)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, size.GetWidth(), size.GetHeight()); + + const wxColour& c0 = colours[0]; + const wxColour& c1 = colours[1]; + const wxColour& c2 = colours[2]; + const TriCacheKey key{size.GetWidth(), size.GetHeight(), + c0.Red(), c0.Green(), c0.Blue(), + c1.Red(), c1.Green(), c1.Blue(), + c2.Red(), c2.Green(), c2.Blue(), + theme.background.Red(), theme.background.Green(), theme.background.Blue(), + theme.outline.Red(), theme.outline.Green(), theme.outline.Blue()}; + + wxBitmap& bmp = tri_cache()[key]; + if (!bmp.IsOk()) { + bmp = wxBitmap(size.GetWidth(), size.GetHeight(), 24); + wxMemoryDC mdc(bmp); + mdc.SetBrush(wxBrush(theme.background)); + mdc.SetPen(*wxTRANSPARENT_PEN); + mdc.DrawRectangle(0, 0, size.GetWidth(), size.GetHeight()); + + const int min_y = int(std::min({v[0].y, v[1].y, v[2].y})); + const int max_y = int(std::max({v[0].y, v[1].y, v[2].y})); + const int min_x = int(std::min({v[0].x, v[1].x, v[2].x})); + const int max_x = int(std::max({v[0].x, v[1].x, v[2].x})); + for (int py = min_y; py <= max_y; ++py) { + for (int px = min_x; px <= max_x; ++px) { + const TriPoint p = {double(px), double(py)}; + if (!tri_contains(p, v[0], v[1], v[2])) + continue; + double w0, w1, w2; + tri_barycentric(p, v[0], v[1], v[2], w0, w1, w2); + unsigned char mr, mg, mb; + if (w0 + w1 > 1e-6) { + float t01 = float(w1 / (w0 + w1)); + Slic3r::filament_mixer_lerp(c0.Red(), c0.Green(), c0.Blue(), c1.Red(), c1.Green(), c1.Blue(), t01, &mr, &mg, &mb); + Slic3r::filament_mixer_lerp(mr, mg, mb, c2.Red(), c2.Green(), c2.Blue(), float(w2), &mr, &mg, &mb); + } else { + mr = c2.Red(); mg = c2.Green(); mb = c2.Blue(); + } + mdc.SetPen(wxPen(wxColour(mr, mg, mb))); + mdc.DrawPoint(px, py); + } + } + + mdc.SetPen(wxPen(theme.outline, 1)); + mdc.SetBrush(*wxTRANSPARENT_BRUSH); + const wxPoint pts[3] = {{int(v[0].x), int(v[0].y)}, {int(v[1].x), int(v[1].y)}, {int(v[2].x), int(v[2].y)}}; + mdc.DrawPolygon(3, pts); + mdc.SelectObject(wxNullBitmap); + + // Keep the cache from growing without bound across DPI/size changes. + if (tri_cache().size() > 6) { + auto& cache = tri_cache(); + cache.erase(cache.begin()); + } + } + dc.DrawBitmap(bmp, 0, 0); + + // Published-ratio marker (read-only twin of the editor's drag handle). + const double w0 = weights[0], w1 = weights[1], w2 = weights[2]; + const int hx = int(w0 * v[0].x + w1 * v[1].x + w2 * v[2].x); + const int hy = int(w0 * v[0].y + w1 * v[1].y + w2 * v[2].y); + dc.SetBrush(*wxWHITE_BRUSH); + dc.SetPen(wxPen(theme.ring, dip_px(2))); + dc.DrawCircle(hx, hy, dip_px(5)); +} + +void draw_mixed_triangle_labels(wxDC& dc, const wxSize& size, const std::array& weights, + const MixedTriangleTheme& theme) +{ + const std::array v = mixed_triangle_vertices(size, kTriangleMarginDip); + dc.SetFont(::Label::Body_12); + dc.SetTextForeground(theme.label); + + // "Ratio" title, sitting above the top vertex. + const wxString title = _L("Ratio"); + dc.DrawText(title, dip_px(2), std::max(0, int(v[0].y - dc.GetTextExtent(title).GetHeight() - dip_px(4)))); + + for (int i = 0; i < 3; ++i) { + const wxString text = wxString::Format("%d%%", int(std::lround(weights[i] * 100.0))); + const wxSize tsz = dc.GetTextExtent(text); + int lx = int(v[i].x - tsz.GetWidth() / 2.0); + int ly = (i == 0) ? int(v[i].y - tsz.GetHeight() - dip_px(4)) : int(v[i].y + dip_px(3)); + ly = std::clamp(ly, 0, size.GetHeight() - tsz.GetHeight()); + lx = std::clamp(lx, 0, size.GetWidth() - tsz.GetWidth()); + dc.DrawText(text, lx, ly); + } +} + }} // namespace Slic3r::GUI \ No newline at end of file diff --git a/src/slic3r/GUI/FilamentBitmapUtils.hpp b/src/slic3r/GUI/FilamentBitmapUtils.hpp index 11696f3401..687d34a825 100644 --- a/src/slic3r/GUI/FilamentBitmapUtils.hpp +++ b/src/slic3r/GUI/FilamentBitmapUtils.hpp @@ -5,6 +5,9 @@ #include #include #include +#include +#include +#include #include // Orca: forward-declare so the header is self-contained outside libslic3r_gui's @@ -13,6 +16,16 @@ namespace Slic3r { class DynamicPrintConfig; struct GradientCurve; } namespace Slic3r { namespace GUI { +// Barycentric utilities for a ternary (triangle) ratio picker, shared by the mixed-filament +// editor and the Publish dialog's read-only definition preview. +struct TriPoint { double x, y; }; + +double tri_signed_area2(TriPoint a, TriPoint b, TriPoint c); +bool tri_contains(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2); +void tri_barycentric(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2, + double& w0, double& w1, double& w2); +TriPoint tri_clamp(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2); + // Fills a rect with a west->east linear gradient by drawing solid 1px columns. // Use instead of wxDC::GradientFillLinear, whose CoreGraphics (CGShading) backend // fails to render on some macOS builds; solid fills are unaffected. @@ -51,6 +64,12 @@ std::vector sample_gradient_ramp(const wxColour& first, // destination's height in pixels. std::vector mixed_gradient_ramp(const Slic3r::DynamicPrintConfig& cfg, size_t slot, int steps); +// Resolve the curve a gradient slot is sampled with: the custom curve wins when it has at +// least two points, otherwise a straight line between gradient_range's endpoints, otherwise +// the 0.10 -> 0.90 default. Mirrors the slicer's ToolOrdering fallback so every preview +// agrees with what gets sliced. Always returns a two-point curve. +Slic3r::GradientCurve mixed_gradient_curve(const Slic3r::DynamicPrintConfig& cfg, size_t slot); + // Fill rect with a ramp, ramp.front() along the bottom edge. void fill_gradient_ramp_rect(wxDC& dc, const wxRect& rect, const std::vector& ramp); @@ -63,6 +82,81 @@ wxBitmap create_gradient_ramp_bitmap(const std::vector& ramp, const wx void recompute_mixed_slot_colors(std::vector& colors, const Slic3r::DynamicPrintConfig& cfg); +// --- Gradient plot (shared by GradientCurveEditor and the Publish dialog's read-only +// preview). The plot is a square 1:1 rect laid out with the editor's ratios so both +// render identically; curves are drawn as sub-pixel anti-aliased polylines through +// wxGCDC so they never quantize to whole pixels. + +// One curve of the plot: screen-space sub-pixel points already mapped into the plot +// rect, the stroke colour and the stroke width in DIP. +struct MixedGradientCurve +{ + std::vector points; + wxColour colour; + int stroke_dip; +}; + +// Theme tokens, resolved by the caller through StateColor::darkModeColorFor. +struct MixedGradientTheme +{ + wxColour background; // for near-background outline detection + wxColour grid; // grid line + wxColour axis; // axis + arrow fill + wxColour label; // "Material Ratio" / "Model Height" + wxColour label_strong; // "100%" + wxColour outline; // near-background curve lift + wxColour point_fill; // anchor fill +}; + +// Square 1:1 plot rect inside `canvas`, using the editor's plot ratios. +wxRect mixed_gradient_plot_rect(const wxSize& canvas); + +// Draw the whole plot (grid, axes + arrowheads, axis labels, each curve with an optional +// near-background outline, and anchor circles). `anchors` are empty when the caller has +// none to show. `dc` is the caller's buffered paint DC; a wxGCDC is created inside so the +// geometry gets anti-aliased. +void draw_mixed_gradient_plot(wxDC& dc, const wxSize& canvas, + const std::vector& curves, + const std::vector& anchors, + const MixedGradientTheme& theme); + +// --- Ratio bar (2-component continuous blend + divider, matching MixedFilamentDialog). +// Colours blend first->second across the rect; the divider marks `second_fraction` of the +// rect's width (the second component's share, 0..1). +void draw_mixed_ratio_blend_bar(wxDC& dc, const wxRect& rect, const wxColour& first, + const wxColour& second, double second_fraction); + +// Fallback ratio bar for N>2 non-gradient slots: one solid segment per component, +// widths proportional to shares. Label text (the "NN%" inside wide-enough segments) is +// the caller's concern. +void draw_mixed_ratio_segments(wxDC& dc, const wxRect& rect, const std::vector& colours, + const std::vector& shares); + +// --- Triangle picker (3-component), shared by MixedFilamentDialog and the Publish preview. +struct MixedTriangleTheme +{ + wxColour background; + wxColour outline; // triangle border + wxColour ring; // drag-handle ring + wxColour label; // "Ratio" title + per-vertex labels +}; + +// The three vertices of the read-only/miniature triangle inside a `size` square panel, +// with `margin_dip` inset. Order: top, bottom-left, bottom-right. +std::array mixed_triangle_vertices(const wxSize& size, double margin_dip = 20.0); + +// Draw background, the cached barycentric fill, the outline and the drag-handle marker. +// `weights` are the three barycentric shares (sum 1). The "Ratio" title and per-vertex +// percentage labels are drawn by the caller so the interactive editor can keep its own +// live child labels while the read-only preview draws them as text. +void draw_mixed_triangle_picker(wxDC& dc, const wxSize& size, const std::array& colours, + const std::array& weights, const MixedTriangleTheme& theme); + +// Draw the "Ratio" title plus one "NN%" label per vertex (used by the read-only preview; +// the interactive editor positions its own live labels instead). +void draw_mixed_triangle_labels(wxDC& dc, const wxSize& size, const std::array& weights, + const MixedTriangleTheme& theme); + }} // namespace Slic3r::GUI #endif // slic3r_GUI_FilamentBitmapUtils_hpp_ \ No newline at end of file diff --git a/src/slic3r/GUI/FilamentMapPanel.cpp b/src/slic3r/GUI/FilamentMapPanel.cpp index 81117bc2a6..0f3cc7217d 100644 --- a/src/slic3r/GUI/FilamentMapPanel.cpp +++ b/src/slic3r/GUI/FilamentMapPanel.cpp @@ -1,5 +1,6 @@ #include "FilamentMapPanel.hpp" #include "GUI_App.hpp" +#include "I18N.hpp" #include "Plater.hpp" #include "Widgets/MultiNozzleSync.hpp" // manuallySetNozzleCount producer for extruder_nozzle_stats #include diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index 6cdc27ed65..e63501eec1 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -1078,56 +1078,36 @@ const double GLCanvas3D::DefaultCameraZoomToPlateMarginFactor = 1.25; void GLCanvas3D::load_arrange_settings() { - std::string dist_fff_str = - wxGetApp().app_config->get("arrange", "min_object_distance_fff"); + // Each key must match what _render_arrange_menu writes, which appends a per-mode + // postfix to the base name. + auto load_float = [](const char *key, float &out) { + // The menu writes these with float_to_string_decimal_point, so parse them back + // the same way rather than with anything locale-dependent. + std::string value = wxGetApp().app_config->get("arrange", key); + size_t parsed = 0; + double number = string_to_double_decimal_point(value, &parsed); + if (parsed > 0) + out = float(number); + }; + auto load_bool = [](const char *key, bool &out) { + std::string value = wxGetApp().app_config->get("arrange", key); + if (!value.empty()) + out = (value == "1" || value == "true"); + }; - std::string dist_fff_seq_print_str = - wxGetApp().app_config->get("arrange", "min_object_distance_seq_print_fff"); + load_float("min_object_distance_fff", m_arrange_settings_fff.distance); + load_float("min_object_distance_fff_seq_print", m_arrange_settings_fff_seq_print.distance); + load_float("min_object_distance_sla", m_arrange_settings_sla.distance); - std::string dist_sla_str = - wxGetApp().app_config->get("arrange", "min_object_distance_sla"); + load_bool("enable_rotation_fff", m_arrange_settings_fff.enable_rotation); + load_bool("enable_rotation_fff_seq_print", m_arrange_settings_fff_seq_print.enable_rotation); + load_bool("enable_rotation_sla", m_arrange_settings_sla.enable_rotation); - std::string en_rot_fff_str = - wxGetApp().app_config->get("arrange", "enable_rotation_fff"); - - std::string en_rot_fff_seqp_str = - wxGetApp().app_config->get("arrange", "enable_rotation_seq_print"); - - std::string en_rot_sla_str = - wxGetApp().app_config->get("arrange", "enable_rotation_sla"); - - std::string en_allow_multiple_materials_str = - wxGetApp().app_config->get("arrange", "allow_multi_materials_on_same_plate"); - - std::string en_avoid_region_str = - wxGetApp().app_config->get("arrange", "avoid_extrusion_cali_region"); - - - - if (!dist_fff_str.empty()) - m_arrange_settings_fff.distance = std::stof(dist_fff_str); - - if (!dist_fff_seq_print_str.empty()) - m_arrange_settings_fff_seq_print.distance = std::stof(dist_fff_seq_print_str); - - if (!dist_sla_str.empty()) - m_arrange_settings_sla.distance = std::stof(dist_sla_str); - - if (!en_rot_fff_str.empty()) - m_arrange_settings_fff.enable_rotation = (en_rot_fff_str == "1" || en_rot_fff_str == "true"); - - if (!en_allow_multiple_materials_str.empty()) - m_arrange_settings_fff.allow_multi_materials_on_same_plate = (en_allow_multiple_materials_str == "1" || en_allow_multiple_materials_str == "true"); - - - if (!en_rot_fff_seqp_str.empty()) - m_arrange_settings_fff_seq_print.enable_rotation = (en_rot_fff_seqp_str == "1" || en_rot_fff_seqp_str == "true"); - - if(!en_avoid_region_str.empty()) - m_arrange_settings_fff.avoid_extrusion_cali_region = (en_avoid_region_str == "1" || en_avoid_region_str == "true"); - - if (!en_rot_sla_str.empty()) - m_arrange_settings_sla.enable_rotation = (en_rot_sla_str == "1" || en_rot_sla_str == "true"); + // These two keys carry no postfix, so the one stored value covers both FFF modes. + load_bool("allow_multi_materials_on_same_plate", m_arrange_settings_fff.allow_multi_materials_on_same_plate); + load_bool("allow_multi_materials_on_same_plate", m_arrange_settings_fff_seq_print.allow_multi_materials_on_same_plate); + load_bool("avoid_extrusion_cali_region", m_arrange_settings_fff.avoid_extrusion_cali_region); + load_bool("avoid_extrusion_cali_region", m_arrange_settings_fff_seq_print.avoid_extrusion_cali_region); //BBS: add specific arrange settings m_arrange_settings_fff_seq_print.is_seq_print = true; @@ -2191,7 +2171,7 @@ void GLCanvas3D::render(bool only_init) // Negative coordinate means out of the window, likely because the window was deactivated. // In that case the tooltip should be hidden. - if (m_mouse.position.x() >= 0. && m_mouse.position.y() >= 0. || has_mouse_capture()) { // ORCA continue to capture mouse pos mid drag + if ((m_mouse.position.x() >= 0. && m_mouse.position.y() >= 0.) || has_mouse_capture()) { // ORCA continue to capture mouse pos mid drag if (tooltip.empty()) tooltip = m_layers_editing.get_tooltip(*this); @@ -2891,23 +2871,37 @@ void GLCanvas3D::reload_scene(bool refresh_immediately, bool force_full_scene_re DynamicPrintConfig& proj_cfg = wxGetApp().preset_bundle->project_config; float x = dynamic_cast(proj_cfg.option("wipe_tower_x"))->get_at(plate_id); float y = dynamic_cast(proj_cfg.option("wipe_tower_y"))->get_at(plate_id); - float w = dynamic_cast(m_config->option("prime_tower_width"))->value; float a = dynamic_cast(m_config->option("wipe_tower_rotation_angle"))->value; - // BBS - float v = dynamic_cast(m_config->option("prime_volume"))->value; Vec3d plate_origin = ppl.get_plate(plate_id)->get_origin(); - const Print* print = m_process->fff_print(); const Print* current_print = part_plate->fff_print(); - if (!need_wipe_tower && part_plate->get_extruders(true).size() < 2) continue; if (part_plate->get_objects_on_this_plate().empty()) continue; - float brim_width = print->wipe_tower_data(filaments_count).brim_width; - int nozzle_nums = wxGetApp().preset_bundle->get_printer_extruder_count(); - Vec3d wipe_tower_size = ppl.get_plate(plate_id)->estimate_wipe_tower_size(full_config, w, v, nozzle_nums, 0, false, dynamic_cast(dconfig.option("enable_wrapping_detection"))->value); + // Body and brim from this plate's own estimate: m_process->fff_print() is the + // selected plate's, so an auto brim drew every tower with that plate's brim. + const WipeTowerFootprint footprint = part_plate->estimate_wipe_tower_footprint(full_config); + // The estimate is also the answer to whether this plate prints a tower; + // deciding it here as well only gave the two room to drift. + if (footprint.depth <= 0.) continue; + float brim_width = float(footprint.brim_width); + Vec3d wipe_tower_size(footprint.width, footprint.depth, footprint.height); + + // set_default_wipe_tower_pos_for_plate doesn't rerun when painting changes the + // filament count, so redo its clamp here on every reload — unconditionally: a + // paint-triggered reload can arrive before the background process invalidates + // psWipeTower, so gating on it would skip the clamp exactly when it is needed. + { + Vec3d clamped_pos, clamped_size; + part_plate->estimate_wipe_tower_polygon(full_config, plate_id, clamped_pos, clamped_size); + if (std::abs(x - (float) clamped_pos(0)) > EPSILON || std::abs(y - (float) clamped_pos(1)) > EPSILON) { + x = (float) clamped_pos(0); + y = (float) clamped_pos(1); + ConfigOptionFloat wt_x_opt(x), wt_y_opt(y); + dynamic_cast(proj_cfg.option("wipe_tower_x"))->set_at(&wt_x_opt, plate_id, 0); + dynamic_cast(proj_cfg.option("wipe_tower_y"))->set_at(&wt_y_opt, plate_id, 0); + } + } - // The stored position is already clamped onto the bed, by - // set_default_wipe_tower_pos_for_plate and again on every drag. if (!current_print->is_step_done(psWipeTower) || !current_print->wipe_tower_data().wipe_tower_mesh_data) { // update for wipe tower position int volume_idx_wipe_tower_new = m_volumes.load_wipe_tower_preview(1000 + plate_id, x + plate_origin(0), y + plate_origin(1), @@ -5045,7 +5039,21 @@ void GLCanvas3D::do_move(const std::string& snapshot_type) } //BBS: notify instance updates to part plater list - m_selection.notify_instance_update(-1, 0); + // Only what moved: the selected instances, or every instance of an object one of whose + // parts moved. Notifying a plate about an instance that stayed put invalidates its slice + // result, and notifying instance 0 alone left a moved copy unregistered on its new plate. + { + std::set> notified; + for (unsigned int i : m_selection.get_volume_idxs()) { + const GLVolume* v = m_volumes.volumes[i]; + const int object_idx = v->object_idx(); + if (object_idx < 0 || object_idx >= static_cast(m_model->objects.size())) + continue; + const std::pair key(object_idx, selection_mode == Selection::Volume ? -1 : v->instance_idx()); + if (notified.insert(key).second) + m_selection.notify_instance_update(key.first, key.second); + } + } // Fixes sinking/flying instances (snaps object to buildplate) for (const std::pair& i : done) { @@ -5931,7 +5939,7 @@ bool GLCanvas3D::_render_orient_menu(float left, float right, float bottom, floa } //BBS: GUI refactor: adjust main toolbar position -bool GLCanvas3D::_render_arrange_menu(float left, float right, float bottom, float top) +void GLCanvas3D::_render_arrange_menu(float left, float right, float bottom, float top) { ImGuiWrapper *imgui = wxGetApp().imgui(); @@ -5956,7 +5964,6 @@ bool GLCanvas3D::_render_arrange_menu(float left, float right, float bottom, flo imgui->begin(_L("Arrange options"), ImGuiWindowFlags_NoMove | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar); - ArrangeSettings settings = get_arrange_settings(); ArrangeSettings &settings_out = get_arrange_settings(); const float slider_icon_width = imgui->get_slider_icon_size().x; const float cursor_slider_left = imgui->calc_text_size(_L("Spacing")).x + imgui->scaled(1.5f); @@ -5965,13 +5972,9 @@ bool GLCanvas3D::_render_arrange_menu(float left, float right, float bottom, flo auto &appcfg = wxGetApp().app_config; PrinterTechnology ptech = current_printer_technology(); - bool settings_changed = false; - float dist_min = 0.f; // 0 means auto std::string dist_key = "min_object_distance", rot_key = "enable_rotation"; - std::string bed_shrink_x_key = "bed_shrink_x", bed_shrink_y_key = "bed_shrink_y"; std::string multi_material_key = "allow_multi_materials_on_same_plate"; std::string avoid_extrusion_key = "avoid_extrusion_cali_region"; - std::string align_to_y_axis_key = "align_to_y_axis"; std::string postfix; //BBS: bool seq_print = false; @@ -5979,59 +5982,41 @@ bool GLCanvas3D::_render_arrange_menu(float left, float right, float bottom, flo if (ptech == ptSLA) { postfix = "_sla"; } else if (ptech == ptFFF) { - seq_print = &settings == &m_arrange_settings_fff_seq_print; - if (seq_print) { - postfix = "_fff_seq_print"; - } else { - postfix = "_fff"; - } + seq_print = wxGetApp().global_print_sequence() == PrintSequence::ByObject; + postfix = seq_print ? "_fff_seq_print" : "_fff"; } dist_key += postfix; rot_key += postfix; - bed_shrink_x_key += postfix; - bed_shrink_y_key += postfix; ImGui::AlignTextToFramePadding(); imgui->text(_L("Spacing")); ImGui::SameLine(1.2 * cursor_slider_left); ImGui::PushItemWidth(window_width - slider_icon_width); - bool b_Spacing = imgui->bbl_slider_float_style("##Spacing", &settings.distance, dist_min, 100.0f, "%5.2f") || dist_min > settings.distance; + bool b_Spacing = imgui->bbl_slider_float_style("##Spacing", &settings_out.distance, 0.f, 100.0f, "%5.2f", 1.0f, /*clamp=*/false); ImGui::SameLine(window_width - slider_icon_width + 1.3 * cursor_slider_left); ImGui::PushItemWidth(1.5 * slider_icon_width); - bool b_spacing_input = ImGui::BBLDragFloat("##spacing_input", &settings.distance, 0.05f, 0.0f, 0.0f, "%.2f"); - if (b_Spacing || b_spacing_input) - { - settings.distance = std::max(dist_min, settings.distance); - settings_out.distance = settings.distance; + bool b_spacing_input = ImGui::BBLDragFloat("##spacing_input", &settings_out.distance, 0.05f, 0.0f, 0.0f, "%.2f"); + if (b_Spacing || b_spacing_input) { + settings_out.distance = std::max(0.f, settings_out.distance); appcfg->set("arrange", dist_key.c_str(), float_to_string_decimal_point(settings_out.distance)); - settings_changed = true; } imgui->text(_L("0 means auto spacing.")); ImGui::Separator(); - if (imgui->bbl_checkbox(_L("Auto rotate for arrangement"), settings.enable_rotation)) { - settings_out.enable_rotation = settings.enable_rotation; + if (imgui->bbl_checkbox(_L("Auto rotate for arrangement"), settings_out.enable_rotation)) appcfg->set("arrange", rot_key.c_str(), settings_out.enable_rotation); - settings_changed = true; - } - if (imgui->bbl_checkbox(_L("Allow multiple materials on same plate"), settings.allow_multi_materials_on_same_plate)) { - settings_out.allow_multi_materials_on_same_plate = settings.allow_multi_materials_on_same_plate; - appcfg->set("arrange", multi_material_key.c_str(), settings_out.allow_multi_materials_on_same_plate ); - settings_changed = true; - } + if (imgui->bbl_checkbox(_L("Allow multiple materials on same plate"), settings_out.allow_multi_materials_on_same_plate)) + appcfg->set("arrange", multi_material_key.c_str(), settings_out.allow_multi_materials_on_same_plate); // only show this option if the printer has micro Lidar and can do first layer scan DynamicPrintConfig ¤t_config = wxGetApp().preset_bundle->printers.get_edited_preset().config; const bool has_lidar = wxGetApp().preset_bundle->is_bbl_vendor(); auto op = current_config.option("scan_first_layer"); if (has_lidar && op && op->getBool()) { - if (imgui->bbl_checkbox(_L("Avoid extrusion calibration region"), settings.avoid_extrusion_cali_region)) { - settings_out.avoid_extrusion_cali_region = settings.avoid_extrusion_cali_region; - appcfg->set("arrange", avoid_extrusion_key.c_str(), settings_out.avoid_extrusion_cali_region ? "1" : "0"); - settings_changed = true; - } + if (imgui->bbl_checkbox(_L("Avoid extrusion calibration region"), settings_out.avoid_extrusion_cali_region)) + appcfg->set("arrange", avoid_extrusion_key.c_str(), settings_out.avoid_extrusion_cali_region); } else { settings_out.avoid_extrusion_cali_region = false; } @@ -6043,11 +6028,7 @@ bool GLCanvas3D::_render_arrange_menu(float left, float right, float bottom, flo settings_out.align_to_y_axis = false; } - if (imgui->bbl_checkbox(_L("Align to Y axis"), settings.align_to_y_axis)) { - settings_out.align_to_y_axis = settings.align_to_y_axis; - appcfg->set("arrange", align_to_y_axis_key, settings_out.align_to_y_axis ? "1" : "0"); - settings_changed = true; - } + imgui->bbl_checkbox(_L("Align to Y axis"), settings_out.align_to_y_axis); if (settings_out.enable_rotation == true) { imgui->disabled_end(); } } @@ -6063,7 +6044,6 @@ bool GLCanvas3D::_render_arrange_menu(float left, float right, float bottom, flo if (imgui->button(_L("Reset"))) { settings_out = ArrangeSettings{}; - settings_out.distance = std::max(dist_min, settings_out.distance); //BBS: add specific arrange settings if (seq_print) settings_out.is_seq_print = true; @@ -6073,18 +6053,16 @@ bool GLCanvas3D::_render_arrange_menu(float left, float right, float bottom, flo else settings_out.align_to_y_axis = false; - appcfg->set("arrange", dist_key, float_to_string_decimal_point(settings_out.distance)); - appcfg->set("arrange", rot_key, settings_out.enable_rotation ? "1" : "0"); - appcfg->set("arrange", align_to_y_axis_key, settings_out.align_to_y_axis ? "1" : "0"); - settings_changed = true; + appcfg->erase("arrange", dist_key); + appcfg->erase("arrange", rot_key); + appcfg->erase("arrange", multi_material_key); + appcfg->erase("arrange", avoid_extrusion_key); } ImGui::PopStyleVar(1); imgui->end(); //BBS ImGuiWrapper::pop_toolbar_style(); - - return settings_changed; } static const float cameraProjection[16] = {1.f, 0.f, 0.f, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 0.f, 1.f}; @@ -9766,18 +9744,18 @@ void GLCanvas3D::_render_paint_toolbar() const ImVec2 number_label_size = ImGui::CalcTextSize(std::to_string(i + 1).c_str()); ImGui::SetCursorPosY(cursor_y + text_offset_y); ImGui::SetCursorPosX(spacing + i * (spacing + button_size.x) + (button_size.x - number_label_size.x) / 2); - ImGui::TextColored(text_color, std::to_string(i + 1).c_str()); + ImGui::TextColored(text_color, "%s", std::to_string(i + 1).c_str()); imgui.pop_bold_font(); ImVec2 filament_first_line_label_size = ImGui::CalcTextSize(filament_text_first_line[i].c_str()); ImGui::SetCursorPosY(cursor_y + text_offset_y + number_label_size.y); ImGui::SetCursorPosX(spacing + i * (spacing + button_size.x) + (button_size.x - filament_first_line_label_size.x) / 2); - ImGui::TextColored(text_color, filament_text_first_line[i].c_str()); + ImGui::TextColored(text_color, "%s", filament_text_first_line[i].c_str()); ImVec2 filament_second_line_label_size = ImGui::CalcTextSize(filament_text_second_line[i].c_str()); ImGui::SetCursorPosY(cursor_y + text_offset_y + number_label_size.y + filament_first_line_label_size.y); ImGui::SetCursorPosX(spacing + i * (spacing + button_size.x) + (button_size.x - filament_second_line_label_size.x) / 2); - ImGui::TextColored(text_color, filament_text_second_line[i].c_str()); + ImGui::TextColored(text_color, "%s", filament_text_second_line[i].c_str()); } if (ImGui::GetWindowWidth() == constraint_window_width) { @@ -10004,9 +9982,9 @@ void GLCanvas3D::_render_assemble_info() const double size1 = m_selection.get_bounding_box().size()(1); double size2 = m_selection.get_bounding_box().size()(2); if (!m_selection.is_empty()) { - ImGui::Text(_L("Volume:").ToUTF8()); ImGui::SameLine(caption_max); + ImGui::Text("%s", _L("Volume:").ToUTF8().data()); ImGui::SameLine(caption_max); ImGui::Text("%.2f", size0 * size1 * size2); - ImGui::Text(_L("Size:").ToUTF8()); ImGui::SameLine(caption_max); + ImGui::Text("%s", _L("Size:").ToUTF8().data()); ImGui::SameLine(caption_max); ImGui::Text("%.2f x %.2f x %.2f", size0, size1, size2); } imgui->end(); diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index 84dbd5d652..b1dd674d96 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -656,11 +656,7 @@ public: } void load_arrange_settings(); - ArrangeSettings& get_arrange_settings();// { return get_arrange_settings(this); } - ArrangeSettings& get_arrange_settings(PrintSequence print_seq) { - return (print_seq == PrintSequence::ByObject) ? m_arrange_settings_fff_seq_print - : m_arrange_settings_fff; - } + ArrangeSettings& get_arrange_settings(); class SequentialPrintClearance { @@ -1163,17 +1159,6 @@ public: void highlight_toolbar_item(const std::string& item_name); void highlight_gizmo(const std::string& gizmo_name); - ArrangeSettings get_arrange_settings() const { - const ArrangeSettings &settings = get_arrange_settings(); - ArrangeSettings ret = settings; - if (&settings == &m_arrange_settings_fff_seq_print) { - ret.distance = std::max(ret.distance, - float(min_object_distance(*m_config))); - } - - return ret; - } - // Timestamp for FPS calculation and notification fade-outs. static int64_t timestamp_now() { #ifdef _WIN32 @@ -1308,7 +1293,7 @@ private: void _render_selection_sidebar_hints() { m_selection.render_sidebar_hints(m_sidebar_field, m_gizmos.get_uniform_scaling()); } //BBS: GUI refactor: adjust main toolbar position bool _render_orient_menu(float left, float right, float bottom, float top); - bool _render_arrange_menu(float left, float right, float bottom, float top); + void _render_arrange_menu(float left, float right, float bottom, float top); void _render_3d_navigator(); void _update_volumes_hover_state(); diff --git a/src/slic3r/GUI/GLTexture.cpp b/src/slic3r/GUI/GLTexture.cpp index d670181b1b..fbdb308c56 100644 --- a/src/slic3r/GUI/GLTexture.cpp +++ b/src/slic3r/GUI/GLTexture.cpp @@ -9,6 +9,7 @@ #include "3DScene.hpp" #include "OpenGLManager.hpp" #include "GUI_App.hpp" +#include "GUI.hpp" #include "GLModel.hpp" #include @@ -31,6 +32,7 @@ #include "GUI_App.hpp" #include #include +#include namespace Slic3r { namespace GUI { diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 3500493329..966cf49013 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -3,6 +3,14 @@ #include "libslic3r/Technologies.hpp" #include "libslic3r/Platform.hpp" #include "GUI_App.hpp" +#include "BindDialog.hpp" +#include "DeviceManager.hpp" +#include "HMS.hpp" +#include "PresetBundleDialog.hpp" +#include "WebUserLoginDialog.hpp" +#include "WebViewDialog.hpp" +#include "slic3r/Utils/BBLCloudServiceAgent.hpp" +#include "slic3r/Utils/NetworkAgent.hpp" #include "GUI_Init.hpp" #include "GUI_ObjectList.hpp" #include "slic3r/GUI/UserManager.hpp" @@ -598,7 +606,7 @@ wxString file_wildcards(FileType file_type, const std::string &custom_extension) static std::string libslic3r_translate_callback(const char *s) { return wxGetTranslation(wxString(s, wxConvUTF8)).utf8_str().data(); } #ifdef WIN32 -static GUID GUID_DEVINTERFACE_HID = { 0x4D1E55B2, 0xF16F, 0x11CF, 0x88, 0xCB, 0x00, 0x11, 0x11, 0x00, 0x00, 0x30 }; +static GUID GUID_DEVINTERFACE_HID = { 0x4D1E55B2, 0xF16F, 0x11CF, { 0x88, 0xCB, 0x00, 0x11, 0x11, 0x00, 0x00, 0x30 } }; static void register_win32_device_notification_event() { @@ -6808,7 +6816,7 @@ bool GUI_App::check_preset_parent_available(const std::pair>& preset_data) { - Preset::Type type; + Preset::Type type = Preset::Type::TYPE_INVALID; if (preset_data.second.at(BBL_JSON_KEY_TYPE) == PRESET_IOT_PRINT_TYPE) type = Preset::Type::TYPE_PRINT; else if (preset_data.second.at(BBL_JSON_KEY_TYPE) == PRESET_IOT_PRINTER_TYPE) @@ -7769,21 +7777,6 @@ void GUI_App::stop_http_server() m_http_server.stop(); } -void GUI_App::switch_staff_pick(bool on) -{ - mainframe->m_webview->SendDesignStaffpick(on); -} - -bool GUI_App::switch_language() -{ - if (select_language()) { - recreate_GUI(_L("Switching application language") + dots); - return true; - } else { - return false; - } -} - #ifdef __linux__ static const wxLanguageInfo* linux_get_existing_locale_language(const wxLanguageInfo* language, const wxLanguageInfo* system_language) @@ -7878,72 +7871,6 @@ int GUI_App::GetSingleChoiceIndex(const wxString& message, #endif } -// select language from the list of installed languages -bool GUI_App::select_language() -{ - wxArrayString translations = wxTranslations::Get()->GetAvailableTranslations(SLIC3R_APP_KEY); - std::vector language_infos; - language_infos.emplace_back(wxLocale::GetLanguageInfo(wxLANGUAGE_ENGLISH)); - for (size_t i = 0; i < translations.GetCount(); ++ i) { - const wxLanguageInfo *langinfo = wxLocale::FindLanguageInfo(translations[i]); - if (langinfo != nullptr) - language_infos.emplace_back(langinfo); - } - sort_remove_duplicates(language_infos); - std::sort(language_infos.begin(), language_infos.end(), [](const wxLanguageInfo* l, const wxLanguageInfo* r) { return l->Description < r->Description; }); - - wxArrayString names; - names.Alloc(language_infos.size()); - - // Some valid language should be selected since the application start up. - const wxString active_language_code = current_language_code(); - const wxLanguageInfo* active_language_info = wxLocale::FindLanguageInfo(active_language_code); - const wxLanguage current_language = active_language_info != nullptr ? wxLanguage(active_language_info->Language) : wxLanguage(m_wxLocale->GetLanguage()); - const wxString active_lang_prefix = active_language_code.BeforeFirst('_'); - int init_selection = -1; - int init_selection_alt = -1; - int init_selection_default = -1; - for (size_t i = 0; i < language_infos.size(); ++ i) { - if (wxLanguage(language_infos[i]->Language) == current_language) - // The dictionary matches the active language and country. - init_selection = i; - else if ((language_infos[i]->CanonicalName.BeforeFirst('_') == active_lang_prefix) || - // if the active language is Slovak, mark the Czech language as active. - (language_infos[i]->CanonicalName.BeforeFirst('_') == "cs" && active_lang_prefix == "sk")) - // The dictionary matches the active language, it does not necessarily match the country. - init_selection_alt = i; - if (language_infos[i]->CanonicalName.BeforeFirst('_') == "en") - // This will be the default selection if the active language does not match any dictionary. - init_selection_default = i; - names.Add(language_infos[i]->Description); - } - if (init_selection == -1) - // This is the dictionary matching the active language. - init_selection = init_selection_alt; - if (init_selection != -1) - // This is the language to highlight in the choice dialog initially. - init_selection_default = init_selection; - - const long index = GetSingleChoiceIndex(_L("Select the language"), _L("Language"), names, init_selection_default); - // Try to load a new language. - if (index != -1 && (init_selection == -1 || init_selection != index)) { - const wxLanguageInfo *new_language_info = language_infos[index]; - if (this->load_language(new_language_info->CanonicalName, false)) { - // Save language at application config. - // Which language to save as the selected dictionary language? - // 1) Hopefully the language set to wxTranslations by this->load_language(), but that API is weird and we don't want to rely on its - // stability in the future: - // wxTranslations::Get()->GetBestTranslation(SLIC3R_APP_KEY, wxLANGUAGE_ENGLISH); - // 2) Current locale language may not match the dictionary name, see GH issue #3901 - // m_wxLocale->GetCanonicalName() - // 3) new_language_info->CanonicalName is a safe bet. It points to a valid dictionary name. - app_config->set("language", new_language_info->CanonicalName.ToUTF8().data()); - return true; - } - } - - return false; -} // Load gettext translation files and activate them at the start of the application, // based on the "language" key stored in the application config. @@ -8330,146 +8257,6 @@ void GUI_App::show_ip_address_enter_dialog_handler(wxCommandEvent& evt) show_modal_ip_address_enter_dialog(mode == -1?false:true, title); } -//void GUI_App::add_config_menu(wxMenuBar *menu) -//void GUI_App::add_config_menu(wxMenu *menu) -//{ -// auto local_menu = new wxMenu(); -// wxWindowID config_id_base = wxWindow::NewControlId(int(ConfigMenuCnt)); -// -// const auto config_wizard_name = _(ConfigWizard::name(true)); -// const auto config_wizard_tooltip = from_u8((boost::format(_utf8(L("Open %s"))) % config_wizard_name).str()); -// // Cmd+, is standard on OS X - what about other operating systems? -// if (is_editor()) { -// local_menu->Append(config_id_base + ConfigMenuWizard, config_wizard_name + dots, config_wizard_tooltip); -// local_menu->Append(config_id_base + ConfigMenuUpdate, _L("Check for Configuration Updates"), _L("Check for configuration updates")); -// local_menu->AppendSeparator(); -// } -// local_menu->Append(config_id_base + ConfigMenuPreferences, _L("Preferences") + dots + -//#ifdef __APPLE__ -// "\tCtrl+,", -//#else -// "\tCtrl+P", -//#endif -// _L("Application preferences")); -// wxMenu* mode_menu = nullptr; -// if (is_editor()) { -// local_menu->AppendSeparator(); -// mode_menu = new wxMenu(); -// mode_menu->AppendRadioItem(config_id_base + ConfigMenuModeSimple, _L("Simple"), _L("Simple Mode")); -// mode_menu->AppendRadioItem(config_id_base + ConfigMenuModeAdvanced, _L("Advanced"), _L("Advanced Mode")); -// Bind(wxEVT_UPDATE_UI, [this](wxUpdateUIEvent& evt) { if (get_mode() == comSimple) evt.Check(true); }, config_id_base + ConfigMenuModeSimple); -// Bind(wxEVT_UPDATE_UI, [this](wxUpdateUIEvent& evt) { if (get_mode() == comAdvanced) evt.Check(true); }, config_id_base + ConfigMenuModeAdvanced); -// -// local_menu->AppendSubMenu(mode_menu, _L("Mode"), wxString::Format(_L("%s Mode"), SLIC3R_APP_NAME)); -// } -// local_menu->AppendSeparator(); -// local_menu->Append(config_id_base + ConfigMenuLanguage, _L("Language")); -// if (is_editor()) { -// local_menu->AppendSeparator(); -// } -// -// local_menu->Bind(wxEVT_MENU, [this, config_id_base](wxEvent &event) { -// switch (event.GetId() - config_id_base) { -// case ConfigMenuWizard: -// run_wizard(ConfigWizard::RR_USER); -// break; -// case ConfigMenuUpdate: -// check_updates(true); -// break; -//#ifdef __linux__ -// case ConfigMenuDesktopIntegration: -// show_desktop_integration_dialog(); -// break; -//#endif -// case ConfigMenuSnapshots: -// //BBS do not support task snapshot -// break; -// case ConfigMenuPreferences: -// { -// //BBS GUI refactor: remove unuse layout logic -// //bool app_layout_changed = false; -// { -// // the dialog needs to be destroyed before the call to recreate_GUI() -// // or sometimes the application crashes into wxDialogBase() destructor -// // so we put it into an inner scope -// PreferencesDialog dlg(mainframe); -// dlg.ShowModal(); -// //BBS GUI refactor: remove unuse layout logic -// //app_layout_changed = dlg.settings_layout_changed(); -// if (dlg.seq_top_layer_only_changed()) -// this->plater_->refresh_print(); -// -// if (dlg.recreate_GUI()) { -// recreate_GUI(_L("Restart application") + dots); -// return; -// } -//#ifdef _WIN32 -// if (is_editor()) { -// if (app_config->get("associate_3mf") == "true") -// associate_3mf_files(); -// if (app_config->get("associate_stl") == "true") -// associate_stl_files(); -// } -// else { -// if (app_config->get("associate_gcode") == "true") -// associate_gcode_files(); -// } -//#endif // _WIN32 -// } -// //BBS GUI refactor: remove unuse layout logic -// /*if (app_layout_changed) { -// // hide full main_sizer for mainFrame -// mainframe->GetSizer()->Show(false); -// mainframe->update_layout(); -// mainframe->select_tab(size_t(0)); -// }*/ -// break; -// } -// case ConfigMenuLanguage: -// { -// /* Before change application language, let's check unsaved changes on 3D-Scene -// * and draw user's attention to the application restarting after a language change -// */ -// { -// // the dialog needs to be destroyed before the call to switch_language() -// // or sometimes the application crashes into wxDialogBase() destructor -// // so we put it into an inner scope -// wxString title = is_editor() ? wxString(SLIC3R_APP_NAME) : wxString(GCODEVIEWER_APP_NAME); -// title += " - " + _L("Choose language"); -// //wxMessageDialog dialog(nullptr, -// MessageDialog dialog(nullptr, -// _L("Switching the language requires application restart.\n") + "\n\n" + -// _L("Do you want to continue?"), -// title, -// wxICON_QUESTION | wxOK | wxCANCEL); -// if (dialog.ShowModal() == wxID_CANCEL) -// return; -// } -// -// switch_language(); -// break; -// } -// case ConfigMenuFlashFirmware: -// //BBS FirmwareDialog::run(mainframe); -// break; -// default: -// break; -// } -// }); -// -// using std::placeholders::_1; -// -// if (mode_menu != nullptr) { -// auto modfn = [this](int mode, wxCommandEvent&) { if (get_mode() != mode) save_mode(mode); }; -// mode_menu->Bind(wxEVT_MENU, std::bind(modfn, comSimple, _1), config_id_base + ConfigMenuModeSimple); -// mode_menu->Bind(wxEVT_MENU, std::bind(modfn, comAdvanced, _1), config_id_base + ConfigMenuModeAdvanced); -// } -// -// // BBS -// //menu->Append(local_menu, _L("Configuration")); -// menu->AppendSubMenu(local_menu, _L("Configuration")); -//} - void GUI_App::open_presetbundledialog(size_t open_on_tab, const std::string& highlight_option) { bool app_layout_changed = false; @@ -9418,7 +9205,7 @@ int GUI_App::filaments_cnt() const PrintSequence GUI_App::global_print_sequence() const { PrintSequence global_print_seq = PrintSequence::ByDefault; - auto curr_preset_config = preset_bundle->prints.get_edited_preset().config; + const auto &curr_preset_config = preset_bundle->prints.get_edited_preset().config; if (curr_preset_config.has("print_sequence")) global_print_seq = curr_preset_config.option>("print_sequence")->value; return global_print_seq; diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 8bf32df64c..f6f0b81c92 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -1,23 +1,17 @@ #ifndef slic3r_GUI_App_hpp_ #define slic3r_GUI_App_hpp_ +#include #include #include #include "ActionRegistry.hpp" #include "ImGuiWrapper.hpp" #include "ConfigWizard.hpp" #include "OpenGLManager.hpp" -#include "PresetBundleDialog.hpp" #include "libslic3r/Preset.hpp" #include "libslic3r/PresetBundle.hpp" -#include "slic3r/GUI/DeviceManager.hpp" #include "slic3r/GUI/UserNotification.hpp" -#include "slic3r/Utils/NetworkAgent.hpp" -#include "slic3r/Utils/BBLCloudServiceAgent.hpp" -#include "slic3r/GUI/WebViewDialog.hpp" -#include "slic3r/GUI/WebUserLoginDialog.hpp" -#include "slic3r/GUI/BindDialog.hpp" -#include "slic3r/GUI/HMS.hpp" +#include "slic3r/Utils/CloudProvider.hpp" #include "slic3r/GUI/Jobs/UpgradeNetworkJob.hpp" #include "slic3r/GUI/HttpServer.hpp" #include "../Utils/PrintHost.hpp" @@ -64,9 +58,14 @@ class ModelObject; class Model; class UserManager; class DeviceManager; +class MachineObject; class NetworkAgent; +class IPrinterAgent; class TaskManager; +// Same typedef as in bambu_networking.hpp, so this header need not include it. +typedef std::function WasCancelledFn; + namespace GUI{ class RemovableDriveManager; @@ -85,6 +84,8 @@ class ParamsDialog; class HMSQuery; class ModelMallDialog; class PingCodeBindDialog; +class PresetBundleDialog; +class ZUserLogin; class NetworkErrorDialog; class PluginsDialog; class SpeedDialWebDialog; @@ -569,7 +570,6 @@ public: void start_http_server(const std::string& provider = ORCA_CLOUD_PROVIDER); void start_http_server(int port, const std::string& provider = ORCA_CLOUD_PROVIDER); void stop_http_server(); - void switch_staff_pick(bool on); void on_show_check_privacy_dlg(int online_login = 0, const std::string& provider = ORCA_CLOUD_PROVIDER); void show_check_privacy_dlg(wxCommandEvent& evt); @@ -583,7 +583,6 @@ public: void persist_window_geometry(wxTopLevelWindow *window, bool default_maximized = false); void update_ui_from_settings(); - bool switch_language(); bool load_language(wxString language, bool initial); Tab* get_tab(Preset::Type type); @@ -801,7 +800,6 @@ private: bool window_pos_restore(wxTopLevelWindow* window, const std::string &name, bool default_maximized = false); void window_pos_sanitize(wxTopLevelWindow* window); void window_pos_center(wxTopLevelWindow *window); - bool select_language(); // Dynamic printer agent selection - internal helpers for switch_printer_agent // and the plugin load/unload callbacks (init_plugin_gui_wiring). @@ -832,7 +830,7 @@ wxDECLARE_EVENT(EVT_UPDATE_BUNDLE_COMPLETE, wxCommandEvent); bool is_support_filament(int extruder_id, bool strict_check = true); bool is_soluble_filament(int extruder_id); // check if the filament for model is in the list -bool has_filaments(const std::vector& model_filaments); +bool has_filaments(const std::vector& model_filaments); } // namespace GUI } // Slic3r diff --git a/src/slic3r/GUI/GUI_ObjectTable.cpp b/src/slic3r/GUI/GUI_ObjectTable.cpp index 35508c6113..a496eca6d3 100644 --- a/src/slic3r/GUI/GUI_ObjectTable.cpp +++ b/src/slic3r/GUI/GUI_ObjectTable.cpp @@ -2578,7 +2578,7 @@ void ObjectGridTable::OnSelectCell(int row, int col) return; m_panel->m_side_window->Freeze(); if (row == 0 || col == col_filaments) { - m_panel->m_object_settings->UpdateAndShow(row, false, false, false, nullptr, nullptr, std::string()); + m_panel->m_object_settings->UpdateAndShowRow(row, false, false, false, nullptr, nullptr, std::string()); } else { ObjectGridRow* grid_row = m_grid_data[row - 1]; @@ -2588,7 +2588,7 @@ void ObjectGridTable::OnSelectCell(int row, int col) //m_panel->m_object_settings->get_og()->set_name(GUI::from_u8(grid_row->name.value)); //m_panel->m_page_text->SetLabel(GUI::from_u8(grid_row->name.value)); - m_panel->m_object_settings->UpdateAndShow(row, true, is_object, false, object, grid_row->config, grid_col->category); + m_panel->m_object_settings->UpdateAndShowRow(row, true, is_object, false, object, grid_row->config, grid_col->category); std::vector object_volume_ids; ObjectVolumeID object_volume_id; diff --git a/src/slic3r/GUI/GUI_ObjectTable.hpp b/src/slic3r/GUI/GUI_ObjectTable.hpp index a5a0059e1b..0fbd432e02 100644 --- a/src/slic3r/GUI/GUI_ObjectTable.hpp +++ b/src/slic3r/GUI/GUI_ObjectTable.hpp @@ -591,16 +591,11 @@ private: wxColour m_hover_colour; wxBoxSizer* m_top_sizer{nullptr}; wxBoxSizer* m_page_sizer{nullptr}; - wxBoxSizer* m_page_top_sizer{nullptr}; - wxTextCtrl* m_search_line{ nullptr }; ObjectGrid* m_object_grid{nullptr}; ObjectGridTable* m_object_grid_table{nullptr}; - wxStaticText* m_page_text{nullptr}; - ScalableButton* m_global_reset{nullptr}; wxScrolledWindow* m_side_window{nullptr}; ObjectTableSettings* m_object_settings{ nullptr }; Model* m_model{nullptr}; - ModelConfig* m_config {nullptr}; Plater* m_plater{nullptr}; int m_cur_row { -1 }; @@ -625,8 +620,6 @@ class ObjectTableDialog : public GUI::DPIDialog const int POPUP_HEIGHT = FromDIP(1024); //wxPanel* m_panel{ nullptr }; - wxBoxSizer* m_top_sizer{ nullptr }; - wxStaticText* m_static_title{ nullptr }; //wxTimer* m_refresh_timer; ObjectTablePanel* m_obj_panel{ nullptr }; Model* m_model{ nullptr }; diff --git a/src/slic3r/GUI/GUI_ObjectTableSettings.cpp b/src/slic3r/GUI/GUI_ObjectTableSettings.cpp index 2290018419..4cd272840f 100644 --- a/src/slic3r/GUI/GUI_ObjectTableSettings.cpp +++ b/src/slic3r/GUI/GUI_ObjectTableSettings.cpp @@ -463,7 +463,7 @@ void ObjectTableSettings::update_config_values(bool is_object, ModelObject* obje m_table->reload_cell_data(m_current_row, category); } -void ObjectTableSettings::UpdateAndShow(int row, const bool show, bool is_object, bool is_multiple_selection, ModelObject* object, ModelConfig* config, const std::string& category) +void ObjectTableSettings::UpdateAndShowRow(int row, const bool show, bool is_object, bool is_multiple_selection, ModelObject* object, ModelConfig* config, const std::string& category) { m_current_row = row; m_current_category = category; diff --git a/src/slic3r/GUI/GUI_ObjectTableSettings.hpp b/src/slic3r/GUI/GUI_ObjectTableSettings.hpp index 39e7e514e2..24e3d427a9 100644 --- a/src/slic3r/GUI/GUI_ObjectTableSettings.hpp +++ b/src/slic3r/GUI/GUI_ObjectTableSettings.hpp @@ -71,7 +71,7 @@ public: //return visible count int update_extra_column_visible_status(ConfigOptionsGroup* option_group, const std::vector& option_keys, ModelConfig* config); void update_config_values(bool is_object, ModelObject* object, ModelConfig* config, const std::string& category, const std::string& changed_opt_key = ""); - void UpdateAndShow(int row, const bool show, bool is_object, bool is_multiple_selection, ModelObject* object, ModelConfig* config, const std::string& category); + void UpdateAndShowRow(int row, const bool show, bool is_object, bool is_multiple_selection, ModelObject* object, ModelConfig* config, const std::string& category); void ValueChanged(int row, bool is_object, ModelObject* object, ModelConfig* config, const std::string& category, const std::string& key); void resetAllValues(int row, bool is_object, ModelObject* object, ModelConfig* config, const std::string& category); void msw_rescale(); diff --git a/src/slic3r/GUI/GUI_Utils.cpp b/src/slic3r/GUI/GUI_Utils.cpp index cb8ba45c6b..10dd29c9c1 100644 --- a/src/slic3r/GUI/GUI_Utils.cpp +++ b/src/slic3r/GUI/GUI_Utils.cpp @@ -69,6 +69,8 @@ CopyFileResult copy_file_gui(const std::string &from, const std::string &to, std HANDLE handlesrc = nullptr; HANDLE handledst = nullptr; CopyFileResult ret = SUCCESS; + DWORD size = 0; + DWORD dwRead = 0, dwWrite = 0; handlesrc = CreateFile(src.wc_str(), GENERIC_READ, @@ -96,13 +98,12 @@ CopyFileResult copy_file_gui(const std::string &from, const std::string &to, std goto __finished; } - DWORD size=GetFileSize(handlesrc,NULL); + size = GetFileSize(handlesrc,NULL); buff = new char[size+1]; - DWORD dwRead=0,dwWrite; result = ReadFile(handlesrc, buff, size, &dwRead, NULL); if (!result) { DWORD errCode = GetLastError(); - error_message = "Error: " + errCode; + error_message = "Error: " + std::to_string(errCode); ret = FAIL_COPY_FILE; goto __finished; } @@ -110,7 +111,7 @@ CopyFileResult copy_file_gui(const std::string &from, const std::string &to, std result = WriteFile(handledst,buff,size,&dwWrite,NULL); if (!result) { DWORD errCode = GetLastError(); - error_message = "Error: " + errCode; + error_message = "Error: " + std::to_string(errCode); ret = FAIL_COPY_FILE; goto __finished; } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoAdvancedCut.cpp b/src/slic3r/GUI/Gizmos/GLGizmoAdvancedCut.cpp index 012e62b2bc..99ad00fe55 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoAdvancedCut.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoAdvancedCut.cpp @@ -1,5 +1,7 @@ // Include GLGizmoBase.hpp before I18N.hpp as it includes some libigl code, which overrides our localization "L" macro. #include "GLGizmoAdvancedCut.hpp" +#include "slic3r/GUI/Widgets/ProgressDialog.hpp" +#include "slic3r/GUI/GUI.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" #include diff --git a/src/slic3r/GUI/Gizmos/GLGizmoBrimEars.cpp b/src/slic3r/GUI/Gizmos/GLGizmoBrimEars.cpp index 709e7b5b21..6d9e810815 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoBrimEars.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoBrimEars.cpp @@ -1,6 +1,7 @@ #include "GLGizmoBrimEars.hpp" #include #include "slic3r/GUI/GLCanvas3D.hpp" +#include "slic3r/GUI/GUI.hpp" #include "slic3r/GUI/Camera.hpp" #include "slic3r/GUI/Gizmos/GLGizmosCommon.hpp" #include "slic3r/GUI/GUI_App.hpp" @@ -342,7 +343,7 @@ bool GLGizmoBrimEars::on_mouse(const wxMouseEvent& mouse_event) // concludes that the event was not intended for it, it should return false. bool GLGizmoBrimEars::gizmo_event(SLAGizmoEventType action, const Vec2d &mouse_position, bool shift_down, bool alt_down, bool control_down) { - if (action != SLAGizmoEventType::MouseWheelDown || action != SLAGizmoEventType::MouseWheelUp || action != SLAGizmoEventType::Moving) { + if (action != SLAGizmoEventType::MouseWheelDown && action != SLAGizmoEventType::MouseWheelUp && action != SLAGizmoEventType::Moving) { apply_radius_change(); } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoCut.hpp b/src/slic3r/GUI/Gizmos/GLGizmoCut.hpp index e6cbb7d0ef..109e0fe5bc 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoCut.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoCut.hpp @@ -94,7 +94,6 @@ class GLGizmoCut3D : public GLGizmoBase GLModel m_reference_radius; GLModel m_angle_arc; - Vec3d m_old_center; Vec3d m_cut_normal; struct InvalidConnectorsStatistics diff --git a/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.cpp b/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.cpp index f190e1ad97..035fb895aa 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoFdmSupports.cpp @@ -122,7 +122,7 @@ bool GLGizmoFdmSupports::on_init() {ctrl + _L("Mouse wheel"), _L("Gap area")} }; - memset(&m_print_instance, 0, sizeof(m_print_instance)); + m_print_instance = PrintInstance(); return true; } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoFuzzySkin.cpp b/src/slic3r/GUI/Gizmos/GLGizmoFuzzySkin.cpp index 6689fcdcea..d807e466c8 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoFuzzySkin.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoFuzzySkin.cpp @@ -4,6 +4,7 @@ #include "libslic3r/Print.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" +#include "slic3r/GUI/GUI.hpp" #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/GUI_ObjectList.hpp" #include "slic3r/GUI/ImGuiWrapper.hpp" diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMeasure.cpp b/src/slic3r/GUI/Gizmos/GLGizmoMeasure.cpp index e21498163a..6f7d6fed58 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMeasure.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMeasure.cpp @@ -1,4 +1,5 @@ #include "GLGizmoMeasure.hpp" +#include "slic3r/GUI/GUI.hpp" #include "slic3r/GUI/GLCanvas3D.hpp" #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/Plater.hpp" diff --git a/src/slic3r/GUI/Gizmos/GLGizmoSimplify.cpp b/src/slic3r/GUI/Gizmos/GLGizmoSimplify.cpp index f4fa1fbbb9..6a08d5eff4 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoSimplify.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoSimplify.cpp @@ -343,12 +343,12 @@ void GLGizmoSimplify::on_render_input_window(float x, float y, float bottom_limi ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing,ImVec2(10,20)); if (is_worker_running) { // apply or preview // draw progress bar - std::string progress_text = GUI::format("%1%", std::to_string(progress)) + "%%"; + std::string progress_text = GUI::format("%1%", std::to_string(progress)) + "%"; ImVec2 progress_size(bottom_left_width - space_size, 0.0f); ImGui::BBLProgressBar2(progress / 100., progress_size); ImGui::SameLine(); ImGui::AlignTextToFramePadding(); - ImGui::TextColored(ImVec4(0.42f, 0.42f, 0.42f, 1.00f), progress_text.c_str()); + ImGui::TextColored(ImVec4(0.42f, 0.42f, 0.42f, 1.00f), "%s", progress_text.c_str()); ImGui::SameLine(bottom_left_width + slider_width + m_imgui->scaled(1.0f)); } else { ImGui::Dummy(ImVec2(bottom_left_width - space_size, -1)); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoUtils.cpp b/src/slic3r/GUI/Gizmos/GLGizmoUtils.cpp index 00d608b80a..75dca0855d 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoUtils.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoUtils.cpp @@ -32,6 +32,8 @@ */ +using namespace std::string_view_literals; + namespace Slic3r::GUI::GLGizmoUtils { void render_tooltip_button( diff --git a/src/slic3r/GUI/Gizmos/GizmoObjectManipulation.cpp b/src/slic3r/GUI/Gizmos/GizmoObjectManipulation.cpp index 7c4a2afd38..5d70fde833 100644 --- a/src/slic3r/GUI/Gizmos/GizmoObjectManipulation.cpp +++ b/src/slic3r/GUI/Gizmos/GizmoObjectManipulation.cpp @@ -702,7 +702,7 @@ bool GizmoObjectManipulation::reset_zero_button(ImGuiWrapper *imgui_wrapper, bo for (int i = 0; i < number; i++) { - char buf[3][64] = {0}; + char buf[3][64] = {}; float buf_size[3] = {0}; for (int j = 0; j < 3; j++) { ImGui::DataTypeFormatString(buf[j], IM_ARRAYSIZE(buf[j]), ImGuiDataType_Double, (void *) &vec[i][j], "%.2f"); diff --git a/src/slic3r/GUI/GradientCurveEditor.cpp b/src/slic3r/GUI/GradientCurveEditor.cpp index 5b1073d231..6d38ef81a5 100644 --- a/src/slic3r/GUI/GradientCurveEditor.cpp +++ b/src/slic3r/GUI/GradientCurveEditor.cpp @@ -1,4 +1,5 @@ #include "GradientCurveEditor.hpp" +#include "FilamentBitmapUtils.hpp" #include "GUI_App.hpp" #include "GuiColor.hpp" #include "I18N.hpp" @@ -19,23 +20,13 @@ namespace GUI { wxDEFINE_EVENT(wxEVT_GRADIENT_CURVE_CHANGED, wxCommandEvent); namespace { -// Layout ratios of the plot rect within the widget, taken from a 214 x 180 px reference drawing. -// Plot rect occupies the upper-left region; right + bottom margins host axis arrows / labels. -constexpr double kPlotLeftRatio = 0.0316; -constexpr double kPlotRightRatio = 0.6766; -constexpr double kPlotTopRatio = 0.1529; -constexpr double kPlotBottomRatio = 0.8474; -constexpr int kGridDivisions = 9; // 10 grid lines including the outer borders. - -// Hit / stroke (DIP). +// Hit / stroke (DIP). The plot-rect ratios, grid divisions, axis/arrow geometry and the +// near-background outline threshold now live in FilamentBitmapUtils so the read-only Publish +// preview and this editor stay pixel-identical. constexpr int kHitRadius = 6; constexpr int kCurveHitRadius = 5; -constexpr int kPointRadius = 4; // anchor outer radius (DIP) constexpr int kStrokeUnselected = 2; constexpr int kStrokeSelected = 4; -constexpr int kStrokeAxis = 2; // axis line width (px, no DPI scaling - matches kGridColor pen and 2DBed convention) -constexpr int kAxisArrowHalf = 5; // half-base of the axis arrow triangle (DIP) -constexpr int kAxisArrowLen = 10; // length of the axis arrow triangle (DIP) // Light-mode design tokens. Resolved through StateColor::darkModeColorFor() // at paint time so the editor follows the app theme (#EEEEEE -> #4C4C55, #6B6B6B -> @@ -46,12 +37,6 @@ const wxColour kAxisColor (107, 107, 107); // #6B6B6B grey 700 const wxColour kLabelMuted (107, 107, 107); // #6B6B6B grey 700 const wxColour kLabelStrong ( 38, 46, 48); // #262E30 grey 900 const wxColour kOutlineColor(172, 172, 172); // #ACACAC dimmed elements - -// LAB (DeltaE76) threshold for "curve color is too close to the background": below it the curve -// gets a subtle outline so it does not visually vanish, otherwise it is drawn plain. Looser than -// the 5.0 of FlushPredict::is_similar_color, so a pastel pink on white still gets an outline. -constexpr float kBgSimilarThreshold = 15.0f; -constexpr int kOutlineExtraDip = 2; } // namespace GradientCurveEditor::GradientCurveEditor(wxWindow* parent, @@ -177,15 +162,8 @@ void GradientCurveEditor::emit_changed() wxRect GradientCurveEditor::plot_rect() const { - const wxSize sz = GetClientSize(); - const int x = static_cast(std::lround(sz.x * kPlotLeftRatio)); - const int y = static_cast(std::lround(sz.y * kPlotTopRatio)); - const int x2 = static_cast(std::lround(sz.x * kPlotRightRatio)); - const int y2 = static_cast(std::lround(sz.y * kPlotBottomRatio)); - // Force square 1:1 so X/Y axes share the same scale and grid cells stay square. Anchor at - // the top-left so the "100%" labels on the bottom/right still align with the plot edges. - const int side = std::max(1, std::min(x2 - x, y2 - y)); - return wxRect(x, y, side, side); + // Square 1:1 plot, shared with the Publish dialog's read-only preview. + return mixed_gradient_plot_rect(GetClientSize()); } wxPoint2DDouble GradientCurveEditor::data_to_px_f(double x, double y) const @@ -330,171 +308,52 @@ void GradientCurveEditor::on_paint(wxPaintEvent& /*evt*/) raw_dc.SetBackground(wxBrush(bg)); raw_dc.Clear(); - // Render through wxGCDC so curves, arrows and anchor circles get anti-aliased; the buffered - // DC is the actual back buffer that gets blitted to the window. - wxGCDC dc(raw_dc); - // The curve and its anchors are drawn straight on the graphics context so their - // coordinates stay sub-pixel accurate (see data_to_px_f). - wxGraphicsContext* gc = dc.GetGraphicsContext(); - - const wxRect rc = plot_rect(); - if (rc.width <= 0 || rc.height <= 0) - return; - - // 10x10 light grid (10 lines including outer borders, 9 equal divisions). - dc.SetPen(wxPen(grid_color, 1)); - for (int i = 0; i <= kGridDivisions; ++i) { - const int x = rc.x + rc.width * i / kGridDivisions; - const int y = rc.y + rc.height * i / kGridDivisions; - dc.DrawLine(x, rc.y, x, rc.y + rc.height); - dc.DrawLine(rc.x, y, rc.x + rc.width, y); - } - - // Set the label font first so text width measurements drive arrow / label placement. - wxFont label_font = wxSystemSettings::GetFont(wxSYS_DEFAULT_GUI_FONT); - label_font.SetPointSize(std::max(7, label_font.GetPointSize() - 1)); - dc.SetFont(label_font); - - const wxString axis_y_title = _L("Material Ratio"); - const wxString axis_x_title = _L("Model Height"); - const wxString pct_text = wxT("100%"); - const wxSize x_title_sz = dc.GetTextExtent(axis_x_title); - const wxSize y_title_sz = dc.GetTextExtent(axis_y_title); - - wxFont strong_font = label_font; - strong_font.SetWeight(wxFONTWEIGHT_SEMIBOLD); - dc.SetFont(strong_font); - const wxSize pct_text_sz = dc.GetTextExtent(pct_text); - dc.SetFont(label_font); - - // Axes (grey 700) with filled triangle arrows. Y-axis extends above the plot top to the - // canvas top edge; X-axis extends past the plot right toward the canvas right edge. - const int arrow_half = FromDIP(kAxisArrowHalf); - const int arrow_len = FromDIP(kAxisArrowLen); - const wxSize sz = GetClientSize(); - dc.SetPen(wxPen(axis_color, kStrokeAxis)); - dc.SetBrush(wxBrush(axis_color)); - - // Y-axis: vertical line at plot_left, from arrow tip near canvas top down to plot bottom. - const int y_axis_x = rc.x; - const int y_title_pct_gap = FromDIP(1); - const int y_title_bottom_pad = FromDIP(2); - const int y_title_y = std::max(0, rc.y - y_title_sz.y - y_title_pct_gap - pct_text_sz.y - y_title_bottom_pad); - const int y_arrow_tip_y = y_title_y; - const int y_arrow_ty = y_arrow_tip_y + arrow_len; - dc.DrawLine(y_axis_x, y_arrow_ty, y_axis_x, rc.y + rc.height); - { - wxPoint tri[3] = { - wxPoint(y_axis_x, y_arrow_tip_y), - wxPoint(y_axis_x - arrow_half, y_arrow_ty), - wxPoint(y_axis_x + arrow_half, y_arrow_ty), + // Render the plot (grid, axes, labels, curves, anchors) through the shared painter so the + // interactive editor and the Publish dialog's read-only preview stay pixel-identical. The + // curves are handed over as sub-pixel polylines and anti-alias inside the helper. + std::vector curves; + std::vector anchors; + if (m_points.size() >= 2) { + auto color_for_curve = [&](int curve_idx) -> wxColour { + wxColour c = (curve_idx == 0) ? m_color_low : m_color_high; + // Transparent filaments (alpha == 0, e.g. #FFFFFF00) would be invisible. + if (c.Alpha() == 0) + c.Set(c.Red(), c.Green(), c.Blue(), 150); + return c; }; - dc.DrawPolygon(3, tri); - } - // X-axis arrow tip: stays just past the plot ideally, but is clamped so the trailing - // "Material Ratio" label still fits inside the canvas without overlapping the arrow. - const int x_axis_y = rc.y + rc.height; - const int x_label_gap = FromDIP(4); - const int x_edge_pad = FromDIP(6); - const int x_arrow_ideal = rc.x + rc.width + FromDIP(10); - const int x_arrow_max = sz.x - x_title_sz.x - x_label_gap - x_edge_pad - arrow_len; - const int x_arrow_tx = std::max(rc.x + rc.width + arrow_len, - std::min(x_arrow_ideal, x_arrow_max)); - const int x_arrow_tip_x = x_arrow_tx + arrow_len; - const int x_title_x = x_arrow_tip_x + x_label_gap; - dc.DrawLine(rc.x, x_axis_y, x_arrow_tx, x_axis_y); - { - wxPoint tri[3] = { - wxPoint(x_arrow_tip_x, x_axis_y), - wxPoint(x_arrow_tx, x_axis_y - arrow_half), - wxPoint(x_arrow_tx, x_axis_y + arrow_half), + auto build_polyline = [&](int curve_idx) -> std::vector { + const int samples = std::max(128, plot_rect().width * 2); + std::vector poly; + poly.reserve(samples + 1); + for (int s = 0; s <= samples; ++s) { + const double x = double(s) / samples; + const double y0 = sample_curve_y(x); + const double vy = to_visual_y(curve_idx, y0); + poly.push_back(data_to_px_f(x, vy)); + } + return poly; }; - dc.DrawPolygon(3, tri); - } - // Labels. - // "Model Height" and "100%" share the same left x; the gap is larger than the - // axis-arrow half-base so the text never visually touches the Y-axis arrow. - const int label_left_x = y_axis_x + FromDIP(10); - dc.SetTextForeground(label_muted); - dc.DrawText(axis_y_title, label_left_x, y_title_y); - - dc.SetFont(strong_font); - dc.SetTextForeground(label_strong); - dc.DrawText(pct_text, label_left_x, y_title_y + y_title_sz.y + y_title_pct_gap); - - // Bottom-right "100%" sits under the right end of the plot; "Material Ratio" follows the - // X-axis arrow tip (placement was already clamped above to leave room). - dc.DrawText(pct_text, rc.x + rc.width - pct_text_sz.x, x_axis_y); - dc.SetFont(label_font); - dc.SetTextForeground(label_muted); - dc.DrawText(axis_x_title, x_title_x, x_axis_y - x_title_sz.y / 2); - - if (m_points.size() < 2 || !gc) - return; - - auto color_for_curve = [&](int curve_idx) -> wxColour { - wxColour c = (curve_idx == 0) ? m_color_low : m_color_high; - // Transparent filaments (alpha == 0, e.g. #FFFFFF00) would be invisible. - // Lift alpha so the curve stays visible while still hinting at transparency. - if (c.Alpha() == 0) - c.Set(c.Red(), c.Green(), c.Blue(), 150); - return c; - }; - - auto build_polyline = [&](int curve_idx) -> std::vector { - const int samples = std::max(128, rc.width * 2); - std::vector poly; - poly.reserve(samples + 1); - for (int s = 0; s <= samples; ++s) { - const double x = double(s) / samples; - const double y0 = sample_curve_y(x); - const double vy = to_visual_y(curve_idx, y0); - poly.push_back(data_to_px_f(x, vy)); + // Draw unselected first so the selected curve sits on top. + const int other = 1 - m_selected_curve; + for (const int idx : {other, m_selected_curve}) { + std::vector pts = build_polyline(idx); + if (pts.empty()) + continue; + curves.push_back({std::move(pts), color_for_curve(idx), idx == m_selected_curve ? kStrokeSelected : kStrokeUnselected}); } - return poly; - }; - // Only the geometry goes through the graphics context: dc.DrawLines() takes integer wxPoint - // and would quantize the curve back to whole pixels. The pen is still set on the dc, which - // forwards it here while keeping its own cached state in sync for later dc drawing. - auto draw_polyline = [&](const std::vector& poly, const wxColour& col, int stroke_dip) { - dc.SetPen(wxPen(col, FromDIP(stroke_dip))); - gc->StrokeLines(poly.size(), poly.data()); - }; - - // Outline only when the curve color is perceptually close to the background; otherwise - // the plain filament color reads fine and the extra stroke would look heavy. - auto needs_outline = [&](const wxColour& c) { - return calc_color_distance(c, bg) < kBgSimilarThreshold; - }; - - auto draw_one = [&](int curve_idx, int stroke_dip) { - const auto poly = build_polyline(curve_idx); - const wxColour col = color_for_curve(curve_idx); - if (needs_outline(col)) - draw_polyline(poly, outline_color, stroke_dip + kOutlineExtraDip); - draw_polyline(poly, col, stroke_dip); - }; - - // Draw unselected first so the selected curve sits on top. - const int other = 1 - m_selected_curve; - draw_one(other, kStrokeUnselected); - draw_one(m_selected_curve, kStrokeSelected); - - // Control points (selected curve only): hollow circle with axis-color border, theme-aware fill. - // Drawn on the graphics context with a sub-pixel center so the ring stays centered on the - // curve instead of drifting up to half a pixel off it; pen and brush go through the dc for - // the same reason as in draw_polyline above. - const double r = FromDIP(kPointRadius); - dc.SetPen(wxPen(axis_color, 1)); - dc.SetBrush(wxBrush(point_fill)); - for (size_t i = 0; i < m_points.size(); ++i) { - const double vy = to_visual_y(m_selected_curve, m_points[i].y); - const wxPoint2DDouble p = data_to_px_f(m_points[i].x, vy); - gc->DrawEllipse(p.m_x - r, p.m_y - r, r * 2, r * 2); + // Control points (selected curve only). + anchors.reserve(m_points.size()); + for (size_t i = 0; i < m_points.size(); ++i) { + const double vy = to_visual_y(m_selected_curve, m_points[i].y); + anchors.push_back(data_to_px_f(m_points[i].x, vy)); + } } + + const MixedGradientTheme theme{bg, grid_color, axis_color, label_muted, label_strong, outline_color, point_fill}; + draw_mixed_gradient_plot(raw_dc, GetClientSize(), curves, anchors, theme); } void GradientCurveEditor::on_left_down(wxMouseEvent& evt) diff --git a/src/slic3r/GUI/HMSPanel.hpp b/src/slic3r/GUI/HMSPanel.hpp index 49910590b8..25ac56de96 100644 --- a/src/slic3r/GUI/HMSPanel.hpp +++ b/src/slic3r/GUI/HMSPanel.hpp @@ -25,7 +25,6 @@ class HMSNotifyItem : public wxPanel wxStaticBitmap *m_bitmap_notify; wxStaticBitmap *m_bitmap_arrow; wxStaticText * m_hms_content; - wxHtmlWindow * m_html; wxPanel * m_staticline; wxBitmap m_img_notify_lv1; diff --git a/src/slic3r/GUI/HttpServer.cpp b/src/slic3r/GUI/HttpServer.cpp index afdc46e9f0..ef26f20173 100644 --- a/src/slic3r/GUI/HttpServer.cpp +++ b/src/slic3r/GUI/HttpServer.cpp @@ -4,6 +4,10 @@ #include "slic3r/Utils/Http.hpp" #include "slic3r/Utils/NetworkAgent.hpp" #include "slic3r/Utils/BBLNetworkPlugin.hpp" +#include "libslic3r/Thread.hpp" +#include + +using json = nlohmann::json; namespace Slic3r { namespace GUI { diff --git a/src/slic3r/GUI/IMSlider.cpp b/src/slic3r/GUI/IMSlider.cpp index c008963646..fa777b6a37 100644 --- a/src/slic3r/GUI/IMSlider.cpp +++ b/src/slic3r/GUI/IMSlider.cpp @@ -1,6 +1,7 @@ #include "IMSlider.hpp" #include "libslic3r/GCode.hpp" #include "GUI_App.hpp" +#include "GUI.hpp" #include "NotificationManager.hpp" #include "Widgets/StateColor.hpp" #ifndef IMGUI_DEFINE_MATH_OPERATORS @@ -790,7 +791,7 @@ void IMSlider::draw_ticks(const ImRect& slideable_region) { void IMSlider::show_tooltip(const std::string tooltip) { ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, { 6 * m_scale, 3 * m_scale }); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, { 3 * m_scale }); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 3 * m_scale); ImGui::PushStyleColor(ImGuiCol_PopupBg, ImGuiWrapper::COL_WINDOW_BACKGROUND); ImGui::PushStyleColor(ImGuiCol_Border, { 0,0,0,0 }); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.00f, 1.00f, 1.00f, 1.00f)); diff --git a/src/slic3r/GUI/IMSlider.hpp b/src/slic3r/GUI/IMSlider.hpp index 45e4d6ccb8..d28a65c1ff 100644 --- a/src/slic3r/GUI/IMSlider.hpp +++ b/src/slic3r/GUI/IMSlider.hpp @@ -216,7 +216,6 @@ private: long m_extra_style; float m_label_koef{1.0}; - float m_zero_layer_height = 0.0f; std::vector m_values; TickCodeInfo m_ticks; std::vector m_layers_times; diff --git a/src/slic3r/GUI/ImageDPIFrame.hpp b/src/slic3r/GUI/ImageDPIFrame.hpp index 817ef6be18..c22d296492 100644 --- a/src/slic3r/GUI/ImageDPIFrame.hpp +++ b/src/slic3r/GUI/ImageDPIFrame.hpp @@ -3,6 +3,8 @@ #include "GUI_App.hpp" #include "GUI_Utils.hpp" +#include +#include class wxStaticBitmap; namespace Slic3r { namespace GUI { diff --git a/src/slic3r/GUI/ImageGrid.cpp b/src/slic3r/GUI/ImageGrid.cpp index abef6f0f12..f6bf25d77c 100644 --- a/src/slic3r/GUI/ImageGrid.cpp +++ b/src/slic3r/GUI/ImageGrid.cpp @@ -521,7 +521,7 @@ void ImageGrid::render(wxDC& dc) if (!m_status_msg.IsEmpty()) { auto si = m_status_icon.GetBmpSize(); auto st = dc.GetMultiLineTextExtent(m_status_msg); - auto rect = wxRect{0, 0, max(st.x, si.x), si.y + 26 + st.y}.CenterIn(wxRect({0, 0}, size)); + auto rect = wxRect{0, 0, std::max(st.x, si.x), si.y + 26 + st.y}.CenterIn(wxRect({0, 0}, size)); dc.DrawBitmap(m_status_icon.bmp(), rect.x + (rect.width - si.x) / 2, rect.y); dc.SetTextForeground(wxColor(0x909090)); dc.DrawText(m_status_msg, rect.x + (rect.width - st.x) / 2, rect.GetBottom() - st.y); diff --git a/src/slic3r/GUI/InstanceCheck.cpp b/src/slic3r/GUI/InstanceCheck.cpp index bc68a3f788..28c5176cb1 100644 --- a/src/slic3r/GUI/InstanceCheck.cpp +++ b/src/slic3r/GUI/InstanceCheck.cpp @@ -114,7 +114,10 @@ namespace instance_check_internal if (my_instance_hash == other_instance_hash) { BOOST_LOG_TRIVIAL(debug) << "win enum - found correct instance"; orca_slicer_hwnd = hwnd; - ShowWindow(hwnd, SW_SHOWMAXIMIZED); + // Do not alter the window state when opening a file in the existing instance. + // A minimized window still needs restoring before it can receive focus. + if (IsIconic(hwnd)) + ShowWindow(hwnd, SW_RESTORE); SetForegroundWindow(hwnd); return false; } diff --git a/src/slic3r/GUI/InstanceCheck.hpp b/src/slic3r/GUI/InstanceCheck.hpp index 5f26f1e48f..9bfb3e2500 100644 --- a/src/slic3r/GUI/InstanceCheck.hpp +++ b/src/slic3r/GUI/InstanceCheck.hpp @@ -87,7 +87,6 @@ private: std::condition_variable m_thread_stop_condition; mutable std::mutex m_thread_stop_mutex; bool m_stop{ false }; - bool m_start{ true }; // background thread method void listen(); diff --git a/src/slic3r/GUI/Jobs/ArrangeJob.cpp b/src/slic3r/GUI/Jobs/ArrangeJob.cpp index 6b74b71af1..c0cc7bdb5a 100644 --- a/src/slic3r/GUI/Jobs/ArrangeJob.cpp +++ b/src/slic3r/GUI/Jobs/ArrangeJob.cpp @@ -265,8 +265,7 @@ arrangement::ArrangePolygon estimate_wipe_tower_info(int plate_index, std::setget_printer_extruder_count(); - auto arrange_poly = ppl.get_plate(plate_index_valid)->estimate_wipe_tower_polygon(full_config, plate_index, wipe_tower_pos, wipe_tower_size, nozzle_nums, extruder_size); + auto arrange_poly = ppl.get_plate(plate_index_valid)->estimate_wipe_tower_polygon(full_config, plate_index, wipe_tower_pos, wipe_tower_size, extruder_size); arrange_poly.bed_idx = plate_index; return arrange_poly; } diff --git a/src/slic3r/GUI/Jobs/BindJob.cpp b/src/slic3r/GUI/Jobs/BindJob.cpp index 61c430c6e6..76af712f63 100644 --- a/src/slic3r/GUI/Jobs/BindJob.cpp +++ b/src/slic3r/GUI/Jobs/BindJob.cpp @@ -3,6 +3,10 @@ #include "slic3r/GUI/Plater.hpp" #include "slic3r/GUI/GUI.hpp" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/DeviceManager.hpp" +#include "slic3r/GUI/HMS.hpp" +#include "slic3r/GUI/I18N.hpp" +#include "slic3r/Utils/NetworkAgent.hpp" #include "slic3r/GUI/DeviceCore/DevManager.h" diff --git a/src/slic3r/GUI/Jobs/BindJob.hpp b/src/slic3r/GUI/Jobs/BindJob.hpp index af89e2fc1b..1e8aa91d6f 100644 --- a/src/slic3r/GUI/Jobs/BindJob.hpp +++ b/src/slic3r/GUI/Jobs/BindJob.hpp @@ -20,7 +20,6 @@ class BindJob : public Job std::string m_sec_link; std::string m_ssdp_version; bool m_job_finished{ false }; - int m_print_job_completed_id = 0; bool m_improved{false}; public: diff --git a/src/slic3r/GUI/Jobs/SendJob.cpp b/src/slic3r/GUI/Jobs/SendJob.cpp index 67ce02b476..d27b18f24b 100644 --- a/src/slic3r/GUI/Jobs/SendJob.cpp +++ b/src/slic3r/GUI/Jobs/SendJob.cpp @@ -1,4 +1,6 @@ #include "SendJob.hpp" +#include "slic3r/GUI/I18N.hpp" +#include "slic3r/Utils/NetworkAgent.hpp" #include "libslic3r/MTUtils.hpp" #include "libslic3r/Model.hpp" #include "libslic3r/PresetBundle.hpp" diff --git a/src/slic3r/GUI/Jobs/UpgradeNetworkJob.cpp b/src/slic3r/GUI/Jobs/UpgradeNetworkJob.cpp index 6cd88ac5d3..7090f0e2e0 100644 --- a/src/slic3r/GUI/Jobs/UpgradeNetworkJob.cpp +++ b/src/slic3r/GUI/Jobs/UpgradeNetworkJob.cpp @@ -2,6 +2,7 @@ #include "slic3r/GUI/GUI.hpp" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/I18N.hpp" #include "slic3r/Utils/Http.hpp" namespace Slic3r { diff --git a/src/slic3r/GUI/Jobs/UpgradeNetworkJob.hpp b/src/slic3r/GUI/Jobs/UpgradeNetworkJob.hpp index 5a531065f3..3e634e0337 100644 --- a/src/slic3r/GUI/Jobs/UpgradeNetworkJob.hpp +++ b/src/slic3r/GUI/Jobs/UpgradeNetworkJob.hpp @@ -27,7 +27,6 @@ class UpgradeNetworkJob : public Job wxWindow * m_event_handle{nullptr}; std::function m_success_fun{nullptr}; bool m_job_finished{ false }; - int m_print_job_completed_id = 0; InstallProgressFn pro_fn { nullptr }; diff --git a/src/slic3r/GUI/KBShortcutsDialog.cpp b/src/slic3r/GUI/KBShortcutsDialog.cpp index ed513c425f..2f882e35aa 100644 --- a/src/slic3r/GUI/KBShortcutsDialog.cpp +++ b/src/slic3r/GUI/KBShortcutsDialog.cpp @@ -174,6 +174,7 @@ void KBShortcutsDialog::fill_shortcuts() { ctrl + "O", L("Open Project") }, { ctrl + "S", L("Save Project") }, { ctrl + shift + "S", L("Save Project as")}, + { ctrl + shift + "E", L("Publish 3MF") }, // File>Import { ctrl + "I", L("Import geometry data from STL/STEP/3MF/OBJ/AMF files") }, // File>Export diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index 675bc8da27..5f36323d6e 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -39,7 +39,6 @@ #include "Plater.hpp" #include "WebViewDialog.hpp" #include "../Utils/Process.hpp" -#include "format.hpp" // BBS #include "PartPlate.hpp" #include "Preferences.hpp" @@ -49,11 +48,9 @@ #include "../Utils/NetworkAgentFactory.hpp" #include "../Utils/PrintHost.hpp" -#include -#include - #include "GUI_App.hpp" #include "UnsavedChangesDialog.hpp" +#include "PublishSettingsDialog.hpp" #include "MsgDialog.hpp" #include "Notebook.hpp" #include "GUI_Factories.hpp" @@ -743,6 +740,10 @@ DPIFrame(NULL, wxID_ANY, "", wxDefaultPosition, wxDefaultSize, BORDERLESS_FRAME_ if (m_plater) { m_plater->add_file(); } return; } + if (evt.CmdDown() && evt.ShiftDown() && evt.GetKeyCode() == 'E') { + if (can_export_model()) publish_project(); + return; + } evt.Skip(); }); @@ -1590,7 +1591,7 @@ void MainFrame::register_win32_callbacks() //static GUID GUID_DEVINTERFACE_USB_DEVICE = { 0xA5DCBF10, 0x6530, 0x11D2, 0x90, 0x1F, 0x00, 0xC0, 0x4F, 0xB9, 0x51, 0xED }; //static GUID GUID_DEVINTERFACE_DISK = { 0x53f56307, 0xb6bf, 0x11d0, 0x94, 0xf2, 0x00, 0xa0, 0xc9, 0x1e, 0xfb, 0x8b }; //static GUID GUID_DEVINTERFACE_VOLUME = { 0x71a27cdd, 0x812a, 0x11d0, 0xbe, 0xc7, 0x08, 0x00, 0x2b, 0xe2, 0x09, 0x2f }; - static GUID GUID_DEVINTERFACE_HID = { 0x4D1E55B2, 0xF16F, 0x11CF, 0x88, 0xCB, 0x00, 0x11, 0x11, 0x00, 0x00, 0x30 }; + static GUID GUID_DEVINTERFACE_HID = { 0x4D1E55B2, 0xF16F, 0x11CF, { 0x88, 0xCB, 0x00, 0x11, 0x11, 0x00, 0x00, 0x30 } }; // Register USB HID (Human Interface Devices) notifications to trigger the 3DConnexion enumeration. DEV_BROADCAST_DEVICEINTERFACE NotificationFilter = { 0 }; @@ -1630,7 +1631,7 @@ void MainFrame::register_win32_callbacks() { static constexpr int device_count = 1; - RAWINPUTDEVICE devices[device_count] = { 0 }; + RAWINPUTDEVICE devices[device_count] = {}; // multi-axis mouse (SpaceNavigator, etc.) devices[0].usUsagePage = 0x01; devices[0].usUsage = 0x08; @@ -1739,6 +1740,22 @@ bool MainFrame::save_project_as(const wxString& filename) return ret; } +void MainFrame::publish_project() +{ + if (m_plater == nullptr) + return; + // Seed the dialog from the session selection (a remembered state or a freshly loaded + // published 3MF); a null pointer means "fresh", keeping the dirty defaults. + std::vector pending_keys; + std::vector pending_material; + const bool has_prior = m_plater->get_pending_published(pending_keys, pending_material); + PublishSettingsDialog dlg(this, has_prior ? &pending_keys : nullptr, has_prior ? &pending_material : nullptr); + if (dlg.ShowModal() != wxID_OK) + return; + m_plater->set_pending_published(dlg.GetPublishedKeys(), dlg.GetPublishedMaterialKeys()); + m_plater->export_published_3mf(dlg.GetPublishedKeys(), dlg.GetPublishedMaterialKeys()); +} + bool MainFrame::can_upload() const { return true; @@ -2834,6 +2851,20 @@ void MainFrame::init_menubar_as_editor() [this](){return m_plater != nullptr && can_save_as(); }, this); #endif + // BBS: publish + fileMenu->AppendSeparator(); + auto publish_handler = [this](wxCommandEvent&) { publish_project(); }; + +#ifndef __APPLE__ + append_menu_item(fileMenu, wxID_ANY, _L("Publish 3MF") + dots + "\t" + ctrl + shift + "E", _L("Export a 3MF file with the selected settings embedded"), + publish_handler, "menu_publish", nullptr, + [this](){return can_export_model(); }, this); +#else + append_menu_item(fileMenu, wxID_ANY, _L("Publish 3MF") + dots + "\t" + ctrl + shift + "E", _L("Export a 3MF file with the selected settings embedded"), + publish_handler, "", nullptr, + [this](){return can_export_model(); }, this); +#endif + fileMenu->AppendSeparator(); @@ -3244,98 +3275,9 @@ void MainFrame::init_menubar_as_editor() auto preference_item = new wxMenuItem(parent_menu, ConfigMenuPreferences + config_id_base, _L("Preferences") + "\t" + ctrl + "P", ""); #endif - //auto printer_item = new wxMenuItem(parent_menu, ConfigMenuPrinter + config_id_base, _L("Printer"), ""); - //auto language_item = new wxMenuItem(parent_menu, ConfigMenuLanguage + config_id_base, _L("Switch Language"), ""); -// parent_menu->Bind(wxEVT_MENU, [this, config_id_base](wxEvent& event) { -// switch (event.GetId() - config_id_base) { -// //case ConfigMenuLanguage: -// //{ -// // /* Before change application language, let's check unsaved changes on 3D-Scene -// // * and draw user's attention to the application restarting after a language change -// // */ -// // { -// // // the dialog needs to be destroyed before the call to switch_language() -// // // or sometimes the application crashes into wxDialogBase() destructor -// // // so we put it into an inner scope -// // wxString title = _L("Language selection"); -// // wxMessageDialog dialog(nullptr, -// // _L("Switching the language requires application restart.\n") + "\n\n" + -// // _L("Do you want to continue?"), -// // title, -// // wxICON_QUESTION | wxOK | wxCANCEL); -// // if (dialog.ShowModal() == wxID_CANCEL) -// // return; -// // } -// -// // wxGetApp().switch_language(); -// // break; -// //} -// //case ConfigMenuWizard: -// //{ -// // wxGetApp().run_wizard(ConfigWizard::RR_USER); -// // break; -// //} -// case ConfigMenuPrinter: -// { -// wxGetApp().params_dialog()->Popup(); -// wxGetApp().get_tab(Preset::TYPE_PRINTER)->restore_last_select_item(); -// break; -// } -// case ConfigMenuPreferences: -// { -// CallAfter([this] { -// PreferencesDialog dlg(this); -// dlg.ShowModal(); -//#if ENABLE_GCODE_LINES_ID_IN_H_SLIDER -// if (dlg.seq_top_layer_only_changed() || dlg.seq_seq_top_gcode_indices_changed()) -//#else -// if (dlg.seq_top_layer_only_changed()) -//#endif // ENABLE_GCODE_LINES_ID_IN_H_SLIDER -// plater()->refresh_print(); -//#if ENABLE_CUSTOMIZABLE_FILES_ASSOCIATION_ON_WIN -//#ifdef _WIN32 -// /* -// if (wxGetApp().app_config()->get("associate_3mf") == "true") -// wxGetApp().associate_3mf_files(); -// if (wxGetApp().app_config()->get("associate_stl") == "true") -// wxGetApp().associate_stl_files(); -// /*if (wxGetApp().app_config()->get("associate_step") == "true") -// wxGetApp().associate_step_files();*/ -//#endif // _WIN32 -//#endif -// }); -// break; -// } -// default: -// break; -// } -// }); #ifdef __APPLE__ wxString about_title = wxString::Format(_L("&About %s"), SLIC3R_APP_FULL_NAME); - //auto about_item = new wxMenuItem(parent_menu, OrcaSlicerMenuAbout + bambu_studio_id_base, about_title, ""); - //parent_menu->Bind(wxEVT_MENU, [this, bambu_studio_id_base](wxEvent& event) { - // switch (event.GetId() - bambu_studio_id_base) { - // case OrcaSlicerMenuAbout: - // Slic3r::GUI::about(); - // break; - // case OrcaSlicerMenuPreferences: - // CallAfter([this] { - // PreferencesDialog dlg(this); - // dlg.ShowModal(); - //#if ENABLE_GCODE_LINES_ID_IN_H_SLIDER - // if (dlg.seq_top_layer_only_changed() || dlg.seq_seq_top_gcode_indices_changed()) - //#else - // if (dlg.seq_top_layer_only_changed()) - //#endif // ENABLE_GCODE_LINES_ID_IN_H_SLIDER - // plater()->refresh_print(); - // }); - // break; - // default: - // break; - // } - //}); - //parent_menu->Insert(0, about_item); append_menu_item( parent_menu, wxID_ANY, _L(about_title), "", [](wxCommandEvent &) { Slic3r::GUI::about();}, @@ -4212,15 +4154,23 @@ std::wstring MainFrame::FileHistory::GetThumbnailUrl(int index) const return wss.str(); } +bool MainFrame::FileHistory::GetPublished(int index) const +{ + return index >= 0 && index < static_cast(m_published_files.size()) && m_published_files[index]; +} + void MainFrame::FileHistory::AddFileToHistory(const wxString &file) { if (this->m_fileMaxFiles == 0) return; wxFileHistory::AddFileToHistory(file); - if (m_load_called) + if (m_load_called) { m_thumbnails.push_front(bbs_3mf_get_thumbnail(into_u8(file).c_str())); - else + m_published_files.push_front(bbs_3mf_is_published(into_u8(file))); + } else { m_thumbnails.push_front(""); + m_published_files.push_front(false); + } } void MainFrame::FileHistory::RemoveFileFromHistory(size_t i) @@ -4229,6 +4179,7 @@ void MainFrame::FileHistory::RemoveFileFromHistory(size_t i) return; wxFileHistory::RemoveFileFromHistory(i); m_thumbnails.erase(m_thumbnails.begin() + i); + m_published_files.erase(m_published_files.begin() + i); } size_t MainFrame::FileHistory::FindFileInHistory(const wxString & file) @@ -4244,6 +4195,7 @@ void MainFrame::FileHistory::LoadThumbnails() if (!thumbnail.empty()) { m_thumbnails[i] = thumbnail; } + m_published_files[i] = bbs_3mf_is_published(into_u8(GetHistoryFile(i))); } }); m_load_called = true; @@ -4264,6 +4216,7 @@ void MainFrame::get_recent_projects(boost::property_tree::wptree &tree, int imag std::wstring proj = m_recent_projects.GetHistoryFile(i).ToStdWstring(); item.put(L"project_name", proj.substr(proj.find_last_of(L"/\\") + 1)); item.put(L"path", proj); + item.put(L"published", m_recent_projects.GetPublished(i) ? L"1" : L"0"); boost::system::error_code ec; std::time_t t = boost::filesystem::last_write_time(proj, ec); if (!ec) { @@ -4488,10 +4441,9 @@ std::string MainFrame::get_dir_name(const wxString &full_name) const // ---------------------------------------------------------------------------- SettingsDialog::SettingsDialog(MainFrame* mainframe) -:DPIDialog(NULL, wxID_ANY, wxString(SLIC3R_APP_NAME) + " - " + _L("Settings"), wxDefaultPosition, wxDefaultSize, wxDEFAULT_FRAME_STYLE, "settings_dialog"), +:DPIDialog(NULL, wxID_ANY, wxString(SLIC3R_APP_NAME) + " - " + _L("Settings"), wxDefaultPosition, wxDefaultSize, wxDEFAULT_FRAME_STYLE, "settings_dialog") //: DPIDialog(mainframe, wxID_ANY, wxString(SLIC3R_APP_NAME) + " - " + _L("Settings"), wxDefaultPosition, wxDefaultSize, // wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER | wxMINIMIZE_BOX | wxMAXIMIZE_BOX, "settings_dialog"), - m_main_frame(mainframe) { if (wxGetApp().is_gcode_viewer()) return; diff --git a/src/slic3r/GUI/MainFrame.hpp b/src/slic3r/GUI/MainFrame.hpp index 2052860d80..5340115609 100644 --- a/src/slic3r/GUI/MainFrame.hpp +++ b/src/slic3r/GUI/MainFrame.hpp @@ -66,6 +66,7 @@ class Tab; class PrintHostQueueDialog; class Plater; class MainFrame; +class WebViewPanel; class ParamsDialog; #ifdef __WXGTK__ class ResizeEdgePanel; @@ -94,7 +95,6 @@ class SettingsDialog : public DPIDialog//DPIDialog { //wxNotebook* m_tabpanel { nullptr }; Notebook* m_tabpanel{ nullptr }; - MainFrame* m_main_frame { nullptr }; wxMenuBar* m_menubar{ nullptr }; public: SettingsDialog(MainFrame* mainframe); @@ -179,6 +179,7 @@ class MainFrame : public DPIFrame { FileHistory(int max) : wxFileHistory(max) {} std::wstring GetThumbnailUrl(int index) const; + bool GetPublished(int index) const; virtual void AddFileToHistory(const wxString &file); virtual void RemoveFileFromHistory(size_t i); @@ -189,6 +190,7 @@ class MainFrame : public DPIFrame void SetMaxFiles(int max); private: std::deque m_thumbnails; + std::deque m_published_files; // parallel to m_thumbnails: is it a published 3mf? bool m_load_called = false; }; @@ -342,6 +344,8 @@ public: bool can_upload() const; void save_project(); bool save_project_as(const wxString& filename = wxString()); + // Open the Publish dialog and export the selected settings as a published 3MF. + void publish_project(); void add_to_recent_projects(const wxString& filename); void get_recent_projects(boost::property_tree::wptree &tree, int images); diff --git a/src/slic3r/GUI/MediaFilePanel.cpp b/src/slic3r/GUI/MediaFilePanel.cpp index 36316f8ff5..e9e1f56b03 100644 --- a/src/slic3r/GUI/MediaFilePanel.cpp +++ b/src/slic3r/GUI/MediaFilePanel.cpp @@ -2,6 +2,9 @@ #include "ImageGrid.h" #include "I18N.hpp" #include "GUI_App.hpp" +#include "GUI.hpp" +#include "DeviceManager.hpp" +#include "slic3r/Utils/NetworkAgent.hpp" #include "Plater.hpp" #include "Widgets/Button.hpp" #include "Widgets/SwitchButton.hpp" diff --git a/src/slic3r/GUI/MediaPlayCtrl.cpp b/src/slic3r/GUI/MediaPlayCtrl.cpp index 0d75c3770d..557d859cf7 100644 --- a/src/slic3r/GUI/MediaPlayCtrl.cpp +++ b/src/slic3r/GUI/MediaPlayCtrl.cpp @@ -3,6 +3,11 @@ #include "Widgets/CheckBox.hpp" #include "Widgets/Label.hpp" #include "GUI_App.hpp" +#include "GUI.hpp" +#include "DeviceManager.hpp" +#include "DeviceCore/DevConfigUtil.h" +#include "slic3r/Utils/NetworkAgent.hpp" +#include "libslic3r/Thread.hpp" #include "libslic3r/AppConfig.hpp" #include "I18N.hpp" #include "MsgDialog.hpp" @@ -13,6 +18,7 @@ #include #include #include +#include #include #undef pid_t #include @@ -71,7 +77,7 @@ MediaPlayCtrl::MediaPlayCtrl(wxWindow *parent, wxMediaCtrl3 *media_ctrl, const w auto ip = str.find(' ', ik); if (ip == wxString::npos) ip = str.Length(); auto v = str.Mid(ik, ip - ik); - if (k == "T:" && v.Length() == 8) { + if (strcmp(k, "T:") == 0 && v.Length() == 8) { long h = 0,m = 0,s = 0; v.Left(2).ToLong(&h); v.Mid(3, 2).ToLong(&m); diff --git a/src/slic3r/GUI/MeshUtils.cpp b/src/slic3r/GUI/MeshUtils.cpp index bc6c60a360..173c5d2f13 100644 --- a/src/slic3r/GUI/MeshUtils.cpp +++ b/src/slic3r/GUI/MeshUtils.cpp @@ -297,7 +297,7 @@ void MeshClipper::recalculate_triangles() // it so it lies on our line. This will be the figure to subtract // from the cut. The coordinates must not overflow after the transform, // make the rectangle a bit smaller. - const coord_t size = (std::numeric_limits::max()/2 - scale_(std::max(std::abs(e * a), std::abs(e * b)))) / 4; + const coord_t size = (double(std::numeric_limits::max()/2) - scale_(std::max(std::abs(e * a), std::abs(e * b)))) / 4; Polygons ep {Polygon({Point(-size, 0), Point(size, 0), Point(size, 2*size), Point(-size, 2*size)})}; ep.front().rotate(angle); ep.front().translate(scale_(-e * a), scale_(-e * b)); @@ -352,7 +352,7 @@ void MeshClipper::recalculate_triangles() // To prevent overflow after scaling, downscale the input if needed: double extra_scale = 1.; - coord_t limit = coord_t(std::min(std::numeric_limits::max() / (2. * std::max(1., scale_x)), std::numeric_limits::max() / (2. * std::max(1., scale_y)))); + coord_t limit = coord_t(std::min(double(std::numeric_limits::max()) / (2. * std::max(1., scale_x)), double(std::numeric_limits::max()) / (2. * std::max(1., scale_y)))); coord_t max_coord = 0; for (const Point& pt : exp.contour) max_coord = std::max(max_coord, std::max(std::abs(pt.x()), std::abs(pt.y()))); diff --git a/src/slic3r/GUI/MixedFilamentDialog.cpp b/src/slic3r/GUI/MixedFilamentDialog.cpp index 902c12d27b..8176a76202 100644 --- a/src/slic3r/GUI/MixedFilamentDialog.cpp +++ b/src/slic3r/GUI/MixedFilamentDialog.cpp @@ -126,7 +126,6 @@ MixedFilamentDialog::MixedFilamentDialog(wxWindow* parent, const std::vector& physical_types) : DPIDialog(parent, wxID_ANY, _L("Add Mixed Filament"), wxDefaultPosition, wxDefaultSize, wxCAPTION | wxCLOSE_BOX) - , m_edit_mode(false) , m_physical_colors(physical_colors) , m_physical_names(physical_names) , m_physical_types(physical_types) @@ -157,7 +156,6 @@ MixedFilamentDialog::MixedFilamentDialog(wxWindow* parent, : DPIDialog(parent, wxID_ANY, _L("Edit Mixed Filament"), wxDefaultPosition, wxDefaultSize, wxCAPTION | wxCLOSE_BOX) , m_result(existing) - , m_edit_mode(true) , m_physical_colors(physical_colors) , m_physical_names(physical_names) , m_physical_types(physical_types) @@ -847,23 +845,8 @@ wxBoxSizer* MixedFilamentDialog::create_ratio_slider() m_ratio_bar->Bind(wxEVT_PAINT, [this](wxPaintEvent&) { wxBufferedPaintDC dc(m_ratio_bar); wxSize sz = m_ratio_bar->GetClientSize(); - - wxColour col_a = comp_colour(0), col_b = comp_colour(1); - - for (int x = 0; x < sz.GetWidth(); ++x) { - double t = (double)x / sz.GetWidth(); - wxColour c = blend_colors(col_a, col_b, 1.0 - t); - dc.SetPen(wxPen(c)); - dc.DrawLine(x, 0, x, sz.GetHeight()); - } - - int div_x = (int)(ratio(1) / 100.0 * sz.GetWidth()); - // Fixed in both themes, like the triangle picker's drag handle: the divider is drawn over - // blended filament colour, so it has to keep its contrast against data rather than chrome. - dc.SetPen(wxPen(wxColour(80, 80, 80), FromDIP(4))); - dc.DrawLine(div_x, 0, div_x, sz.GetHeight()); - dc.SetPen(wxPen(*wxWHITE, FromDIP(2))); - dc.DrawLine(div_x, 0, div_x, sz.GetHeight()); + draw_mixed_ratio_blend_bar(dc, wxRect(0, 0, sz.GetWidth(), sz.GetHeight()), + comp_colour(0), comp_colour(1), ratio(1) / 100.0); }); m_ratio_bar->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& e) { @@ -919,52 +902,8 @@ wxBoxSizer* MixedFilamentDialog::create_ratio_slider() } // ---- Triangle (ternary) ratio picker ---- - -// Barycentric coordinate utilities -struct TriPoint { double x, y; }; - -static double tri_signed_area2(TriPoint a, TriPoint b, TriPoint c) -{ - return (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y); -} - -static bool tri_contains(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2) -{ - double total = tri_signed_area2(v0, v1, v2); - if (std::abs(total) < 1e-9) return false; - double s0 = tri_signed_area2(p, v1, v2) / total; - double s1 = tri_signed_area2(v0, p, v2) / total; - double s2 = 1.0 - s0 - s1; - return s0 >= -0.001 && s1 >= -0.001 && s2 >= -0.001; -} - -static void tri_barycentric(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2, - double& w0, double& w1, double& w2) -{ - double total = std::abs(tri_signed_area2(v0, v1, v2)); - if (total < 1e-9) { w0 = w1 = w2 = 1.0 / 3.0; return; } - w0 = std::abs(tri_signed_area2(p, v1, v2)) / total; - w1 = std::abs(tri_signed_area2(v0, p, v2)) / total; - w2 = 1.0 - w0 - w1; - w0 = std::clamp(w0, 0.0, 1.0); - w1 = std::clamp(w1, 0.0, 1.0); - w2 = std::clamp(w2, 0.0, 1.0); - double s = w0 + w1 + w2; - if (s > 0) { w0 /= s; w1 /= s; w2 /= s; } -} - -static TriPoint tri_clamp(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2) -{ - double w0, w1, w2; - tri_barycentric(p, v0, v1, v2, w0, w1, w2); - w0 = std::clamp(w0, 0.0, 1.0); - w1 = std::clamp(w1, 0.0, 1.0); - w2 = std::clamp(w2, 0.0, 1.0); - double s = w0 + w1 + w2; - if (s > 0) { w0 /= s; w1 /= s; w2 /= s; } - return {w0 * v0.x + w1 * v1.x + w2 * v2.x, - w0 * v0.y + w1 * v1.y + w2 * v2.y}; -} +// The barycentric utilities (TriPoint, tri_contains, tri_barycentric, tri_clamp) live in +// FilamentBitmapUtils so the Publish dialog can mirror this picker read-only. wxBoxSizer* MixedFilamentDialog::create_triangle_picker() { @@ -998,72 +937,15 @@ wxBoxSizer* MixedFilamentDialog::create_triangle_picker() wxSize sz = m_triangle_panel->GetClientSize(); auto [v0, v1, v2] = get_vertices(); - wxColour tri_bg = StateColor::darkModeColorFor(*wxWHITE); - dc.SetBrush(wxBrush(tri_bg)); - dc.SetPen(*wxTRANSPARENT_PEN); - dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); - - wxColour c0 = comp_colour(0), c1 = comp_colour(1), c2 = comp_colour(2); - - const bool cache_valid = m_tri_cache_bmp.IsOk() && - m_tri_cache_size == sz && - m_tri_cache_c0 == c0 && m_tri_cache_c1 == c1 && m_tri_cache_c2 == c2; - - if (!cache_valid) { - int min_y = (int)std::min({v0.y, v1.y, v2.y}); - int max_y = (int)std::max({v0.y, v1.y, v2.y}); - int min_x = (int)std::min({v0.x, v1.x, v2.x}); - int max_x = (int)std::max({v0.x, v1.x, v2.x}); - - m_tri_cache_bmp = wxBitmap(sz.GetWidth(), sz.GetHeight(), 24); - wxMemoryDC mdc(m_tri_cache_bmp); - mdc.SetBrush(wxBrush(tri_bg)); - mdc.SetPen(*wxTRANSPARENT_PEN); - mdc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); - - for (int py = min_y; py <= max_y; ++py) { - for (int px = min_x; px <= max_x; ++px) { - TriPoint p = {(double)px, (double)py}; - if (!tri_contains(p, v0, v1, v2)) continue; - double w0, w1, w2; - tri_barycentric(p, v0, v1, v2, w0, w1, w2); - unsigned char mr, mg, mb; - if (w0 + w1 > 1e-6) { - float t01 = static_cast(w1 / (w0 + w1)); - Slic3r::filament_mixer_lerp(c0.Red(), c0.Green(), c0.Blue(), - c1.Red(), c1.Green(), c1.Blue(), - t01, &mr, &mg, &mb); - float t2 = static_cast(w2); - Slic3r::filament_mixer_lerp(mr, mg, mb, - c2.Red(), c2.Green(), c2.Blue(), - t2, &mr, &mg, &mb); - } else { - mr = c2.Red(); mg = c2.Green(); mb = c2.Blue(); - } - mdc.SetPen(wxPen(wxColour(mr, mg, mb))); - mdc.DrawPoint(px, py); - } - } - - mdc.SetPen(wxPen(StateColor::darkModeColorFor(wxColour("#CECECE")), 1)); - mdc.SetBrush(*wxTRANSPARENT_BRUSH); - wxPoint pts[3] = {{(int)v0.x, (int)v0.y}, {(int)v1.x, (int)v1.y}, {(int)v2.x, (int)v2.y}}; - mdc.DrawPolygon(3, pts); - - mdc.SelectObject(wxNullBitmap); - m_tri_cache_c0 = c0; m_tri_cache_c1 = c1; m_tri_cache_c2 = c2; - m_tri_cache_size = sz; - } - - dc.DrawBitmap(m_tri_cache_bmp, 0, 0); - - // Drag handle (always redrawn on top of cached bitmap) - double hx = m_tri_wx * v0.x + m_tri_wy * v1.x + m_tri_wz * v2.x; - double hy = m_tri_wx * v0.y + m_tri_wy * v1.y + m_tri_wz * v2.y; - int handle_r = FromDIP(5); - dc.SetBrush(*wxWHITE_BRUSH); - dc.SetPen(wxPen(wxColour("#262E30"), FromDIP(2))); - dc.DrawCircle((int)hx, (int)hy, handle_r); + // Draw the background, cached barycentric fill, outline and drag-handle marker through the + // shared picker painter (same geometry the read-only Publish preview uses). + draw_mixed_triangle_picker(dc, sz, + {comp_colour(0), comp_colour(1), comp_colour(2)}, + {m_tri_wx, m_tri_wy, m_tri_wz}, + {StateColor::darkModeColorFor(*wxWHITE), + StateColor::darkModeColorFor(wxColour("#CECECE")), + StateColor::darkModeColorFor(wxColour("#262E30")), + StateColor::darkModeColorFor(COLOR_LABEL_MUTED)}); if (m_result.ratios.size() >= 3) { dc.SetFont(::Label::Body_10); diff --git a/src/slic3r/GUI/MixedFilamentDialog.hpp b/src/slic3r/GUI/MixedFilamentDialog.hpp index ea8ac5ad16..381896494e 100644 --- a/src/slic3r/GUI/MixedFilamentDialog.hpp +++ b/src/slic3r/GUI/MixedFilamentDialog.hpp @@ -115,7 +115,6 @@ private: wxColour comp_colour(size_t i) const; MixedFilamentResult m_result; - bool m_edit_mode{false}; std::vector m_physical_colors; std::vector m_physical_names; std::vector m_physical_types; @@ -170,10 +169,6 @@ private: // Triangle picker drag point (barycentric weights) double m_tri_wx{0.333}, m_tri_wy{0.333}, m_tri_wz{0.334}; - // Cached triangle color bitmap (invalidated when colors or size change) - wxBitmap m_tri_cache_bmp; - wxColour m_tri_cache_c0, m_tri_cache_c1, m_tri_cache_c2; - wxSize m_tri_cache_size; std::array m_triangle_ratio_labels{nullptr, nullptr, nullptr}; }; diff --git a/src/slic3r/GUI/Monitor.hpp b/src/slic3r/GUI/Monitor.hpp index 13688d6526..9252b04559 100644 --- a/src/slic3r/GUI/Monitor.hpp +++ b/src/slic3r/GUI/Monitor.hpp @@ -78,7 +78,6 @@ private: Tabbook* m_tabpanel{ nullptr }; wxSizer* m_main_sizer{ nullptr }; - AddMachinePanel* m_status_add_machine_panel; StatusPanel* m_status_info_panel; MediaFilePanel* m_media_file_panel; UpgradePanel* m_upgrade_panel; @@ -86,8 +85,6 @@ private: /* side tools */ SideTools* m_side_tools{nullptr}; - wxStaticBitmap* m_bitmap_arrow; - wxStaticBitmap* m_bitmap_wifi_signal; SelectMachinePopup m_select_machine; /* images */ diff --git a/src/slic3r/GUI/Mouse3DController.cpp b/src/slic3r/GUI/Mouse3DController.cpp index 11709501ac..0317342412 100644 --- a/src/slic3r/GUI/Mouse3DController.cpp +++ b/src/slic3r/GUI/Mouse3DController.cpp @@ -1,6 +1,7 @@ #include "libslic3r/libslic3r.h" #include "libslic3r/PresetBundle.hpp" #include "Mouse3DController.hpp" +#include "GUI.hpp" #include "Camera.hpp" #include "GUI_App.hpp" @@ -498,7 +499,7 @@ void Mouse3DController::render_settings_dialog(GLCanvas3D& canvas) const ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(20.0f, 20.0f)); static ImVec2 last_win_size(0.0f, 0.0f); bool shown = true; - if (imgui.begin(_L("3Dconnexion settings"), &shown, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse || ImGuiWindowFlags_NoTitleBar)) { + if (imgui.begin(_L("3Dconnexion settings"), &shown, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) { if (shown) { ImVec2 win_size = ImGui::GetWindowSize(); if (last_win_size.x != win_size.x || last_win_size.y != win_size.y) { diff --git a/src/slic3r/GUI/MsgDialog.hpp b/src/slic3r/GUI/MsgDialog.hpp index 90fd160310..ed8079d88c 100644 --- a/src/slic3r/GUI/MsgDialog.hpp +++ b/src/slic3r/GUI/MsgDialog.hpp @@ -177,7 +177,6 @@ public: // Generic rich message dialog, used intead of wxRichMessageDialog class RichMessageDialog : public MsgDialog { - wxCheckBox* m_checkBox{ nullptr }; wxString m_checkBoxText; bool m_checkBoxValue{ false }; @@ -416,7 +415,6 @@ private: wxString m_new_keys; Button * m_update_btn = nullptr; Button * m_later_btn = nullptr; - wxStaticText *m_msg_text = nullptr; }; diff --git a/src/slic3r/GUI/MultiMachineManagerPage.hpp b/src/slic3r/GUI/MultiMachineManagerPage.hpp index 2576e36f12..9b5655a9ee 100644 --- a/src/slic3r/GUI/MultiMachineManagerPage.hpp +++ b/src/slic3r/GUI/MultiMachineManagerPage.hpp @@ -79,7 +79,6 @@ private: wxBoxSizer* m_main_sizer{nullptr}; wxBoxSizer* m_sizer_machine_list{nullptr}; wxScrolledWindow* m_machine_list{ nullptr }; - wxStaticText* m_selected_num{ nullptr }; // table head wxPanel* m_table_head_panel{ nullptr }; @@ -99,8 +98,6 @@ private: int m_total_count{ 0 }; int m_count_page_item{ 10 }; - bool prev{ false }; - bool next{ false }; Button* btn_last_page{ nullptr }; Button* btn_next_page{ nullptr }; wxStaticText* st_page_number{ nullptr }; diff --git a/src/slic3r/GUI/MultiMachinePage.hpp b/src/slic3r/GUI/MultiMachinePage.hpp index 0572c30d1b..724eeed2a9 100644 --- a/src/slic3r/GUI/MultiMachinePage.hpp +++ b/src/slic3r/GUI/MultiMachinePage.hpp @@ -81,7 +81,6 @@ private: AppConfig* app_config; Label* m_label{ nullptr }; wxScrolledWindow* scroll_macine_list{ nullptr }; - wxBoxSizer* m_sizer_body{ nullptr }; wxBoxSizer* sizer_machine_list{ nullptr }; std::map m_device_items; int m_selected_count{0}; diff --git a/src/slic3r/GUI/MultiTaskManagerPage.hpp b/src/slic3r/GUI/MultiTaskManagerPage.hpp index 0f676d06b3..f1b35ca2aa 100644 --- a/src/slic3r/GUI/MultiTaskManagerPage.hpp +++ b/src/slic3r/GUI/MultiTaskManagerPage.hpp @@ -99,7 +99,6 @@ private: wxBoxSizer* page_sizer{ nullptr }; wxBoxSizer* m_sizer_task_list{ nullptr }; wxScrolledWindow* m_task_list{ nullptr }; - wxStaticText* m_selected_num{ nullptr }; // table head wxPanel* m_table_head_panel{ nullptr }; @@ -113,7 +112,6 @@ private: Button* m_action{ nullptr }; // ctrl button for all - int m_sel_number{0}; wxPanel* m_ctrl_btn_panel{ nullptr }; wxBoxSizer* m_btn_sizer{ nullptr }; Button* btn_stop_all{ nullptr }; @@ -160,15 +158,12 @@ private: wxBoxSizer* m_sizer_task_list{ nullptr }; wxBoxSizer* m_main_sizer{ nullptr }; wxScrolledWindow* m_task_list{ nullptr }; - wxStaticText* m_selected_num{ nullptr }; // Flipping pages int m_current_page{ 0 }; int m_total_page{0}; int m_total_count{ 0 }; int m_count_page_item{ 10 }; - bool prev{ false }; - bool next{ false }; Button* btn_last_page{ nullptr }; Button* btn_next_page{ nullptr }; wxStaticText* st_page_number{ nullptr }; @@ -191,7 +186,6 @@ private: Button* m_action{ nullptr }; // ctrl button for all - int m_sel_number; wxPanel* m_ctrl_btn_panel{ nullptr }; wxBoxSizer* m_btn_sizer{ nullptr }; Button* btn_pause_all{ nullptr }; diff --git a/src/slic3r/GUI/NotificationManager.cpp b/src/slic3r/GUI/NotificationManager.cpp index 5e83ad845f..2ee875901c 100644 --- a/src/slic3r/GUI/NotificationManager.cpp +++ b/src/slic3r/GUI/NotificationManager.cpp @@ -3083,7 +3083,7 @@ bool NotificationManager::push_notification_data(std::unique_ptractivate_existing(notification.get())) { - if (m_initialized) { // ignore update action - it cant be initialized if canvas and imgui context is not ready + if (m_initialized && m_imgui_ready) { if (notification->get_type() == NotificationType::SlicingWarning) { m_pop_notifications.back()->append(notification->get_data().ori_text); } else { @@ -3129,6 +3129,10 @@ void NotificationManager::stop_delayed_notifications_of_type(const NotificationT void NotificationManager::render_notifications(GLCanvas3D &canvas, float overlay_width, float bottom_margin, float right_margin) { + // Notifications render inside an ImGui frame, so the font atlas is built from this point on + // and pushed notifications may safely measure their text. + m_imgui_ready = true; + sort_notifications(); float bottom_up_last_y = bottom_margin; // ORCA dont scale margins @@ -3339,17 +3343,7 @@ size_t NotificationManager::get_notification_count() const void NotificationManager::bbl_show_plateinfo_notification(const std::string &text) { NotificationData data{NotificationType::BBLPlateInfo, NotificationLevel::PrintInfoNotificationLevel, BBL_NOTICE_MAX_INTERVAL, text}; - - for (std::unique_ptr ¬ification : m_pop_notifications) { - if (notification->get_type() == NotificationType::BBLPlateInfo) { - notification->reinit(); - notification->update(data); - return; - } - } - - auto notification = std::make_unique(data, m_id_provider, m_evt_handler); - push_notification_data(std::move(notification), 0); + push_notification_data(data, 0); } void NotificationManager::bbl_close_3mf_warn_notification() @@ -3360,20 +3354,10 @@ void NotificationManager::bbl_close_3mf_warn_notification() } } -void NotificationManager::bbl_show_3mf_warn_notification(const std::string &text) +void NotificationManager::bbl_show_3mf_warn_notification(const std::string &text, NotificationLevel level) { - NotificationData data{NotificationType::BBL3MFInfo, NotificationLevel::ErrorNotificationLevel, BBL_NOTICE_MAX_INTERVAL, text}; - - for (std::unique_ptr ¬ification : m_pop_notifications) { - if (notification->get_type() == NotificationType::BBL3MFInfo) { - notification->reinit(); - notification->update(data); - return; - } - } - - auto notification = std::make_unique(data, m_id_provider, m_evt_handler); - push_notification_data(std::move(notification), 0); + NotificationData data{NotificationType::BBL3MFInfo, level, BBL_NOTICE_MAX_INTERVAL, text}; + push_notification_data(data, 0); } void NotificationManager::bbl_close_plateinfo_notification() @@ -3388,17 +3372,7 @@ void NotificationManager::bbl_close_plateinfo_notification() void NotificationManager::bbl_show_preview_only_notification(const std::string &text) { NotificationData data{NotificationType::BBLPreviewOnlyMode, NotificationLevel::WarningNotificationLevel, 0, text}; - - for (std::unique_ptr ¬ification : m_pop_notifications) { - if (notification->get_type() == NotificationType::BBLPreviewOnlyMode) { - notification->reinit(); - notification->update(data); - return; - } - } - - auto notification = std::make_unique(data, m_id_provider, m_evt_handler); - push_notification_data(std::move(notification), 0); + push_notification_data(data, 0); } void NotificationManager::bbl_close_preview_only_notification() diff --git a/src/slic3r/GUI/NotificationManager.hpp b/src/slic3r/GUI/NotificationManager.hpp index bf0a5cfe1a..cb65524dac 100644 --- a/src/slic3r/GUI/NotificationManager.hpp +++ b/src/slic3r/GUI/NotificationManager.hpp @@ -378,7 +378,9 @@ public: void bbl_close_plateinfo_notification(); //BBS-- 3mf warning - void bbl_show_3mf_warn_notification(const std::string &text); + // level defaults to the historical error styling; callers reporting informational + // 3MF load notices (published settings) pass WarningNotificationLevel instead. + void bbl_show_3mf_warn_notification(const std::string &text, NotificationLevel level = NotificationLevel::ErrorNotificationLevel); void bbl_close_3mf_warn_notification(); //BBS--preview only mode @@ -1052,6 +1054,11 @@ private: bool m_is_dark = false; // set by init(), until false notifications are only added not updated and frame is not requested after push bool m_initialized{ false }; + // set by render_notifications() on the first rendered frame. m_initialized only proves the + // manager exists, not that the ImGui context can measure text: the font atlas is built lazily + // in ImGuiWrapper::new_frame() on the first GL render, so updating a notification before that + // (PopNotification::init -> count_spaces -> ImGui::CalcTextSize) dereferences a null font. + bool m_imgui_ready{ false }; // Target for wxWidgets events sent by clicking on the hyperlink available at some notifications. wxEvtHandler* m_evt_handler; // Cache of IDs to identify and reuse ImGUI windows. @@ -1076,7 +1083,10 @@ private: NotificationType::ProgressBar, NotificationType::PrintHostUpload, NotificationType::SimplifySuggestion, - NotificationType::ValidateWarning + NotificationType::ValidateWarning, + // A published file load can produce several distinct 3MF warnings (invalid values, + // skipped settings, changed slots); let them stack rather than clobber each other. + NotificationType::BBL3MFInfo }; //prepared (basic) notifications // non-static so its not loaded too early. If static, the translations wont load correctly. diff --git a/src/slic3r/GUI/ObjColorDialog.cpp b/src/slic3r/GUI/ObjColorDialog.cpp index d5a37fc9a7..ef5d4ba9d2 100644 --- a/src/slic3r/GUI/ObjColorDialog.cpp +++ b/src/slic3r/GUI/ObjColorDialog.cpp @@ -86,8 +86,6 @@ ObjColorDialog::ObjColorDialog(wxWindow *parent, Slic3r::ObjDialogInOut &in_out, wxDefaultPosition, wxDefaultSize, wxDEFAULT_DIALOG_STYLE /* | wxRESIZE_BORDER*/) - , m_filament_ids(in_out.filament_ids) - , m_first_extruder_id(in_out.first_extruder_id) { auto m_line_top = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(-1, 1)); m_line_top->SetBackgroundColour(wxColour(166, 169, 170)); diff --git a/src/slic3r/GUI/ObjColorDialog.hpp b/src/slic3r/GUI/ObjColorDialog.hpp index 19ed1dcb24..34f9eb5de5 100644 --- a/src/slic3r/GUI/ObjColorDialog.hpp +++ b/src/slic3r/GUI/ObjColorDialog.hpp @@ -94,7 +94,6 @@ private: std::vector m_cluster_map_filaments;//show middle int m_max_filament_index = 0; std::vector m_cluster_colours;//from_algo and show left - bool m_can_add_filament{true}; bool m_deal_thumbnail_flag{false}; std::vector m_new_add_colors; std::vector m_new_add_final_colors; @@ -123,8 +122,6 @@ private: wxBoxSizer * m_main_sizer = nullptr; wxBoxSizer * m_buttons_sizer = nullptr; std::unordered_map m_button_list; - std::vector& m_filament_ids; - unsigned char & m_first_extruder_id; }; #endif // _WIPE_TOWER_DIALOG_H_ \ No newline at end of file diff --git a/src/slic3r/GUI/OptionsGroup.cpp b/src/slic3r/GUI/OptionsGroup.cpp index 25c13c4b8d..7d63556eff 100644 --- a/src/slic3r/GUI/OptionsGroup.cpp +++ b/src/slic3r/GUI/OptionsGroup.cpp @@ -698,10 +698,15 @@ std::string OptionsGroup::pick_plugin(const ConfigOptionDef& opt) Slic3r::PluginManager& manager = Slic3r::PluginManager::instance(); const Slic3r::PluginCapabilityType plugin_type = Slic3r::plugin_capability_type_from_string(opt.plugin_type); if (plugin_type == Slic3r::PluginCapabilityType::Unknown) { - const std::string message = opt.plugin_type.empty() - ? "This setting does not specify a plugin capability type." - : "This setting specifies an unrecognized plugin capability type: '" + opt.plugin_type + "'."; - wxMessageBox(from_u8(message), _L("Plugin Selection"), wxOK | wxICON_WARNING, m_parent); + MessageDialog dlg(m_parent, + opt.plugin_type.empty() ? _L("This setting does not specify a plugin capability type.") + : _L("This setting specifies an unrecognized plugin capability type: ") + "'" + opt.plugin_type + "'.", + _L("Plugin Selection"), + wxOK | wxICON_WARNING + ); + dlg.CenterOnParent(); + dlg.ShowModal(); + return {}; } @@ -714,7 +719,13 @@ std::string OptionsGroup::pick_plugin(const ConfigOptionDef& opt) }); if (caps.empty()) { - wxMessageBox(_L("No plugins capabilities available for this type.\nEnable or install some to use."), _L("Plugin Selection"), wxOK | wxICON_INFORMATION, m_parent); + MessageDialog dlg(m_parent, + _L("No plugins capabilities available for this type.\nEnable or install some to use."), + _L("Plugin Selection"), + wxOK | wxICON_INFORMATION + ); + dlg.CenterOnParent(); + dlg.ShowModal(); return {}; } diff --git a/src/slic3r/GUI/ParamsPanel.hpp b/src/slic3r/GUI/ParamsPanel.hpp index 0726db91d3..91bf3d2a7e 100644 --- a/src/slic3r/GUI/ParamsPanel.hpp +++ b/src/slic3r/GUI/ParamsPanel.hpp @@ -66,7 +66,6 @@ class ParamsPanel : public wxPanel { #if __WXOSX__ wxWindow* m_tmp_panel; - int m_size_move = -1; #endif // __WXOSX__ private: diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 849aa4e31a..c9370cc282 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -16,10 +17,12 @@ #include #include #include +#include #include #include "libslic3r/libslic3r.h" #include "libslic3r/Polygon.hpp" +#include "libslic3r/GCode/WipeTowerEstimate.hpp" #include "libslic3r/ClipperUtils.hpp" #include "libslic3r/BoundingBox.hpp" #include "libslic3r/Geometry.hpp" @@ -1110,7 +1113,7 @@ void PartPlate::show_tooltip(const std::string tooltip) { const auto scale = m_plater->get_current_canvas3D()->get_scale(); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, {6 * scale, 3 * scale}); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, {3 * scale}); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 3 * scale); ImGui::PushStyleColor(ImGuiCol_PopupBg, ImGuiWrapper::COL_WINDOW_BACKGROUND); ImGui::PushStyleColor(ImGuiCol_Border, {0, 0, 0, 0}); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.00f, 1.00f, 1.00f, 1.00f)); @@ -1531,8 +1534,23 @@ std::vector PartPlate::get_extruders(bool conside_custom_gcode) const if (check_objects_empty_and_gcode3mf(plate_extruders)) { return plate_extruders; } - // if 3mf file - const DynamicPrintConfig& glb_config = wxGetApp().preset_bundle->prints.get_edited_preset().config; + return get_extruders(conside_custom_gcode, wxGetApp().preset_bundle->prints.get_edited_preset().config, wxGetApp().preset_bundle->project_config); +} + +// The plate's filaments, with the global keys read from the given configs rather than the +// application's presets: the wipe tower estimate is also called under the CLI, which has no +// application object. get_extruders(bool) passes the edited presets; a full config serves both. +std::vector PartPlate::get_extruders(bool conside_custom_gcode, const DynamicPrintConfig& glb_config, const DynamicPrintConfig& project_config) const +{ + std::vector plate_extruders; + // A plate from a sliced .gcode.3mf holds no objects, so report the filaments the G-code + // used. check_objects_empty_and_gcode3mf does this for get_extruders(bool), but reaches + // the plater, which the CLI has none of; slice_filaments_info is only filled for such a plate. + if (m_model->objects.empty()) { + for (const FilamentInfo &info : slice_filaments_info) + plate_extruders.push_back(info.id + 1); + return plate_extruders; + } int glb_support_intf_extr = glb_config.opt_int("support_interface_filament"); int glb_support_extr = glb_config.opt_int("support_filament"); int glb_outer_wall_extr = glb_config.opt_int("outer_wall_filament_id"); @@ -1549,7 +1567,9 @@ std::vector PartPlate::get_extruders(bool conside_custom_gcode) const glb_support |= glb_config.opt_int("raft_layers") > 0; for (int obj_idx = 0; obj_idx < m_model->objects.size(); obj_idx++) { - if (!contain_instance_totally(obj_idx, 0)) + // Any instance on the plate counts, as PrintApply does: after an arrange, instance 0 + // can sit on a different plate. + if (!contain_any_instance_totally(obj_idx)) continue; ModelObject* mo = m_model->objects[obj_idx]; @@ -1662,7 +1682,7 @@ std::vector PartPlate::get_extruders(bool conside_custom_gcode) const if (conside_custom_gcode) { //BBS int nums_extruders = 0; - if (const ConfigOptionStrings *color_option = dynamic_cast(wxGetApp().preset_bundle->project_config.option("filament_colour"))) { + if (const ConfigOptionStrings *color_option = dynamic_cast(project_config.option("filament_colour"))) { nums_extruders = color_option->values.size(); if (m_model->plates_custom_gcodes.find(m_plate_index) != m_model->plates_custom_gcodes.end()) { for (auto item : m_model->plates_custom_gcodes.at(m_plate_index).gcodes) { @@ -1681,9 +1701,8 @@ std::vector PartPlate::get_extruders(bool conside_custom_gcode) const // is never loaded into a tray, so callers (AMS mapping, filament checks) must see the // physical filaments it resolves to instead. { - auto& project_config = wxGetApp().preset_bundle->project_config; - auto* is_mixed_opt = project_config.option("filament_is_mixed"); - auto* comp_strs_opt = project_config.option("filament_mixed_components"); + const auto* is_mixed_opt = project_config.option("filament_is_mixed"); + const auto* comp_strs_opt = project_config.option("filament_mixed_components"); if (is_mixed_opt && comp_strs_opt && has_any_mixed_filament(is_mixed_opt->values)) { std::vector ext_0based; for (int e : plate_extruders) @@ -1899,7 +1918,7 @@ std::vector PartPlate::get_extruders_without_support(bool conside_custom_gc const DynamicPrintConfig& glb_config = wxGetApp().preset_bundle->prints.get_edited_preset().config; for (int obj_idx = 0; obj_idx < m_model->objects.size(); obj_idx++) { - if (!contain_instance_totally(obj_idx, 0)) + if (!contain_any_instance_totally(obj_idx)) continue; ModelObject* mo = m_model->objects[obj_idx]; @@ -2070,7 +2089,7 @@ bool PartPlate::check_single_extruder_mixed_filament_risk(const DynamicPrintConf "which may significantly increase waste and the risk of nozzle / waste-chute clogging."); for (int obj_idx = 0; obj_idx < (int)m_model->objects.size(); ++obj_idx) { - if (!contain_instance_totally(obj_idx, 0)) + if (!contain_any_instance_totally(obj_idx)) continue; ModelObject *mo = m_model->objects[obj_idx]; int obj_ext = mo->config.has("extruder") ? mo->config.extruder() : 1; @@ -2289,7 +2308,7 @@ bool PartPlate::check_compatible_of_nozzle_and_filament(const DynamicPrintConfig return wipe_tower_size; for (int obj_idx = 0; obj_idx < m_model->objects.size(); obj_idx++) { - if (!use_global_objects && !contain_instance_totally(obj_idx, 0)) + if (!use_global_objects && !contain_any_instance_totally(obj_idx)) continue; BoundingBoxf3 bbox = m_model->objects[obj_idx]->bounding_box(); @@ -2311,113 +2330,97 @@ bool PartPlate::check_compatible_of_nozzle_and_filament(const DynamicPrintConfig return wipe_tower_size; }*/ -Vec3d PartPlate::estimate_wipe_tower_size(const DynamicPrintConfig & config, const double w, const double wipe_volume, int extruder_count, int plate_extruder_size, bool use_global_objects, bool enable_wrapping_detection) const +WipeTowerFootprint PartPlate::estimate_wipe_tower_footprint(const DynamicPrintConfig &config, int plate_extruder_size, bool use_global_objects) const { - Vec3d wipe_tower_size; - double layer_height = 0.08f; // hard code layer height - double max_height = 0.f; - wipe_tower_size.setZero(); + // The CLI calls this too, so the plate's filaments are derived from the passed config: + // get_extruders(bool) reads the same keys off wxGetApp()'s presets, which the CLI has none of. + // An explicit count is a floor: init-time and arrange estimates size an empty plate for that + // many generic filaments, the lowest ids not already on the plate. + std::vector plate_extruders = get_extruders(true, config, config); + for (int id = 1; int(plate_extruders.size()) < plate_extruder_size; ++id) + if (std::find(plate_extruders.begin(), plate_extruders.end(), id) == plate_extruders.end()) + plate_extruders.push_back(id); + // The wipe tower filament joins the tool ordering even when unused (Print::extruders), so + // validation counts it - but only where there is a tower to join, which is the + // has_wipe_tower() half of that guard. + const ConfigOption *wipe_tower_filament_opt = config.option("wipe_tower_filament"); + const ConfigOption *enable_prime_tower_opt = config.option("enable_prime_tower"); + const int wipe_tower_filament = wipe_tower_filament_opt != nullptr ? wipe_tower_filament_opt->getInt() : 0; + if (enable_prime_tower_opt != nullptr && enable_prime_tower_opt->getBool() && plate_extruders.size() > 1 && wipe_tower_filament > 0 && + std::find(plate_extruders.begin(), plate_extruders.end(), wipe_tower_filament) == plate_extruders.end()) + plate_extruders.push_back(wipe_tower_filament); + if (plate_extruders.empty()) + return WipeTowerFootprint(); - const ConfigOption* layer_height_opt = config.option("layer_height"); - if (layer_height_opt) - layer_height = layer_height_opt->getFloat(); - - // empty plate - if (plate_extruder_size == 0) - { - std::vector plate_extruders = get_extruders(true); - plate_extruder_size = plate_extruders.size(); - } - if (plate_extruder_size == 0) - return wipe_tower_size; - - for (int obj_idx = 0; obj_idx < m_model->objects.size(); obj_idx++) { - if (!use_global_objects && !contain_instance_totally(obj_idx, 0)) + // Tallest object on this plate and the thinnest layer it is sliced at, resolved per object + // as PrintObject resolves them (override, else preset) and over this plate's objects only - + // seeding from the global value, or folding in an off-plate override, diverges from Print. + const ConfigOption *layer_height_opt = config.option("layer_height"); + const double global_layer_height = layer_height_opt != nullptr ? layer_height_opt->getFloat() : 0.08; + double max_height = 0.; + double layer_height = std::numeric_limits::max(); + for (int obj_idx = 0; obj_idx < int(m_model->objects.size()); ++obj_idx) { + const ModelObject *object = m_model->objects[obj_idx]; + if (!use_global_objects && !contain_any_instance_totally(obj_idx)) continue; - - BoundingBoxf3 bbox = m_model->objects[obj_idx]->bounding_box_exact(); - max_height = std::max(bbox.size().z(), max_height); - } - wipe_tower_size(2) = max_height; - //const DynamicPrintConfig &dconfig = wxGetApp().preset_bundle->prints.get_edited_preset().config; - auto timelapse_type = config.option>("timelapse_type"); - bool need_wipe_tower = (timelapse_type ? (timelapse_type->value == TimelapseType::tlSmooth) : false) | enable_wrapping_detection; - double extra_spacing = config.option("prime_tower_infill_gap")->getFloat() / 100.; - const ConfigOptionEnum* use_rib_wall_opt = config.option>("wipe_tower_wall_type"); - bool use_rib_wall = use_rib_wall_opt ? use_rib_wall_opt->value == WipeTowerWallType::wtwRib: false; - double rib_width = config.option("wipe_tower_rib_width")->getFloat(); - double depth; - double filament_change_volume=0.; - { - std::vector filament_change_lengths; - auto filament_change_lengths_opt = m_print->config().option("filament_change_length"); - if (filament_change_lengths_opt) filament_change_lengths = filament_change_lengths_opt->values; - double length = filament_change_lengths.empty() ? 0 : *std::max_element(filament_change_lengths.begin(), filament_change_lengths.end()); - double diameter = 1.75; - std::vector diameters; - auto filament_diameter_opt = m_print->config().option("filament_diameter"); - if (filament_diameter_opt) diameters = filament_diameter_opt->values; - diameter = diameters.empty() ? diameter : *std::max_element(diameters.begin(), diameters.end()); - filament_change_volume = length * PI * diameter * diameter / 4.; - } - double volume = wipe_volume * (extruder_count == 2 ? plate_extruder_size : (plate_extruder_size - 1)); - if (extruder_count == 2) volume += filament_change_volume * (int) (plate_extruder_size / 2); - // Read from the passed plate config — m_print may not have been applied yet - // (fresh plates, CLI), in which case its PrintConfig still holds defaults. - const auto *purge_opt = config.option("purge_in_prime_tower"); - const auto *semm_opt = config.option("single_extruder_multi_material"); - const bool semm_flush = purge_opt && purge_opt->value && semm_opt && semm_opt->value; - if (semm_flush) volume = WipeTower2::estimate_semm_flush_volume(config, plate_extruder_size); - if (use_rib_wall) { - depth = std::sqrt(volume / layer_height * extra_spacing); - if (need_wipe_tower || plate_extruder_size > 1) { - float min_wipe_tower_depth = WipeTower::get_limit_depth_by_height(max_height); - double volume_depth = depth; - depth = std::max((double) min_wipe_tower_depth, depth); - rib_width = std::min(rib_width, depth / 2); - depth = rib_width / std::sqrt(2) + std::max(depth + m_print->config().wipe_tower_extra_rib_length.value, volume_depth); - wipe_tower_size(0) = wipe_tower_size(1) = depth; + // Per instance, to match PrintObject::size(); the union over instances differs once + // they are rotated apart. The cached convex hull has the mesh's z extent and is cheap + // enough for every scene reload. + for (int inst_idx = 0; inst_idx < int(object->instances.size()); ++inst_idx) { + if (!use_global_objects && !contain_instance_totally(obj_idx, inst_idx)) + continue; + max_height = std::max(max_height, object->instance_convex_hull_bounding_box(inst_idx, true).size().z()); } + const ConfigOption *object_layer_height = object->config.option("layer_height"); + layer_height = std::min(layer_height, object_layer_height != nullptr ? object_layer_height->getFloat() : global_layer_height); } - else { - depth = volume / (layer_height * w); - // The flush volumes already hold the spacing between wipes. - if (!semm_flush) depth *= extra_spacing; - if (need_wipe_tower || depth > EPSILON) { - float min_wipe_tower_depth = WipeTower::get_limit_depth_by_height(max_height); - depth = std::max((double)min_wipe_tower_depth, depth); - } - wipe_tower_size(0) = w; - wipe_tower_size(1) = depth; - } + if (layer_height == std::numeric_limits::max()) + layer_height = global_layer_height; - return wipe_tower_size; + std::vector filament_ids; + for (int id : plate_extruders) + if (id > 0) + filament_ids.push_back(static_cast(id - 1)); + return Slic3r::estimate_wipe_tower_footprint(config, resolve_wipe_tower_type(config), filament_ids, layer_height, max_height); } -arrangement::ArrangePolygon PartPlate::estimate_wipe_tower_polygon(const DynamicPrintConfig& config, int plate_index, Vec3d& wt_pos, Vec3d& wt_size, int extruder_count, int plate_extruder_size, bool use_global_objects) const +arrangement::ArrangePolygon PartPlate::estimate_wipe_tower_polygon(const DynamicPrintConfig& config, int plate_index, Vec3d& wt_pos, Vec3d& wt_size, int plate_extruder_size, bool use_global_objects) const { float x = dynamic_cast(config.option("wipe_tower_x"))->get_at(plate_index); float y = dynamic_cast(config.option("wipe_tower_y"))->get_at(plate_index); - float w = dynamic_cast(config.option("prime_tower_width"))->value; //float a = dynamic_cast(config.option("wipe_tower_rotation_angle"))->value; - float v = dynamic_cast(config.option("prime_volume"))->value; - float tower_brim_width = dynamic_cast(config.option("prime_tower_brim_width"))->value; - const ConfigOptionBool * wrapping_opt = dynamic_cast(config.option("enable_wrapping_detection")); - bool enable_wrapping = (wrapping_opt != nullptr) && wrapping_opt->value; - wt_size = estimate_wipe_tower_size(config, w, v, extruder_count, plate_extruder_size, use_global_objects, enable_wrapping); + const WipeTowerFootprint footprint = estimate_wipe_tower_footprint(config, plate_extruder_size, use_global_objects); + wt_size = Vec3d(footprint.width, footprint.depth, footprint.height); int plate_width=m_width, plate_depth=m_depth; - w = wt_size(0); // effective width; differs from prime_tower_width when the rib wall squares the tower + float w = wt_size(0); // effective width; differs from prime_tower_width when the rib wall squares the tower float depth = wt_size(1); - float margin = WIPE_TOWER_MARGIN + tower_brim_width, wp_brim_width = 0.f; - const ConfigOption* wipe_tower_brim_width_opt = config.option("prime_tower_brim_width"); - if (wipe_tower_brim_width_opt) { - wp_brim_width = wipe_tower_brim_width_opt->getFloat(); - if (wp_brim_width < 0) wp_brim_width = WipeTower::get_auto_brim_by_height((float) wt_size.z()); - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format("arrange wipe_tower: wp_brim_width %1%") % wp_brim_width; - } - - x = std::clamp(x, margin, (float)plate_width - w - margin - wp_brim_width); - y = std::clamp(y, margin, (float)plate_depth - depth - margin - wp_brim_width); + // Resolved brim, not the raw option: "Auto" (-1) would yield a margin of 0 and let the + // clamp put the brim off the bed. Matches set_default_wipe_tower_pos_for_plate. + float wp_brim_width = float(footprint.brim_width); + // A Type2 stabilization cone bulges past the body box like a brim does - fold its worst-axis + // bulge into the same margin. + const BoundingBox outline = get_extents(estimate_wipe_tower_first_layer_outline(config, resolve_wipe_tower_type(config), w, depth, wt_size.z())); + wp_brim_width += float(std::max({0., unscaled(outline.max.x()) - w, unscaled(outline.max.y()) - depth, -unscaled(outline.min.x()), -unscaled(outline.min.y())})); + // A position valid by WIPE_TOWER_MARGIN is the user's choice and stays untouched; an + // invalid one is re-placed with the comfort margin (falling back to the validity bounds + // on cramped plates). std::clamp is UB if lo > hi, so keep every hi >= lo. + const float margin = WIPE_TOWER_MARGIN + wp_brim_width; + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format("arrange wipe_tower: wp_brim_width %1%") % wp_brim_width; + const float x_hi = std::max(margin, (float) plate_width - w - margin); + const float y_hi = std::max(margin, (float) plate_depth - depth - margin); + const float margin_c = (float) WIPE_TOWER_AUTO_MARGIN + wp_brim_width; + float x_lo_c = margin_c, x_hi_c = (float) plate_width - w - margin_c; + if (x_lo_c > x_hi_c) { x_lo_c = margin; x_hi_c = x_hi; } + float y_lo_c = margin_c, y_hi_c = (float) plate_depth - depth - margin_c; + if (y_lo_c > y_hi_c) { y_lo_c = margin; y_hi_c = y_hi; } + // Drag clamps reach this limit through the volume's bounding box (post-slice: the real + // mesh, a couple of mm inside this reserved estimate), so a drop can land slightly out + // of bounds — snap it onto the bound; only far-out positions get the comfort re-place. + const float tol = 5.f; + if (x < margin - tol || x > x_hi + tol) x = std::clamp(x, x_lo_c, x_hi_c); + else x = std::clamp(x, margin, x_hi); + if (y < margin - tol || y > y_hi + tol) y = std::clamp(y, y_lo_c, y_hi_c); + else y = std::clamp(y, margin, y_hi); wt_pos(0) = x; wt_pos(1) = y; wt_pos(2) = 0.f; @@ -2755,6 +2758,20 @@ bool PartPlate::contain_instance_totally(int obj_id, int instance_id) const return result; } +//judge whether any of the object's instances is totally included in plate or not +bool PartPlate::contain_any_instance_totally(int obj_id) const +{ + if (obj_id < 0 || obj_id >= int(m_model->objects.size())) + return false; + + const ModelObject *object = m_model->objects[obj_id]; + for (int instance_id = 0; instance_id < int(object->instances.size()); ++instance_id) + if (contain_instance_totally(obj_id, instance_id)) + return true; + + return false; +} + //check whether instance is outside the plate or not bool PartPlate::check_outside(int obj_id, int instance_id, BoundingBoxf3* bounding_box) { @@ -4488,26 +4505,16 @@ void PartPlateList::set_default_wipe_tower_pos_for_plate(int plate_idx, bool ini f_volume_maps = wxGetApp().preset_bundle->get_default_nozzle_volume_types_for_filaments(filament_maps); } DynamicPrintConfig full_config = wxGetApp().preset_bundle->full_config(false, filament_maps, f_volume_maps); - float w = dynamic_cast(full_config.option("prime_tower_width"))->value; - float v = dynamic_cast(full_config.option("prime_volume"))->value; - bool enable_wrapping = false; - const ConfigOptionBool *wrapping_opt = dynamic_cast(full_config.option("enable_wrapping_detection")); - if (wrapping_opt) enable_wrapping = wrapping_opt->value; - int nozzle_nums = wxGetApp().preset_bundle->get_printer_extruder_count(); - Vec3d wipe_tower_size = part_plate->estimate_wipe_tower_size(full_config, w, v, nozzle_nums, init_pos ? 2 : 0, false, enable_wrapping); + WipeTowerFootprint footprint = part_plate->estimate_wipe_tower_footprint(full_config, init_pos ? 2 : 0); - if (!init_pos && (is_approx(wipe_tower_size(0), 0.0) || is_approx(wipe_tower_size(1), 0.0))) { - wipe_tower_size = part_plate->estimate_wipe_tower_size(full_config, w, v, nozzle_nums, 2, false, enable_wrapping); + if (!init_pos && (is_approx(footprint.width, 0.0) || is_approx(footprint.depth, 0.0))) { + footprint = part_plate->estimate_wipe_tower_footprint(full_config, 2); } + Vec3d wipe_tower_size(footprint.width, footprint.depth, footprint.height); - // Compute brim-aware margin: brim extends outward from tower position - float brim_width = 0.f; - const ConfigOptionFloat *brim_opt = full_config.option("prime_tower_brim_width"); - if (brim_opt) { - brim_width = brim_opt->value; - if (brim_width < 0) brim_width = WipeTower::get_auto_brim_by_height((float) wipe_tower_size.z()); - } - const float margin = WIPE_TOWER_MARGIN + brim_width; + // Brim-aware margin: the brim extends outward from the tower position. + const float brim_width = float(footprint.brim_width); + const float margin = WIPE_TOWER_AUTO_MARGIN + brim_width; // clamp wipe tower position within plate boundaries { diff --git a/src/slic3r/GUI/PartPlate.hpp b/src/slic3r/GUI/PartPlate.hpp index 8ad2f4a7d1..6d7eb18beb 100644 --- a/src/slic3r/GUI/PartPlate.hpp +++ b/src/slic3r/GUI/PartPlate.hpp @@ -11,6 +11,7 @@ #include "libslic3r/GCode/GCodeProcessor.hpp" #include "libslic3r/Format/bbs_3mf.hpp" #include "libslic3r/Slicing.hpp" +#include "libslic3r/GCode/WipeTowerEstimate.hpp" #include "libslic3r/Arrange.hpp" #include "Plater.hpp" #include "libslic3r/Model.hpp" @@ -339,11 +340,16 @@ public: Vec3d get_origin() { return m_origin; } //Vec3d calculate_wipe_tower_size(const DynamicPrintConfig &config, const double w, const double wipe_volume, int plate_extruder_size = 0, bool use_global_objects = false) const; - Vec3d estimate_wipe_tower_size(const DynamicPrintConfig & config, const double w, const double wipe_volume, int extruder_count = 1, int plate_extruder_size = 0, bool use_global_objects = false, bool enable_wrapping_detection = false) const; - arrangement::ArrangePolygon estimate_wipe_tower_polygon(const DynamicPrintConfig & config, int plate_index, Vec3d& wt_pos, Vec3d& wt_size, int extruder_count = 1, int plate_extruder_size = 0, bool use_global_objects = false) const; + // plate_extruder_size: a floor on the filaments purged on the plate; its own are always + // counted, so 0 sizes for exactly those. + // use_global_objects skips the containment test, which the CLI needs before objects are + // assigned to plates - the layer height is then the project's thinnest, which over-reserves. + WipeTowerFootprint estimate_wipe_tower_footprint(const DynamicPrintConfig & config, int plate_extruder_size = 0, bool use_global_objects = false) const; + arrangement::ArrangePolygon estimate_wipe_tower_polygon(const DynamicPrintConfig & config, int plate_index, Vec3d& wt_pos, Vec3d& wt_size, int plate_extruder_size = 0, bool use_global_objects = false) const; bool check_objects_empty_and_gcode3mf(std::vector &result) const; // get used filaments from config, 1 based idx std::vector get_extruders(bool conside_custom_gcode = false) const; + std::vector get_extruders(bool conside_custom_gcode, const DynamicPrintConfig& glb_config, const DynamicPrintConfig& project_config) const; std::vector get_extruders_under_cli(bool conside_custom_gcode, DynamicPrintConfig& full_config) const; std::vector get_extruders_without_support(bool conside_custom_gcode = false) const; // get used filaments from gcode result, 1 based idx @@ -366,6 +372,8 @@ public: bool contain_instance_totally(ModelObject* object, int instance_id) const; //judge whether instance is totally included in plate or not bool contain_instance_totally(int obj_id, int instance_id) const; + //judge whether any of the object's instances is totally included in plate or not + bool contain_any_instance_totally(int obj_id) const; //judge whether the plate's origin is at the left of instance or not bool is_left_top_of(int obj_id, int instance_id); diff --git a/src/slic3r/GUI/PartSkipDialog.cpp b/src/slic3r/GUI/PartSkipDialog.cpp index b9dd7d5007..9bd6687757 100644 --- a/src/slic3r/GUI/PartSkipDialog.cpp +++ b/src/slic3r/GUI/PartSkipDialog.cpp @@ -1,5 +1,6 @@ #include "GUI_Utils.hpp" #include "GUI_App.hpp" +#include "slic3r/Utils/NetworkAgent.hpp" #include #include #include diff --git a/src/slic3r/GUI/PhysicalPrinterDialog.cpp b/src/slic3r/GUI/PhysicalPrinterDialog.cpp index 989cf204e1..04317ca46b 100644 --- a/src/slic3r/GUI/PhysicalPrinterDialog.cpp +++ b/src/slic3r/GUI/PhysicalPrinterDialog.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 2d8816960b..0fdb9dcd94 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -1,12 +1,10 @@ #include "Plater.hpp" #include "../Utils/NetworkAgent.hpp" -#include "../Utils/NetworkAgentFactory.hpp" #include "libslic3r/Config.hpp" #include "libslic3r_version.h" #include #include -#include #include #include #include @@ -16,8 +14,6 @@ #include #include #include -#include -#include #include #include #include @@ -69,7 +65,6 @@ #include "libslic3r/Format/bbs_3mf.hpp" #include "libslic3r/GCode/ThumbnailData.hpp" #include "libslic3r/Model.hpp" -#include "libslic3r/SLA/Hollowing.hpp" #include "libslic3r/SLA/SupportPoint.hpp" #include "libslic3r/SLA/ReprojectPointsOnMesh.hpp" #include "libslic3r/Polygon.hpp" @@ -78,16 +73,15 @@ #include "libslic3r/SLAPrint.hpp" #include "libslic3r/Utils.hpp" #include "libslic3r/PresetBundle.hpp" +#include "libslic3r/PublishSettings.hpp" #include "slic3r/Utils/CrealityPrint.hpp" #include "libslic3r/ClipperUtils.hpp" -#include "libslic3r/ObjColorUtils.hpp" // For stl export #include "libslic3r/CSGMesh/ModelToCSGMesh.hpp" #include "libslic3r/CSGMesh/PerformCSGMeshBooleans.hpp" #include "GUI.hpp" #include "GUI_App.hpp" -#include "GuiColor.hpp" #include "GUI_ObjectList.hpp" #ifdef __WXGTK__ #include "LinuxDisplayBackend.hpp" @@ -123,7 +117,6 @@ #include "SendMultiMachinePage.hpp" #include "SendToPrinter.hpp" #include "PublishDialog.hpp" -#include "ModelMall.hpp" #include "ConfigWizard.hpp" #include "SyncAmsInfoDialog.hpp" #include "../Utils/ASCIIFolding.hpp" @@ -149,7 +142,6 @@ #include "ParamsDialog.hpp" #include "ImageDPIFrame.hpp" #include "Widgets/Label.hpp" -#include "Widgets/RoundedRectangle.hpp" #include "Widgets/RadioGroup.hpp" #include "Widgets/CheckBox.hpp" #include "Widgets/Button.hpp" @@ -3667,8 +3659,8 @@ void Sidebar::update_presets(Preset::Type preset_type) // so extruders without an explicit sub-nozzle count never offer Hybrid. A nullable-int // nil is INT_MAX (> 1) and would otherwise falsely pass the gate, so exclude it too. if (boost::algorithm::contains(extruder_variants->values[index], type + " " + nozzle_volumes_def->enum_labels[i]) || - extruder_max_nozzle_count->get_at(index) > 1 && extruder_max_nozzle_count->get_at(index) != ConfigOptionIntsNullable::nil_value() && - nozzle_volumes_def->enum_keys_map->at(nozzle_volumes_def->enum_values[i]) == nvtHybrid) { + (extruder_max_nozzle_count->get_at(index) > 1 && extruder_max_nozzle_count->get_at(index) != ConfigOptionIntsNullable::nil_value() && + nozzle_volumes_def->enum_keys_map->at(nozzle_volumes_def->enum_values[i]) == nvtHybrid)) { if (nozzle_volumes_def->enum_keys_map->at(nozzle_volumes_def->enum_values[i]) == NozzleVolumeType::nvtHighFlow &&(diameter == "0.2" || is_skip_high_flow_printer(printer_model))) continue; @@ -6712,6 +6704,12 @@ struct Plater::priv SendToPrinterDialog* m_send_to_sdcard_dlg = nullptr; PublishDialog *m_publish_dlg = nullptr; + // Session-level stash of the last published selection. Written on publish and on + // loading a published 3MF; read when the Publish dialog is opened. + bool m_has_pending_published{false}; + std::vector m_pending_published_keys; + std::vector m_pending_material_keys; + // Data Slic3r::DynamicPrintConfig *config; // FIXME: leak? Slic3r::Print fff_print; @@ -6936,7 +6934,10 @@ struct Plater::priv // BBS: backup & restore using LoadProgressCallback = std::function; - std::vector load_files(const std::vector& input_files, LoadStrategy strategy, bool ask_multi = false); + std::vector load_files(const std::vector& input_files, + LoadStrategy strategy, + bool ask_multi = false, + bool* published_out = nullptr); std::vector load_model_objects(const ModelObjectPtrs& model_objects, bool allow_negative_z = false, bool split_object = false, bool auto_drop = true); // Texture-to-color import: a mesh loaded with UVs + a texture map gets its faces clustered @@ -6964,7 +6965,7 @@ struct Plater::priv std::function cancel_callback = {}); fs::path get_export_file_path(GUI::FileType file_type); - wxString get_export_file(GUI::FileType file_type); + wxString get_export_file(GUI::FileType file_type, const wxString& title = {}, bool published = false); // BBS void load_auxiliary_files(); @@ -8272,7 +8273,10 @@ void read_binary_stl(const std::string& filename, std::string& model_id, std::st } // BBS: backup & restore -std::vector Plater::priv::load_files(const std::vector& input_files, LoadStrategy strategy, bool ask_multi) +std::vector Plater::priv::load_files(const std::vector& input_files, + LoadStrategy strategy, + bool ask_multi, + bool* published_out) { std::vector empty_result; bool dlg_cont = true; @@ -8352,6 +8356,7 @@ std::vector Plater::priv::load_files(const std::vector& input_ const float INPUT_FILES_RATIO = 0.7; const float INIT_MODEL_RATIO = 0.75; const float CENTER_AROUND_ORIGIN_RATIO = 0.8; + const float LOAD_MODEL_RATIO = 0.9; for (size_t i = 0; i < input_files.size(); ++i) { @@ -8392,6 +8397,11 @@ std::vector Plater::priv::load_files(const std::vector& input_ DynamicPrintConfig config; Semver file_version; En3mfType en_3mf_file_type = En3mfType::From_BBS; + // BBS: a "published" 3MF carries a flag plus the author-selected setting keys; + // on load keep the user's current presets and overlay only those keys. Declared + // here (outside the config block below) so it stays alive for the embedded-preset + // gate, the metadata strip and the preset overlay after the block closes. + PublishedConfig published_config; { DynamicPrintConfig config_loaded; @@ -8419,6 +8429,107 @@ std::vector Plater::priv::load_files(const std::vector& input_ << boost::format(", plate_data.size %1%, project_preset.size %2%, is_bbs_or_orca_3mf %3%, file_version %4% \n") % plate_data.size() % project_presets.size() % (en_3mf_file_type == En3mfType::From_BBS || en_3mf_file_type == En3mfType::From_Orca) % file_version.to_string(); + // BBS: a "published" 3MF carries a flag plus the author-selected setting keys; + // on load keep the user's current presets and overlay only those keys. Parsed + // here (before the version/fallback chain below) because a published file has + // no project_settings.config: its values travel in the published_config + // metadata payload, which must fill config_loaded before the chain decides + // whether to import geometry only. + if (model.model_info != nullptr) { + auto published_it = model.model_info->metadata_items.find(ORCA_PUBLISHED_TAG); + if (published_it != model.model_info->metadata_items.end() && is_published_3mf_flag(published_it->second)) { + published_config.published = true; + auto keys_it = model.model_info->metadata_items.find(ORCA_PUBLISHED_KEYS_TAG); + if (keys_it != model.model_info->metadata_items.end()) { + try { + auto j = nlohmann::json::parse(keys_it->second); + if (j.is_array()) + for (const auto& k : j) + if (k.is_string()) + published_config.published_keys.emplace_back(k.get()); + } catch (...) { + // Ignore malformed published_keys; the project still loads normally. + } + } + + auto material_keys_it = model.model_info->metadata_items.find(ORCA_PUBLISHED_MATERIAL_TAG); + if (material_keys_it != model.model_info->metadata_items.end()) { + try { + auto jm = nlohmann::json::parse(material_keys_it->second); + if (jm.is_array()) + for (const auto& m : jm) { + try { + // Malformed entries are isolated so one bad item + // cannot discard valid entries that follow it. + if (!m.is_object()) + continue; + PublishedMaterialEntry entry; + const auto mat_it = m.find("material"); + if (mat_it != m.end() && mat_it->is_object()) { + const auto& mat = *mat_it; + if (mat.contains("filament_type") && mat["filament_type"].is_string()) + entry.filament_type = mat["filament_type"].get(); + if (mat.contains("filament_vendor") && mat["filament_vendor"].is_string()) + entry.filament_vendor = mat["filament_vendor"].get(); + if (mat.contains("filament_id") && mat["filament_id"].is_string()) + entry.filament_id = mat["filament_id"].get(); + if (mat.contains("setting_id") && mat["setting_id"].is_string()) + entry.setting_id = mat["setting_id"].get(); + if (mat.contains("name") && mat["name"].is_string()) + entry.preset_name = mat["name"].get(); + } + if (m.contains("slot") && m["slot"].is_number_integer()) + entry.slot = m["slot"].get(); + const auto entry_keys_it = m.find("keys"); + if (entry_keys_it != m.end() && entry_keys_it->is_array()) + for (const auto& k : *entry_keys_it) + if (k.is_string()) + entry.keys.emplace_back(k.get()); + // Fields always written by the current exporter. + if (m.contains("full") && m["full"].is_boolean()) + entry.full = m["full"].get(); + const auto entry_full_keys_it = m.find("full_keys"); + if (entry_full_keys_it != m.end() && entry_full_keys_it->is_array()) + for (const auto& k : *entry_full_keys_it) + if (k.is_string()) + entry.full_keys.emplace_back(k.get()); + if (m.contains("publish_type") && m["publish_type"].is_boolean()) + entry.publish_type = m["publish_type"].get(); + if (m.contains("type") && m["type"].is_string()) + entry.publish_type_value = m["type"].get(); + if (m.contains("publish_color") && m["publish_color"].is_boolean()) + entry.publish_color = m["publish_color"].get(); + if (m.contains("color") && m["color"].is_string()) + entry.color = m["color"].get(); + published_config.material_keys.emplace_back(std::move(entry)); + } catch (const nlohmann::json::exception&) { + // Ignore only this malformed material entry. + } + } + } catch (const nlohmann::json::exception&) { + // Ignore malformed published_material_keys; the project still loads normally. + } + } + + // Rebuild the published values from the metadata payload: a published + // file carries no project_settings.config, so config_loaded is filled + // from here; a missing or malformed payload leaves it empty and the + // fallback chain below imports the geometry only. + auto payload_it = model.model_info->metadata_items.find(ORCA_PUBLISHED_CONFIG_TAG); + if (payload_it != model.model_info->metadata_items.end()) { + try { + ConfigSubstitutions payload_substitutions = + config_loaded.load_from_ini_string(payload_it->second, ForwardCompatibilitySubstitutionRule::Enable); + config_substitutions.substitutions.insert(config_substitutions.substitutions.end(), + std::make_move_iterator(payload_substitutions.begin()), + std::make_move_iterator(payload_substitutions.end())); + } catch (...) { + // Ignore malformed published_config; the project still loads normally. + } + } + } + } + // 1. add extruder for prusa model if the number of existing extruders is not enough // 2. add extruder for BBS or Other model if only import geometry if (en_3mf_file_type == En3mfType::From_Prusa || (load_model && !load_config)) { @@ -8582,7 +8693,7 @@ std::vector Plater::priv::load_files(const std::vector& input_ text += "\n"; log_and_show_3mf_info(text, bambu_project_title); } - } else if (load_config) { + } else if (load_config && !published_config.published) { // BambuStudio version is older or same as our SLIC3R_VERSION wxString text = _L("The 3MF was created by BambuStudio. Some settings may differ from OrcaSlicer."); log_and_show_3mf_info(text, bambu_project_title); @@ -8642,7 +8753,9 @@ std::vector Plater::priv::load_files(const std::vector& input_ } Semver old_version(1, 5, 9); - if ((en_3mf_file_type == En3mfType::From_BBS || en_3mf_file_type == En3mfType::From_Orca) && (file_version < old_version) && load_model && load_config && !config_loaded.empty()) { + // A published 3MF has no project config to migrate: skip the old-version + // translations even if a slicer tag slipped through classification. + if ((en_3mf_file_type == En3mfType::From_BBS || en_3mf_file_type == En3mfType::From_Orca) && (file_version < old_version) && !published_config.published && load_model && load_config && !config_loaded.empty()) { translate_old = true; partplate_list.get_plate_size(current_width, current_depth, current_height); } @@ -8664,8 +8777,10 @@ std::vector Plater::priv::load_files(const std::vector& input_ } } - // BBS:: project embedded presets - if ((project_presets.size() > 0) && load_config) { + // BBS:: project embedded presets (skipped for published projects: the author's + // embedded presets must not pollute the receiver's library, the overlay applies + // the published keys to the receiver's own presets instead). + if ((project_presets.size() > 0) && load_config && !published_config.published) { // load project embedded presets PresetsConfigSubstitutions preset_substitutions; PresetBundle & preset_bundle = *wxGetApp().preset_bundle; @@ -8721,6 +8836,19 @@ std::vector Plater::priv::load_files(const std::vector& input_ } } + // BBS: a "published" 3MF loads as a new project: its path must not become the + // project filename (Save/Ctrl-S would overwrite the shared file), and the + // published metadata is consumed above and stripped so a later save is a normal + // unpublished 3MF. + if (published_out != nullptr && published_config.published) + *published_out = true; + if (published_config.published && load_config && this->model.model_info != nullptr) { + this->model.model_info->metadata_items.erase(ORCA_PUBLISHED_TAG); + this->model.model_info->metadata_items.erase(ORCA_PUBLISHED_KEYS_TAG); + this->model.model_info->metadata_items.erase(ORCA_PUBLISHED_MATERIAL_TAG); + this->model.model_info->metadata_items.erase(ORCA_PUBLISHED_CONFIG_TAG); + } + if (load_config) { if (!config.empty()) { Preset::normalize(config); @@ -8729,7 +8857,7 @@ std::vector Plater::priv::load_files(const std::vector& input_ { // BBS: modify the prime tower params for old version file Semver old_version3(2, 0, 0); - if ((en_3mf_file_type == En3mfType::From_BBS || en_3mf_file_type == En3mfType::From_Orca) && file_version < old_version3) { + if ((en_3mf_file_type == En3mfType::From_BBS || en_3mf_file_type == En3mfType::From_Orca) && !published_config.published && file_version < old_version3) { double old_filament_prime_volume = 0.; int filament_count = 0; { @@ -8779,7 +8907,7 @@ std::vector Plater::priv::load_files(const std::vector& input_ } auto choise = wxGetApp().app_config->get("no_warn_when_modified_gcodes"); - if (choise.empty() || choise != "true") { + if (!published_config.published && (choise.empty() || choise != "true")) { // BBS: first validate the printer // validate the system profiles std::set modified_gcodes; @@ -8824,12 +8952,56 @@ std::vector Plater::priv::load_files(const std::vector& input_ if (wipe_tower_y_opt) file_wipe_tower_y = *wipe_tower_y_opt; + // Convert the printer's filament ids to Orca ids before loading. if (auto* agent = wxGetApp().getAgent()) { if (auto* ids = config.opt("filament_ids")) for (std::string& id : ids->values) id = agent->to_orca_filament_id(id); } - preset_bundle->load_config_model(filename.string(), std::move(config), file_version); + preset_bundle->load_config_model(filename.string(), std::move(config), file_version, &published_config); + + // Mixed-filament definitions that collided with one of the + // receiver's real slots were relocated during the preset load. + // Re-point the freshly parsed model's extruder references and + // color painting from the author's slot numbers to where each + // definition landed, so volumes colored with a mix follow it. + // Runs before the objects are handed over to the plater below. + if (load_model && !published_config.mixed_slot_relocations.empty()) + Slic3r::remap_model_filament_slots(model, published_config.mixed_slot_relocations); + + // BBS: notify the user about published settings that could not be applied. + if (!published_config.skipped_keys.empty()) { + NotificationManager* notify_manager = q->get_notification_manager(); + std::string message = _u8L("Some published settings could not be applied:"); + for (const std::string& key : published_config.skipped_keys) + message += "\n-" + key; + // Informational: the load succeeded, these keys were skipped. + notify_manager + ->bbl_show_3mf_warn_notification(message, + NotificationManager::NotificationLevel::WarningNotificationLevel); + } + + // BBS: notify the user about slot materials that were replaced while + // loading a published project (type mismatch / no same-type match). + if (!published_config.material_replacements.empty()) { + NotificationManager* notify_manager = q->get_notification_manager(); + std::string message = _u8L("Some filament slots were changed:"); + for (const std::string& replacement : published_config.material_replacements) + message += "\n-" + replacement; + // Informational: the load succeeded, the slots were adapted. + notify_manager + ->bbl_show_3mf_warn_notification(message, + NotificationManager::NotificationLevel::WarningNotificationLevel); + } + + // Remember the imported published selection so the Publish dialog is + // pre-seeded with the file's settings. Stored after the load so any + // per-slot relocations are already reflected in material_keys. + if (published_config.published) { + this->m_has_pending_published = true; + this->m_pending_published_keys = published_config.published_keys; + this->m_pending_material_keys = published_config.material_keys; + } ConfigOption* bed_type_opt = preset_bundle->project_config.option("curr_bed_type"); if (bed_type_opt != nullptr) { @@ -8970,7 +9142,13 @@ std::vector Plater::priv::load_files(const std::vector& input_ dynamic_map->value = false; } // Update filament combobox after loading config - wxGetApp().plater()->sidebar().update_presets(Preset::TYPE_FILAMENT); + if (published_config.published) { + q->update_filament_colors_in_full_config(); + wxGetApp().plater()->sidebar().update_all_preset_comboboxes(); + wxGetApp().plater()->sidebar().update_dynamic_filament_list(); + } else { + wxGetApp().plater()->sidebar().update_presets(Preset::TYPE_FILAMENT); + } // The loaded project supplies nozzle_volume_type; refresh the sidebar // nozzle-count badges against it. if (auto *nozzle_volumes = wxGetApp().preset_bundle->project_config.option("nozzle_volume_type")) { @@ -9723,7 +9901,7 @@ fs::path Plater::priv::get_export_file_path(GUI::FileType file_type) return output_file; } -wxString Plater::priv::get_export_file(GUI::FileType file_type) +wxString Plater::priv::get_export_file(GUI::FileType file_type, const wxString& title, bool published) { wxString wildcard; switch (file_type) { @@ -9765,8 +9943,11 @@ wxString Plater::priv::get_export_file(GUI::FileType file_type) } case FT_3MF: { - output_file.replace_extension("3mf"); - dlg_title = _L("Save file as"); + // A published export is suggested as ".published.3mf" so the role is visible in the + // dialog and in the recent-files list. This is only a pre-filled suggestion; the user's + // typed filename wins, keeping a plain ".3mf" output fully valid. + output_file.replace_extension(published ? "published.3mf" : "3mf"); + dlg_title = title.empty() ? _L("Save file as") : title; break; } case FT_OBJ: @@ -10004,6 +10185,11 @@ void Plater::priv::reset(bool apply_presets_change) clear_warnings(); + // A new project must not inherit the previous project's published selection (Feature A/B). + m_has_pending_published = false; + m_pending_published_keys.clear(); + m_pending_material_keys.clear(); + set_project_filename(""); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << " call set_project_filename: empty"; @@ -15104,14 +15290,15 @@ void Plater::load_project(wxString const& filename2, if (strategy & LoadStrategy::Restore) input_paths.push_back(into_u8(originfile)); - std::vector res = load_files(input_paths, strategy); + bool loaded_published = false; + std::vector res = load_files(input_paths, strategy, false, &loaded_published); reset_project_dirty_initial_presets(); update_project_dirty_from_presets(); wxGetApp().preset_bundle->export_selections(*wxGetApp().app_config); // if res is empty no data has been loaded - if (!res.empty() && (load_restore || !(strategy & LoadStrategy::Silence))) { + if (!res.empty() && !loaded_published && (load_restore || !(strategy & LoadStrategy::Silence))) { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << " call set_project_filename: " << (load_restore ? originfile : filename); p->set_project_filename(load_restore ? originfile : filename); if (load_restore && originfile.IsEmpty()) { @@ -15122,6 +15309,15 @@ void Plater::load_project(wxString const& filename2, if (using_exported_file()) { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << " using ecported set project filename: " << filename; p->set_project_filename(filename); + } else if (loaded_published && !res.empty()) { + // A "published" 3MF loads as a new project: its path must not become the project + // filename (Save/Ctrl-S prompts for a destination instead of overwriting it); + // reset() already cleared the project name, so restore the default title and keep + // the file in recents. Only on a successful load (res not empty): a failed or + // cancelled load must not pollute "Recently opened". + p->set_project_name(_L("Untitled")); + if (!filename.IsEmpty()) + wxGetApp().mainframe->add_to_recent_projects(filename); } } @@ -16779,22 +16975,12 @@ void Plater::force_update_all_plate_thumbnails() } // BBS: backup -std::vector Plater::load_files(const std::vector& input_files, LoadStrategy strategy, bool ask_multi) { +std::vector Plater::load_files(const std::vector& input_files, LoadStrategy strategy, bool ask_multi, bool* published_out) { //BBS: wish to reset state when load a new file p->m_slice_all_only_has_gcode = false; //BBS: wish to reset all plates stats item selected state when load a new file p->preview->get_canvas3d()->reset_select_plate_toolbar_selection(); - return p->load_files(input_files, strategy, ask_multi); -} - -// To be called when providing a list of files to the GUI slic3r on command line. -std::vector Plater::load_files(const std::vector& input_files, LoadStrategy strategy, bool ask_multi) -{ - std::vector paths; - paths.reserve(input_files.size()); - for (const std::string& path : input_files) - paths.emplace_back(path); - return p->load_files(paths, strategy, ask_multi); + return p->load_files(input_files, strategy, ask_multi, published_out); } bool Plater::preview_zip_archive(const boost::filesystem::path& archive_path) @@ -17653,6 +17839,10 @@ void Plater::increase_instances(size_t num) model_object->add_instance(offset_vec, model_instance->get_scaling_factor(), model_instance->get_rotation(), model_instance->get_mirror()); // p->print.get_object(obj_idx)->add_copy(Slic3r::to_2d(offset_vec)); } + // Register the copies with the plate they land on before the scene reloads: the plate's + // filament list and wipe tower preview are read from that registry. + for (size_t i = model_object->instances.size() - num; i < model_object->instances.size(); ++i) + p->partplate_list.notify_instance_update(obj_idx, static_cast(i)); #ifdef SUPPORT_AUTO_CENTER if (p->get_config("autocenter") == "true") @@ -17683,8 +17873,10 @@ void Plater::decrease_instances(size_t num) ModelObject* model_object = p->model.objects[obj_idx]; if (model_object->instances.size() > num) { - for (size_t i = 0; i < num; ++ i) + for (size_t i = 0; i < num; ++ i) { + p->partplate_list.notify_instance_removed(obj_idx, static_cast(model_object->instances.size()) - 1); model_object->delete_last_instance(); + } p->update(); // Delete object from Sidebar list. Do it after update, so that the GLScene selection is updated with the modified model. sidebar().obj_list()->decrease_object_instances(obj_idx, num); @@ -18023,7 +18215,6 @@ void Plater::send_gcode_finish(wxString name) auto out_str = GUI::format(_L("The file %s has been sent to the printer's storage space and can be viewed on the printer."), name); p->notification_manager->push_exporting_finished_notification(out_str, "", false); } - void Plater::export_core_3mf() { wxString path = p->get_export_file(FT_3MF); @@ -18032,6 +18223,182 @@ void Plater::export_core_3mf() export_3mf(path_u8, SaveStrategy::Silence); } +// Export the current project as a "published" 3MF: a pure export that never touches the +// project's file name, dirty state, backup path or title, and attaches the published metadata +// to the model only for the duration of the export (a later Save Project is a normal 3MF). +int Plater::export_published_3mf(const std::vector& published_keys, + const std::vector& material_keys) +{ + wxString path = p->get_export_file(FT_3MF, _L("Publish 3MF file as:"), true); + if (path.empty() || path == "") + return wxID_CANCEL; + + nlohmann::json j = nlohmann::json::array(); + for (const std::string& key : published_keys) + j.push_back(key); + nlohmann::json jm = nlohmann::json::array(); + for (const Slic3r::PublishedMaterialEntry& e : material_keys) + jm.push_back({{"material", + {{"filament_type", e.filament_type}, + {"filament_vendor", e.filament_vendor}, + {"filament_id", e.filament_id}, + {"setting_id", e.setting_id}, + {"name", e.preset_name}}}, + {"slot", e.slot}, + {"keys", e.keys}, + {"full", e.full}, + {"full_keys", e.full_keys}, + {"publish_type", e.publish_type}, + {"type", e.publish_type_value}, + {"publish_color", e.publish_color}, + {"color", e.color}}); + + Model& model = this->model(); + // Save the previous metadata so it can be restored after the export, keeping the in-memory + // project pristine (the published flag lives only in the exported file). + const bool had_model_info = (model.model_info != nullptr); + const bool had_published = had_model_info && + (model.model_info->metadata_items.find(ORCA_PUBLISHED_TAG) != model.model_info->metadata_items.end()); + const bool had_published_keys = had_model_info && (model.model_info->metadata_items.find(ORCA_PUBLISHED_KEYS_TAG) != + model.model_info->metadata_items.end()); + const bool had_material_keys = had_model_info && (model.model_info->metadata_items.find(ORCA_PUBLISHED_MATERIAL_TAG) != + model.model_info->metadata_items.end()); + const bool had_payload = had_model_info && + (model.model_info->metadata_items.find(ORCA_PUBLISHED_CONFIG_TAG) != model.model_info->metadata_items.end()); + const std::string prev_published = had_published ? model.model_info->metadata_items.at(ORCA_PUBLISHED_TAG) : std::string(); + const std::string prev_published_keys = had_published_keys ? model.model_info->metadata_items.at(ORCA_PUBLISHED_KEYS_TAG) : + std::string(); + const std::string prev_material_keys = had_material_keys ? model.model_info->metadata_items.at(ORCA_PUBLISHED_MATERIAL_TAG) : + std::string(); + const std::string prev_payload = had_payload ? model.model_info->metadata_items.at(ORCA_PUBLISHED_CONFIG_TAG) : std::string(); + + // export_3mf() assigns archive paths to previously unsaved SVGs. Preserve those fields too, + // otherwise a publish changes what a later normal project save writes. + std::vector> previous_svg_paths; + for (ModelObject* object : model.objects) + for (ModelVolume* volume : object->volumes) + if (volume != nullptr && volume->emboss_shape.has_value() && volume->emboss_shape->svg_file.has_value()) { + std::string* path_in_3mf = &volume->emboss_shape->svg_file->path_in_3mf; + previous_svg_paths.emplace_back(path_in_3mf, *path_in_3mf); + } + + auto restore_temporary_state = [&]() { + for (const auto& [path_in_3mf, previous_path] : previous_svg_paths) + *path_in_3mf = previous_path; + + if (!had_model_info) { + model.model_info = nullptr; + } else { + if (had_published) + model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = prev_published; + else + model.model_info->metadata_items.erase(ORCA_PUBLISHED_TAG); + if (had_published_keys) + model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] = prev_published_keys; + else + model.model_info->metadata_items.erase(ORCA_PUBLISHED_KEYS_TAG); + if (had_material_keys) + model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] = prev_material_keys; + else + model.model_info->metadata_items.erase(ORCA_PUBLISHED_MATERIAL_TAG); + if (had_payload) + model.model_info->metadata_items[ORCA_PUBLISHED_CONFIG_TAG] = prev_payload; + else + model.model_info->metadata_items.erase(ORCA_PUBLISHED_CONFIG_TAG); + } + }; + bool state_restored = false; + auto restore_now = [&]() { + if (state_restored) + return; + restore_temporary_state(); + state_restored = true; + }; + ScopeGuard restore_guard(restore_now); + + if (model.model_info == nullptr) + model.model_info = std::make_shared(); + model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = "1"; + model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] = j.dump(); + model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] = jm.dump(); + + int ret = -1; + try { + // Minimal published export: filter full_config to the published keys, material keys, + // identity fields and plate geometry keys, and omit the project config file, the + // project-embedded preset dumps and the OrcaSlicer version tag from the archive. The + // filtered values are serialized into the published_config metadata payload instead, so + // OrcaSlicer versions without the publish feature fall back to importing the geometry only + // (keeping the receiver's presets) while new versions rebuild the config from the payload. + DynamicPrintConfig full_cfg = wxGetApp().preset_bundle->full_config_secure(); + DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, published_keys, material_keys); + std::string payload; + for (const std::string& key : filtered_cfg.keys()) { + // A value containing a newline would break the INI written below (read_ini throws), + // so load_from_ini_string discards the whole settings block on import. Skip such + // keys instead of silently dropping every setting. + std::string value = filtered_cfg.opt_serialize(key); + if (value.find('\n') != std::string::npos) { + BOOST_LOG_TRIVIAL(warning) << "publish: dropping key \"" << key + << "\" from the published payload (value contains a newline)"; + continue; + } + payload += key + " = " + value + "\n"; + } + model.model_info->metadata_items[ORCA_PUBLISHED_CONFIG_TAG] = std::move(payload); + + // Same file layout as save_project(), plus Silence (so export_3mf does not set the project + // filename on success, keeping this a pure export like export_core_3mf()) and MinimalPublished. + auto save_strategy = SaveStrategy::SplitModel | SaveStrategy::ShareMesh | SaveStrategy::Silence | SaveStrategy::MinimalPublished; + bool full_pathnames = wxGetApp().app_config->get_bool("export_sources_full_pathnames"); + if (full_pathnames) + save_strategy = save_strategy | SaveStrategy::FullPathSources; + ret = export_3mf(into_path(path), save_strategy, -1, nullptr); + } catch (...) { + restore_now(); + MessageDialog(this, + _L("Failed to export the published 3MF file.\nPlease check whether the folder exists online or if other programs " + "have the file open."), + _L("Publish"), wxOK | wxICON_WARNING) + .ShowModal(); + return wxID_CANCEL; + } + + if (ret < 0) { + restore_now(); + MessageDialog(this, + _L("Failed to export the published 3MF file.\nPlease check whether the folder exists online or if other programs " + "have the file open."), + _L("Publish"), wxOK | wxICON_WARNING) + .ShowModal(); + return wxID_CANCEL; + } + restore_now(); + + // Register the exported file in the "Recently opened" list + wxGetApp().mainframe->add_to_recent_projects(path); + + return wxID_YES; +} + +bool Plater::get_pending_published(std::vector& out_keys, + std::vector& out_material) const +{ + if (!p->m_has_pending_published) + return false; + out_keys = p->m_pending_published_keys; + out_material = p->m_pending_material_keys; + return true; +} + +void Plater::set_pending_published(const std::vector& published_keys, + const std::vector& material_keys) +{ + p->m_has_pending_published = true; + p->m_pending_published_keys = published_keys; + p->m_pending_material_keys = material_keys; +} + Preset *get_printer_preset(const MachineObject *obj) { if (!obj) diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index 5308deec61..84e64acea0 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -42,6 +42,7 @@ class Button; namespace Slic3r { class BuildVolume; +class MachineObject; enum class BuildVolume_Type : char; class Model; class ModelObject; @@ -407,9 +408,7 @@ public: bool preview_zip_archive(const boost::filesystem::path& archive_path); // BBS: restore - std::vector load_files(const std::vector& input_files, LoadStrategy strategy = LoadStrategy::LoadModel | LoadStrategy::LoadConfig, bool ask_multi = false); - // To be called when providing a list of files to the GUI slic3r on command line. - std::vector load_files(const std::vector& input_files, LoadStrategy strategy = LoadStrategy::LoadModel | LoadStrategy::LoadConfig, bool ask_multi = false); + std::vector load_files(const std::vector& input_files, LoadStrategy strategy = LoadStrategy::LoadModel | LoadStrategy::LoadConfig, bool ask_multi = false, bool* published_out = nullptr); // to be called on drag and drop bool load_files(const wxArrayString& filenames); @@ -519,6 +518,13 @@ public: void export_gcode_3mf(bool export_all = false); void send_gcode_finish(wxString name); void export_core_3mf(); + // Export a "published" 3MF embedding the author-selected settings in the file metadata; a + // pure export that leaves the in-memory project untouched. + int export_published_3mf(const std::vector& published_keys, const std::vector& material_keys); + // Session-level stash of the last published selection, seeded into the Publish dialog on + // open and written on publish or on loading a published 3MF + bool get_pending_published(std::vector& out_keys, std::vector& out_material) const; + void set_pending_published(const std::vector& published_keys, const std::vector& material_keys); static TriangleMesh combine_mesh_fff(const ModelObject& mo, int instance_id, std::function notify_func = {}); void export_stl(bool extended = false, bool selection_only = false, bool multi_stls = false, FileType file_type = FT_STL); //BBS: remove amf diff --git a/src/slic3r/GUI/PluginPickerDialog.cpp b/src/slic3r/GUI/PluginPickerDialog.cpp index d0c387b0c0..5109b3b483 100644 --- a/src/slic3r/GUI/PluginPickerDialog.cpp +++ b/src/slic3r/GUI/PluginPickerDialog.cpp @@ -9,14 +9,17 @@ #include "GUI.hpp" #include "I18N.hpp" +#include "GUI_App.hpp" + +#include "Widgets/DialogButtons.hpp" + namespace Slic3r { namespace GUI { PluginPickerDialog::PluginPickerDialog(wxWindow* parent, const wxString& plugin_type_label, const std::vector& plugins) - : wxDialog(parent, wxID_ANY, wxString::Format(_L("Select %s Plugin"), plugin_type_label)) + : DPIDialog(parent, wxID_ANY, wxString::Format(_L("Select %s Plugin"), plugin_type_label)) , m_plugins(plugins) - , m_capability_mode(false) { build_ui(plugin_type_label); CentreOnParent(); @@ -25,9 +28,8 @@ PluginPickerDialog::PluginPickerDialog(wxWindow* parent, PluginPickerDialog::PluginPickerDialog(wxWindow* parent, const wxString& plugin_type_label, std::vector capabilities) - : wxDialog(parent, wxID_ANY, wxString::Format(_L("Select %s Plugin"), plugin_type_label)) + : DPIDialog(parent, wxID_ANY, wxString::Format(_L("Select %s Plugin"), plugin_type_label)) , m_capabilities(std::move(capabilities)) - , m_capability_mode(true) { build_capability_ui(plugin_type_label); CentreOnParent(); @@ -35,12 +37,18 @@ PluginPickerDialog::PluginPickerDialog(wxWindow* parent, void PluginPickerDialog::build_ui(const wxString& plugin_type_label) { + SetBackgroundColour(*wxWHITE); + const bool has_plugins = !m_plugins.empty(); auto* top_sizer = new wxBoxSizer(wxVERTICAL); auto* info_text = new wxStaticText(this, wxID_ANY, wxString::Format(_L("Choose a %s plugin from the list below."), plugin_type_label)); - top_sizer->Add(info_text, 0, wxALL | wxEXPAND, 10); + info_text->SetFont(Label::Body_14); + info_text->SetForegroundColour(wxColour("#363636")); + top_sizer->Add(info_text, 0, wxALL | wxEXPAND, FromDIP(10)); + + top_sizer->AddSpacer(FromDIP(5)); wxArrayString choices; choices.reserve(m_plugins.size()); @@ -51,84 +59,103 @@ void PluginPickerDialog::build_ui(const wxString& plugin_type_label) choices.Add(label); } - m_choice = new wxChoice(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, choices); + m_choice = new ComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0, NULL, wxCB_READONLY); + for (const wxString &opt : choices) { m_choice->Append(opt); } + if (has_plugins) { m_choice->SetSelection(0); - m_choice->Bind(wxEVT_CHOICE, [this](wxCommandEvent& evt) { + m_choice->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent& evt) { update_description(evt.GetSelection()); }); } else { m_choice->Enable(false); } - top_sizer->Add(m_choice, 0, wxLEFT | wxRIGHT | wxEXPAND, 10); + top_sizer->Add(m_choice, 0, wxLEFT | wxRIGHT | wxEXPAND, FromDIP(10)); m_description = new wxStaticText(this, wxID_ANY, wxEmptyString); + m_description->SetFont(Label::Body_14); + m_description->SetForegroundColour(wxColour("#363636")); m_description->Wrap(400); - top_sizer->Add(m_description, 0, wxALL | wxEXPAND, 10); + top_sizer->Add(m_description, 0, wxALL | wxEXPAND, FromDIP(10)); if (has_plugins) update_description(0); else m_description->SetLabel(_L("No plugins found for this type.")); - auto* button_sizer = new wxStdDialogButtonSizer(); - auto* ok_button = new wxButton(this, wxID_OK); - ok_button->Enable(has_plugins); - button_sizer->AddButton(ok_button); - button_sizer->AddButton(new wxButton(this, wxID_CANCEL)); - button_sizer->Realize(); + auto dlg_btns = new DialogButtons(this, {"OK", "Cancel"}); - top_sizer->Add(button_sizer, 0, wxALL | wxALIGN_RIGHT, 10); + dlg_btns->GetOK()->Bind(wxEVT_BUTTON, [this](wxCommandEvent &e) { EndModal(wxID_OK); }); + dlg_btns->GetOK()->Enable(has_plugins); + + dlg_btns->GetCANCEL()->Bind(wxEVT_BUTTON, [this](wxCommandEvent &e) { EndModal(wxID_CANCEL); }); + + top_sizer->Add(dlg_btns, 0, wxEXPAND); SetSizerAndFit(top_sizer); + + wxGetApp().UpdateDlgDarkUI(this); } void PluginPickerDialog::build_capability_ui(const wxString& plugin_type_label) { + SetBackgroundColour(*wxWHITE); + const bool has_capabilities = !m_capabilities.empty(); auto* top_sizer = new wxBoxSizer(wxVERTICAL); auto* info_text = new wxStaticText(this, wxID_ANY, wxString::Format(_L("Choose a %s plugin from the list below."), plugin_type_label)); - top_sizer->Add(info_text, 0, wxALL | wxEXPAND, 10); + info_text->SetFont(Label::Body_14); + info_text->SetForegroundColour(wxColour("#363636")); + + top_sizer->Add(info_text, 0, wxALL | wxEXPAND, FromDIP(10)); + + top_sizer->AddSpacer(FromDIP(5)); wxArrayString choices; choices.reserve(m_capabilities.size()); for (const auto& cap : m_capabilities) choices.Add(cap.label); - m_choice = new wxChoice(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, choices); + m_choice = new ComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0, NULL, wxCB_READONLY); + for (const wxString &opt : choices) { m_choice->Append(opt); } + if (has_capabilities) { m_choice->SetSelection(0); - m_choice->Bind(wxEVT_CHOICE, [this](wxCommandEvent& evt) { + m_choice->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent& evt) { update_capability_description(evt.GetSelection()); }); } else { m_choice->Enable(false); } - top_sizer->Add(m_choice, 0, wxLEFT | wxRIGHT | wxEXPAND, 10); + top_sizer->Add(m_choice, 0, wxLEFT | wxRIGHT | wxEXPAND, FromDIP(10)); m_description = new wxStaticText(this, wxID_ANY, wxEmptyString); + m_description->SetFont(Label::Body_14); + m_description->SetForegroundColour(wxColour("#363636")); m_description->Wrap(400); - top_sizer->Add(m_description, 0, wxALL | wxEXPAND, 10); + top_sizer->Add(m_description, 0, wxALL | wxEXPAND, FromDIP(10)); if (has_capabilities) update_capability_description(0); else m_description->SetLabel(_L("No plugins found for this type.")); - auto* button_sizer = new wxStdDialogButtonSizer(); - auto* ok_button = new wxButton(this, wxID_OK); - ok_button->Enable(has_capabilities); - button_sizer->AddButton(ok_button); - button_sizer->AddButton(new wxButton(this, wxID_CANCEL)); - button_sizer->Realize(); + auto dlg_btns = new DialogButtons(this, {"OK", "Cancel"}); - top_sizer->Add(button_sizer, 0, wxALL | wxALIGN_RIGHT, 10); + dlg_btns->GetOK()->Bind(wxEVT_BUTTON, [this](wxCommandEvent &e) { EndModal(wxID_OK); }); + dlg_btns->GetOK()->Enable(has_capabilities); + + dlg_btns->GetCANCEL()->Bind(wxEVT_BUTTON, [this](wxCommandEvent &e) { EndModal(wxID_CANCEL); }); + + top_sizer->Add(dlg_btns, 0, wxEXPAND); SetSizerAndFit(top_sizer); + + wxGetApp().UpdateDlgDarkUI(this); } PluginPickerDialog::CapabilityEntry PluginPickerDialog::selected_capability() const @@ -187,4 +214,6 @@ void PluginPickerDialog::update_description(int selection) Layout(); } +void PluginPickerDialog::on_dpi_changed(const wxRect &suggested_rect) {} + }} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/PluginPickerDialog.hpp b/src/slic3r/GUI/PluginPickerDialog.hpp index 0d676eb04d..6ae4163c5f 100644 --- a/src/slic3r/GUI/PluginPickerDialog.hpp +++ b/src/slic3r/GUI/PluginPickerDialog.hpp @@ -11,9 +11,12 @@ #include "slic3r/plugin/PluginManager.hpp" +#include "GUI_Utils.hpp" +#include "Widgets/ComboBox.hpp" + namespace Slic3r { namespace GUI { -class PluginPickerDialog : public wxDialog +class PluginPickerDialog : public DPIDialog { public: // Entry for capability-level selection (plugin_type non-empty path). @@ -40,17 +43,18 @@ public: // Returns the {plugin_key, name} of the selected capability (capability path). CapabilityEntry selected_capability() const; + void on_dpi_changed(const wxRect &suggested_rect) override; + private: void build_ui(const wxString& plugin_type_label); void build_capability_ui(const wxString& plugin_type_label); void update_description(int selection); void update_capability_description(int selection); - wxChoice* m_choice { nullptr }; + ComboBox* m_choice { nullptr }; wxStaticText* m_description { nullptr }; std::vector m_plugins; std::vector m_capabilities; - bool m_capability_mode { false }; }; }} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/PluginsConfigDialog.cpp b/src/slic3r/GUI/PluginsConfigDialog.cpp index 0241b47b8c..9a79588af1 100644 --- a/src/slic3r/GUI/PluginsConfigDialog.cpp +++ b/src/slic3r/GUI/PluginsConfigDialog.cpp @@ -1,6 +1,7 @@ #include "PluginsConfigDialog.hpp" #include "GUI_App.hpp" +#include "GUI.hpp" #include "I18N.hpp" #include "format.hpp" diff --git a/src/slic3r/GUI/PluginsDialog.cpp b/src/slic3r/GUI/PluginsDialog.cpp index a39fcbc535..2bac7eddca 100644 --- a/src/slic3r/GUI/PluginsDialog.cpp +++ b/src/slic3r/GUI/PluginsDialog.cpp @@ -2,6 +2,7 @@ #include "GUI.hpp" #include "GUI_App.hpp" +#include "slic3r/Utils/NetworkAgent.hpp" #include "I18N.hpp" #include "OrcaCloudServiceAgent.hpp" #include "slic3r/plugin/PluginConfig.hpp" diff --git a/src/slic3r/GUI/PluginsDialog.hpp b/src/slic3r/GUI/PluginsDialog.hpp index e663de79e4..ec68d12969 100644 --- a/src/slic3r/GUI/PluginsDialog.hpp +++ b/src/slic3r/GUI/PluginsDialog.hpp @@ -2,6 +2,7 @@ #define slic3r_PluginsDialog_hpp_ #include "Widgets/WebViewHostDialog.hpp" +#include "Widgets/ProgressDialog.hpp" #include "PluginSource.hpp" #include "PluginStatus.hpp" #include "PluginSort.hpp" @@ -107,12 +108,12 @@ private: const wxString& title, const wxString& message, int maximum = 100, - int style = wxPD_APP_MODAL | wxPD_AUTO_HIDE, + int style = wxPD_APP_MODAL | wxPD_AUTO_HIDE, // | wxPD_CAN_ABORT for cancel button bool finish_after_dialog_destroyed = false) { const auto alive = m_alive; - wxProgressDialog* progress = new wxProgressDialog(title, message, maximum, this, style); - wxTimer* timer = new wxTimer(); + ProgressDialog* progress = new ProgressDialog(title, message, maximum, this, style); + wxTimer* timer = new wxTimer(); timer->Bind(wxEVT_TIMER, [alive, progress, message](wxTimerEvent&) { if (alive->load(std::memory_order_acquire) && progress) diff --git a/src/slic3r/GUI/Preferences.cpp b/src/slic3r/GUI/Preferences.cpp index 08483f3100..73f3a2c90a 100644 --- a/src/slic3r/GUI/Preferences.cpp +++ b/src/slic3r/GUI/Preferences.cpp @@ -2,6 +2,7 @@ #include "OptionsGroup.hpp" #include "GUI_App.hpp" #include "MainFrame.hpp" +#include "WebViewDialog.hpp" #include "Plater.hpp" #include "GLCanvas3D.hpp" // ORCA: for live preview refresh when toggling "Dim lower layers" #include "MsgDialog.hpp" @@ -507,26 +508,14 @@ wxBoxSizer *PreferencesDialog::create_item_language_combobox(wxString title, wxS } } - - // the dialog needs to be destroyed before the call to switch_language() - // or sometimes the application crashes into wxDialogBase() destructor - // so we put it into an inner scope - MessageDialog msg_wingow(nullptr, _L("Switching languages requires the application to restart.\n") + "\n" + _L("Do you want to continue?"), - L("Language selection"), wxICON_QUESTION | wxOK | wxCANCEL); - if (msg_wingow.ShowModal() == wxID_CANCEL) { + MessageDialog msg_window(nullptr, _L("Switching languages requires the application to restart.\n") + "\n" + _L("Do you want to continue?"), + _L("Language selection"), wxICON_QUESTION | wxOK | wxCANCEL); + if (msg_window.ShowModal() == wxID_CANCEL) { combobox->SetSelection(m_current_language_selected); return; } } - auto check = [](bool yes_or_no) { - // if (yes_or_no) - // return true; - int act_btns = ActionButtons::SAVE; - return wxGetApp().check_and_keep_current_preset_changes(_L("Switching application language"), - _L("Switching application language while some presets are modified."), act_btns); - }; - m_current_language_selected = combobox->GetSelection(); if (m_current_language_selected >= 0 && m_current_language_selected < vlist.size()) { m_pending_language = vlist[m_current_language_selected]->CanonicalName.ToUTF8().data(); @@ -1031,11 +1020,6 @@ wxBoxSizer *PreferencesDialog::create_item_checkbox(wxString title, wxString too app_config->set_bool(param, checkbox->GetValue()); app_config->save(); - // if (param == "staff_pick_switch") { - // bool pbool = app_config->get("staff_pick_switch") == "true"; - // wxGetApp().switch_staff_pick(pbool); - // } - if (param == "sync_user_preset") { bool sync = app_config->get("sync_user_preset") == "true" ? true : false; if (sync) { diff --git a/src/slic3r/GUI/Printer/PrinterFileSystem.cpp b/src/slic3r/GUI/Printer/PrinterFileSystem.cpp index 8ec6909c8c..9aa35e2cff 100644 --- a/src/slic3r/GUI/Printer/PrinterFileSystem.cpp +++ b/src/slic3r/GUI/Printer/PrinterFileSystem.cpp @@ -1803,7 +1803,7 @@ static void* get_function(const char* name) return function; #if defined(_MSC_VER) || defined(_WIN32) - function = GetProcAddress(module, name); + function = reinterpret_cast(GetProcAddress(module, name)); #else function = dlsym(module, name); #endif diff --git a/src/slic3r/GUI/PrivacyUpdateDialog.cpp b/src/slic3r/GUI/PrivacyUpdateDialog.cpp index 92d6d6c8c7..c417767a42 100644 --- a/src/slic3r/GUI/PrivacyUpdateDialog.cpp +++ b/src/slic3r/GUI/PrivacyUpdateDialog.cpp @@ -1,5 +1,6 @@ #include "PrivacyUpdateDialog.hpp" #include "GUI_App.hpp" +#include "GUI.hpp" #include "BitmapCache.hpp" #include #include diff --git a/src/slic3r/GUI/PublishSettingsDialog.cpp b/src/slic3r/GUI/PublishSettingsDialog.cpp new file mode 100644 index 0000000000..16b0b6201c --- /dev/null +++ b/src/slic3r/GUI/PublishSettingsDialog.cpp @@ -0,0 +1,2198 @@ +#include "PublishSettingsDialog.hpp" + +#include "GUI_App.hpp" +#include "MainFrame.hpp" +#include "MsgDialog.hpp" +#include "I18N.hpp" +#include "Tab.hpp" +#include "ConfigValueFormatter.hpp" +#include "FilamentBitmapUtils.hpp" +#include "Widgets/Label.hpp" +#include "Widgets/TextInput.hpp" +#include "Widgets/DialogButtons.hpp" +#include "Widgets/StaticLine.hpp" +#include "Widgets/StateColor.hpp" + +#include "libslic3r/PrintConfig.hpp" +#include "libslic3r/Preset.hpp" +#include "libslic3r/PublishSettings.hpp" +#include "libslic3r/FilamentMixer.hpp" +#include "libslic3r/Model.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { namespace GUI { +namespace { + +// Menu ids for show_menu(): dedicated range so the popup cannot collide with application-level +// bindings (e.g. MainFrame's recent-files wxID_FILE1.. range). +enum { + kPublishSelectAll = wxID_HIGHEST + 1, + kPublishDeselectAll, + kPublishSelectVisible, + kPublishDeselectVisible, + kPublishFilterSelected, + kPublishFilterNonSelected +}; + +// Tree-style flags shared by the outer tabs and every group's inner tabs. +constexpr long s_tab_style = wxTR_NO_BUTTONS | wxTR_HIDE_ROOT | wxTR_SINGLE | wxTR_NO_LINES | wxBORDER_NONE | wxWANTS_CHARS | + wxTR_FULL_ROW_HIGHLIGHT; + +// The author's hex colour for a filament slot (empty when the slot is out of range); shared by +// the header chip, the inner-tab chip and the DPI rescale paths. +std::string filament_color_hex(const DynamicPrintConfig& full, size_t slot) +{ + if (const auto* colours = full.opt("filament_colour")) + if (slot < colours->size()) + return colours->get_at(slot); + return std::string(); +} + +PublishMaterialIdentity material_identity(size_t slot, const DynamicPrintConfig& full) +{ + PublishMaterialIdentity identity; + if (const auto* types = full.opt("filament_type")) + if (slot < types->size()) + identity.type = types->get_at(slot); + if (const auto* vendors = full.opt("filament_vendor")) + if (slot < vendors->size()) + identity.vendor = vendors->get_at(slot); + if (const auto* ids = full.opt("filament_ids")) + if (slot < ids->size()) + identity.id = ids->get_at(slot); + return identity; +} + +// "Generic PLA @System" -> "Generic PLA"; mirrors the alias derivation in PresetBundle.cpp. +std::string material_display_name(const std::string& preset_name) +{ + const size_t at = preset_name.find_first_of('@'); + if (at == std::string::npos) + return preset_name; + std::string bare = preset_name.substr(0, at); + boost::trim_right(bare); + return bare.empty() ? preset_name : bare; +} + +// Section title for a filament slot: the resolved preset name, then the filament type, then +// the generic "Material". +wxString material_title(size_t slot, const PresetBundle* bundle, const DynamicPrintConfig& full) +{ + if (slot < bundle->filament_presets.size()) { + const Preset* preset = bundle->filaments.find_preset(bundle->filament_presets[slot]); + if (preset != nullptr && !preset->name.empty()) { + // Display the name the sidebar does: the alias (already bare-name + variant-tail + // stripped for root filament presets). + const std::string& display = preset->alias.empty() ? get_preset_bare_name(preset->name) : preset->alias; + return from_u8(material_display_name(display)); + } + } + const PublishMaterialIdentity identity = material_identity(slot, full); + if (!identity.type.empty()) + return from_u8(identity.type); + return _L("Material"); +} + +// The component slots of a mixed filament ("1,2,3"), 1-based, or empty when out of range. +std::vector mixed_slot_components(const DynamicPrintConfig& full, size_t slot) +{ + const auto* comp_opt = full.opt("filament_mixed_components"); + if (comp_opt == nullptr || slot >= comp_opt->size()) + return {}; + return parse_mixed_components(comp_opt->values[slot]); +} + +// Human-readable label of a mixed filament, mirroring the sidebar's mixed filament rows: the +// 1-based component slot numbers with their blend percentages (or a "->" gradient arrow), +// e.g. "1 (60%) + 2 (40%)". Used as the slot's tab/header title in place of a preset name. +wxString mixed_filament_label(const DynamicPrintConfig& full, size_t slot) +{ + const std::vector comps = mixed_slot_components(full, slot); + if (comps.empty()) + return _L("Mixed filament"); + const auto* grad_opt = full.opt("filament_mixed_gradient"); + const bool is_gradient = grad_opt != nullptr && slot < grad_opt->size() && grad_opt->values[slot]; + const auto* ratios_opt = full.opt("filament_mixed_sublayer_ratios"); + wxString label; + for (size_t i = 0; i < comps.size(); ++i) { + if (i > 0) + label += is_gradient ? wxString::FromUTF8(" \u2192 ") : wxString::FromUTF8(" + "); + label += wxString::Format("%u", comps[i]); + if (!is_gradient) { + double ratio = 100.0 / comps.size(); + if (ratios_opt != nullptr && slot < ratios_opt->size()) { + const std::vector rs = parse_mixed_ratios(ratios_opt->values[slot], comps.size()); + if (i < rs.size()) + ratio = rs[i] * 100.0; + } + label += wxString::Format(" (%d%%)", int(ratio + 0.5)); + } + } + return label; +} + +// Blended representative colour of a mixed slot, computed exactly like the sidebar's swatches +// (recompute_mixed_slot_colors): sublayer slots blend by their ratios, gradient slots by their +// two end colours, broken references fall back to grey. +wxColour mixed_filament_blend_color(const DynamicPrintConfig& full, size_t slot) +{ + std::vector colors; + if (const auto* colours = full.opt("filament_colour")) { + colors.reserve(colours->values.size()); + for (const std::string& hex : colours->values) { + const wxColour c(hex); + colors.push_back(c.IsOk() ? c : wxColour(0, 0, 0)); + } + } + while (colors.size() <= slot) + colors.push_back(wxColour("#D9D9D9")); + recompute_mixed_slot_colors(colors, full); + return slot < colors.size() ? colors[slot] : wxColour("#D9D9D9"); +} + +// The slot's own chip as the main GUI renders it: a curve-sampled gradient swatch for a +// gradient mixed slot, the static blended colour otherwise. Returns wxNullBitmap when nothing +// could be rendered (the caller falls back to a plain label). +wxBitmap mixed_filament_chip_bitmap(const DynamicPrintConfig& full, size_t slot, int swatch_sz) +{ + const std::string label = std::to_string(slot + 1); + const auto* grad_opt = full.opt("filament_mixed_gradient"); + const bool is_gradient = grad_opt != nullptr && slot < grad_opt->size() && grad_opt->values[slot]; + wxBitmap* icon = nullptr; + if (is_gradient) { + // The same curve-sampled ramp the sidebar chips use; an empty ramp (broken definition) + // degrades to a plain fade between the slot's two component colours. + const std::vector ramp = mixed_gradient_ramp(full, slot, swatch_sz); + if (!ramp.empty()) { + icon = get_extruder_color_icon(std::vector(), true, label, swatch_sz, swatch_sz, &ramp); + } else { + std::vector hexes; + for (const unsigned int comp : mixed_slot_components(full, slot)) { + std::string hex = filament_color_hex(full, size_t(comp) - 1); + if (hex.empty()) + hex = "#D9D9D9"; + hexes.push_back(std::move(hex)); + } + if (hexes.size() >= 2) + icon = get_extruder_color_icon(std::move(hexes), true, label, swatch_sz, swatch_sz); + } + } + if (icon == nullptr) { + const wxColour blend = mixed_filament_blend_color(full, slot); + const std::string blend_hex = blend.IsOk() ? + std::string(wxString::Format("#%02X%02X%02X", blend.Red(), blend.Green(), blend.Blue()).ToUTF8()) : + std::string("#808080"); + icon = get_extruder_color_icon(blend_hex, label, swatch_sz, swatch_sz); + } + return icon != nullptr ? *icon : wxNullBitmap; +} + +// Tab-strip bitmap for a mixed slot: the mix's own chip, then its component swatches each +// followed by their percent share (or a "->" arrow for gradients), mirroring the main GUI's +// sidebar rows - e.g. "[3 purple]: [1 red] 50% + [2 blue] 50%". The whole composition is one +// bitmap because a TabCtrl item cannot interleave images into its text; the tab's text is +// therefore empty. The bitmap is opaque and bakes tab_bg (the tab strip's background colour): +// its texts are rasterized with plain GDI on that solid ground, like the sidebar rows and the +// color chips. Text on a transparent bitmap has to go through the graphics-context route, and +// on MSW that degrades into jagged ClearType-fallback glyphs with fringe halos - the same +// reason draw_mixed_gradient_plot renders into an opaque buffer (FilamentBitmapUtils.cpp). +wxBitmap mixed_filament_tab_bitmap(const DynamicPrintConfig& full, size_t slot, int swatch_sz, const wxColour& tab_bg) +{ + const std::vector comps = mixed_slot_components(full, slot); + if (comps.empty()) + return wxNullBitmap; + const auto* grad_opt = full.opt("filament_mixed_gradient"); + const bool is_gradient = grad_opt != nullptr && slot < grad_opt->size() && grad_opt->values[slot]; + const auto* ratios_opt = full.opt("filament_mixed_sublayer_ratios"); + std::vector ratio_pct(comps.size(), 100.0 / comps.size()); + if (!is_gradient && ratios_opt != nullptr && slot < ratios_opt->size()) { + const std::vector rs = parse_mixed_ratios(ratios_opt->values[slot], comps.size()); + for (size_t i = 0; i < rs.size() && i < comps.size(); ++i) + ratio_pct[i] = rs[i] * 100.0; + } + + const auto* colours = full.opt("filament_colour"); + const wxString lead_sep = wxString::FromUTF8(":"); + const wxString comp_sep = is_gradient ? wxString::FromUTF8("\u2192") : wxString::FromUTF8("+"); + + // Phase 1 (layout): render every swatch and measure every text piece so the composite + // width is known before drawing; the dummy bitmap keeps GetTextExtent reliable. + struct Piece + { + enum Kind { Swatch, Text } kind{Text}; + wxBitmap bmp; + wxString text; + }; + std::vector pieces; + bool has_lead = false; + wxBitmap dummy(1, 1); + wxMemoryDC measure_dc; + measure_dc.SelectObject(dummy); + measure_dc.SetFont(::Label::Body_12); + const int gap = wxWindow::FromDIP(4, nullptr); + + auto push_swatch = [&](const std::string& hex, const std::string& label) { + wxBitmap* icon = get_extruder_color_icon(hex, label, swatch_sz, swatch_sz); + if (icon == nullptr) + return; + pieces.push_back({Piece::Swatch, *icon, wxString()}); + }; + auto push_text = [&](const wxString& text) { pieces.push_back({Piece::Text, wxNullBitmap, text}); }; + + { + const wxBitmap chip = mixed_filament_chip_bitmap(full, slot, swatch_sz); + has_lead = chip.IsOk(); + if (has_lead) + pieces.push_back({Piece::Swatch, chip, wxString()}); + } + for (size_t ci = 0; ci < comps.size(); ++ci) { + if (pieces.empty()) + break; + push_text(has_lead && pieces.size() == 1 ? lead_sep : comp_sep); // lead chip may have failed to render + std::string hex = "#D9D9D9"; + if (colours != nullptr && comps[ci] >= 1 && comps[ci] <= colours->size()) + hex = colours->values[comps[ci] - 1]; + const size_t before = pieces.size(); + push_swatch(hex, std::to_string(comps[ci])); + if (pieces.size() == before) + break; // swatch failed: stop cleanly before an orphaned separator/percent pair + if (!is_gradient) { + push_text(wxString::Format("%d%%", int(ratio_pct[ci] + 0.5))); + } + } + if (pieces.empty()) + return wxNullBitmap; + + int width = 0; + for (const Piece& p : pieces) + width += (p.kind == Piece::Swatch ? swatch_sz : measure_dc.GetTextExtent(p.text).x + gap); + + // Phase 2 (draw): opaque, filled with the tab strip's background colour. Text goes through + // the plain DC on every platform (see the function comment for why the alpha route is + // avoided); chip bitmaps keep their own alpha and composite cleanly onto the fill. + wxBitmap composite(width, swatch_sz); + wxMemoryDC memdc; + memdc.SelectObject(composite); + memdc.SetBackground(wxBrush(tab_bg)); + memdc.Clear(); + memdc.SetBackgroundMode(wxTRANSPARENT); + memdc.SetFont(::Label::Body_12); + memdc.SetTextForeground(StateColor::darkModeColorFor(wxColour("#262E30"))); + int x = 0; + for (const Piece& p : pieces) { + if (p.kind == Piece::Swatch) { + memdc.DrawBitmap(p.bmp, x, 0, true); + x += swatch_sz; + } else { + const wxSize tsz = measure_dc.GetTextExtent(p.text); + memdc.DrawText(p.text, x + gap / 2, (swatch_sz - tsz.y) / 2); + x += tsz.x + gap; + } + } + memdc.SelectObject(wxNullBitmap); + return composite; +} + +// The filament slots (0-based) the current project actually uses, across every plate: base and +// MMU-painted volume extruders, per-object layer-range and support/wall/infill filament +// overrides, and custom-gcode tool changes. Used mixed slots are preserved as themselves (so an +// in-use mix is publishable) while their physical components are added too - a mix always ships +// with the materials of its components even if a component is not referenced directly anywhere. +std::set project_used_filament_slots(const PresetBundle& bundle, const DynamicPrintConfig& full, const Model& model) +{ + std::set used; // 0-based used slots, mixed slots not yet expanded + if (model.objects.empty()) + return used; + + auto add_1based = [&used](int id) { + if (id > 0) + used.insert(size_t(id - 1)); + }; + + const DynamicPrintConfig& print_cfg = bundle.prints.get_edited_preset().config; + const int glb_support_intf = print_cfg.opt_int("support_interface_filament"); + const int glb_support = print_cfg.opt_int("support_filament"); + const int glb_outer_wall = print_cfg.opt_int("outer_wall_filament_id"); + const int glb_inner_wall = print_cfg.opt_int("inner_wall_filament_id"); + const int glb_sparse_infill = print_cfg.opt_int("sparse_infill_filament_id"); + const int glb_internal_solid = print_cfg.opt_int("internal_solid_filament_id"); + const int glb_top_surface = print_cfg.opt_int("top_surface_filament_id"); + const int glb_bottom_surface = print_cfg.opt_int("bottom_surface_filament_id"); + const bool glb_support_on = print_cfg.opt_bool("enable_support") || print_cfg.opt_int("raft_layers") > 0; + + for (ModelObject* mo : model.objects) { + for (ModelVolume* mv : mo->volumes) + for (int e : mv->get_extruders()) + add_1based(e); + + for (const auto& range_entry : mo->layer_config_ranges) + if (const ConfigOption* ext_opt = range_entry.second.option("extruder")) + add_1based(ext_opt->getInt()); + + bool obj_support = glb_support_on; + if (const ConfigOption* s_opt = mo->config.option("enable_support"); s_opt != nullptr) + obj_support = s_opt->getBool(); + else if (const ConfigOption* r_opt = mo->config.option("raft_layers"); r_opt != nullptr) + obj_support = r_opt->getInt() > 0; + + if (obj_support) { + if (const ConfigOption* opt = mo->config.option("support_interface_filament"); opt != nullptr && opt->getInt() != 0) + add_1based(opt->getInt()); + else + add_1based(glb_support_intf); + + if (const ConfigOption* opt = mo->config.option("support_filament"); opt != nullptr && opt->getInt() != 0) + add_1based(opt->getInt()); + else + add_1based(glb_support); + } + + auto obj_id = [&mo](const char* key) { + const ConfigOption* opt = mo->config.option(key); + return (opt != nullptr) ? opt->getInt() : 0; + }; + + int obj_outer_wall = obj_id("outer_wall_filament_id"); + if (obj_outer_wall == 0) + obj_outer_wall = obj_id("inner_wall_filament_id"); + add_1based(obj_outer_wall != 0 ? obj_outer_wall : glb_outer_wall); + + int obj_inner_wall = obj_id("inner_wall_filament_id"); + if (obj_inner_wall == 0) + obj_inner_wall = obj_id("outer_wall_filament_id"); + add_1based(obj_inner_wall != 0 ? obj_inner_wall : glb_inner_wall); + + const int obj_sparse = obj_id("sparse_infill_filament_id"); + add_1based(obj_sparse != 0 ? obj_sparse : glb_sparse_infill); + + const int obj_internal_solid = obj_id("internal_solid_filament_id"); + add_1based(obj_internal_solid != 0 ? obj_internal_solid : glb_internal_solid); + + int obj_top = obj_id("top_surface_filament_id"); + if (obj_top == 0) + obj_top = obj_internal_solid; + add_1based(obj_top != 0 ? obj_top : glb_top_surface); + + int obj_bottom = obj_id("bottom_surface_filament_id"); + if (obj_bottom == 0) + obj_bottom = obj_internal_solid; + add_1based(obj_bottom != 0 ? obj_bottom : glb_bottom_surface); + } + + for (const auto& plate_entry : model.plates_custom_gcodes) + for (const CustomGCode::Item& item : plate_entry.second.gcodes) + if (item.type == CustomGCode::Type::ToolChange) + add_1based(item.extruder); + + // Expand used mixed slots to their physical components and keep each mixed slot itself so the + // dialog shows the mix. A component both used directly and pulled in via a mix is deduped. + const auto* is_mixed_opt = full.opt("filament_is_mixed"); + const auto* comp_opt = full.opt("filament_mixed_components"); + if (is_mixed_opt != nullptr && comp_opt != nullptr && has_any_mixed_filament(is_mixed_opt->values)) { + const std::vector raw(used.begin(), used.end()); + const std::vector expanded = expand_mixed_filaments(raw, is_mixed_opt->values, comp_opt->values); + std::set result(expanded.begin(), expanded.end()); + for (unsigned int s : raw) + if (s < is_mixed_opt->values.size() && is_mixed_opt->values[s]) + result.insert(s); + return result; + } + + return used; +} + +} // namespace + +// Warning shown on OK when an enabled mixed-filament slot relies on a filament that would ship +// without its material. One row per unmet dependency: the mixed slot's colour chip, the +// component filament's colour chip, and the reason. "Cancel" is the safe choice and keeps the +// dialog open; "Publish anyway" continues. +class MixedFilamentWarningDialog : public MsgDialog +{ +public: + MixedFilamentWarningDialog(wxWindow* parent, const DynamicPrintConfig& full, const std::vector& issues) + : MsgDialog(parent, _L("Warning"), wxEmptyString, wxOK | wxCANCEL | wxICON_WARNING) + { + auto* content = new wxBoxSizer(wxVERTICAL); + + auto* intro = new wxStaticText(this, wxID_ANY, _L("Some mixed filaments rely on filaments that will not be published:")); + intro->SetFont(Label::Body_13); + intro->Wrap(FromDIP(400)); + content->Add(intro, 0, wxEXPAND); + content->AddSpacer(FromDIP(10)); + + const int swatch = FromDIP(20); + for (const MixedDependencyIssue& issue : issues) { + auto* row = new wxBoxSizer(wxHORIZONTAL); + + // The mixed slot as just its own chip (gradient-aware, numbered like its tab); + // falls back to a plain label when the chip cannot be built. + const wxString mix_label = wxString::Format(_L("Filament %d (mixed)"), int(issue.mixed_slot) + 1); + const wxBitmap mix_bmp = mixed_filament_chip_bitmap(full, issue.mixed_slot, swatch); + if (mix_bmp.IsOk()) { + auto* bmp = new wxStaticBitmap(this, wxID_ANY, mix_bmp); + bmp->SetToolTip(mix_label); + row->Add(bmp, 0, wxALIGN_CENTER_VERTICAL); + } else { + auto* label = new wxStaticText(this, wxID_ANY, mix_label); + label->SetFont(Label::Body_12); + row->Add(label, 0, wxALIGN_CENTER_VERTICAL); + } + + auto* needs = new wxStaticText(this, wxID_ANY, _L("needs")); + needs->SetFont(Label::Body_12); + needs->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#6B6B6B"))); + row->Add(needs, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, FromDIP(6)); + + // The component filament's colour chip, numbered like the tab strips; the slot + // name stays on hover to keep the row itself short. + std::string hex = filament_color_hex(full, issue.component_slot); + if (hex.empty()) + hex = "#D9D9D9"; + if (wxBitmap* chip = get_extruder_color_icon(hex, std::to_string(issue.component_slot + 1), swatch, swatch)) { + auto* comp_bmp = new wxStaticBitmap(this, wxID_ANY, *chip); + comp_bmp->SetToolTip(wxString::Format(_L("Filament %d"), int(issue.component_slot) + 1)); + row->Add(comp_bmp, 0, wxALIGN_CENTER_VERTICAL); + } + + auto* reason = new wxStaticText(this, wxID_ANY, + issue.reason == MixedDependencyIssue::Reason::Disabled ? _L("not enabled") : + _L("material not published")); + reason->SetFont(Label::Body_12); + reason->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#989898"))); + row->Add(reason, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); + + content->Add(row, 0, wxLEFT, FromDIP(10)); + content->AddSpacer(FromDIP(6)); + } + + auto* hint = new wxStaticText( + this, wxID_ANY, + _L("To publish a mixed filament, enable every filament it uses and choose Full Publish or check its Type requirement.")); + hint->SetFont(Label::Body_12); + hint->Wrap(FromDIP(380)); + content->Add(hint, 0, wxEXPAND | wxTOP, FromDIP(4)); + + content_sizer->Add(content, 0, wxEXPAND); + + SetButtonLabel(wxID_OK, _L("Publish anyway")); + SetButtonLabel(wxID_CANCEL, _L("Cancel"), true); // safe choice gets the focus + + finalize(); + } +}; + +PublishSettingsDialog::MixedVisualSpec PublishSettingsDialog::make_mixed_visual_spec(const DynamicPrintConfig& full, size_t slot) +{ + MixedVisualSpec spec; + const std::vector comps = mixed_slot_components(full, slot); + if (comps.empty()) + return spec; + + const auto* grad_opt = full.opt("filament_mixed_gradient"); + spec.is_gradient = grad_opt != nullptr && slot < grad_opt->size() && grad_opt->values[slot]; + + const auto* colours = full.opt("filament_colour"); + for (unsigned int cid : comps) { + std::string hex = "#D9D9D9"; + if (colours != nullptr && cid >= 1 && cid <= colours->size()) + hex = colours->values[cid - 1]; + const wxColour c(hex); + spec.component_colours.push_back(c.IsOk() ? c : wxColour("#D9D9D9")); + } + + if (!spec.is_gradient) { + // Sublayer shares; parse_mixed_ratios already falls back to equal shares and + // normalizes to sum 1. + spec.ratios.assign(comps.size(), 1.0 / comps.size()); + if (const auto* ratios_opt = full.opt("filament_mixed_sublayer_ratios")) + if (slot < ratios_opt->size()) { + const std::vector rs = parse_mixed_ratios(ratios_opt->values[slot], comps.size()); + if (rs.size() == comps.size()) + spec.ratios = rs; + } + if (comps.size() == 3) + spec.tri_weights = spec.ratios; // the picker's barycentric shares + } else { + const Slic3r::GradientCurve curve = mixed_gradient_curve(full, slot); + constexpr int kSamples = 256; + for (int i = 0; i <= kSamples; ++i) { + const double t = double(i) / kSamples; + spec.gradient_samples.emplace_back(t, sample_gradient_curve(curve, t)); + } + for (const Slic3r::GradientAnchor& anchor : curve.points) + spec.gradient_anchors.emplace_back(anchor.x, anchor.y); + } + + spec.valid = true; + return spec; +} + +PublishSettingsDialog::PublishSettingsDialog(wxWindow* parent, + const std::vector* published_keys, + const std::vector* material_keys) + : DPIDialog(parent ? parent : static_cast(wxGetApp().mainframe), + wxID_ANY, + _L("Publish 3MF..."), + wxDefaultPosition, + wxDefaultSize, + wxCAPTION | wxCLOSE_BOX | wxRESIZE_BORDER | wxFULL_REPAINT_ON_RESIZE) + , m_search(this, "search", 16) + , m_menu(this, "filter", 16) +{ + SetBackgroundColour(*wxWHITE); + + // --- filter bar: search box, All/None, menu button --- + wxPanel* f_bar = new wxPanel(this, wxID_ANY); + f_bar->SetBackgroundColour(GetBackgroundColour()); + wxBoxSizer* f_sizer = new wxBoxSizer(wxHORIZONTAL); + + m_filter_box = new TextInput(f_bar, "", "", "", wxDefaultPosition, wxDefaultSize, wxTE_PROCESS_ENTER); + m_filter_box->SetIcon(m_search.bmp()); + m_filter_box->SetMinSize(FromDIP(wxSize(200, 24))); + m_filter_box->SetSize(FromDIP(wxSize(-1, 24))); + m_filter_box->SetFocus(); + m_filter_ctrl = m_filter_box->GetTextCtrl(); + m_filter_ctrl->SetFont(Label::Body_13); + m_filter_ctrl->SetSize(wxSize(-1, FromDIP(16))); // centers text vertically + m_filter_ctrl->SetHint(_L("Type to filter...")); + m_filter_ctrl->Bind(wxEVT_TEXT, [this](auto&) { apply_filter(m_filter_ctrl->GetValue()); }); + m_filter_ctrl->Bind(wxEVT_TEXT_ENTER, [this](auto&) { apply_filter(m_filter_ctrl->GetValue()); }); + m_filter_ctrl->Bind(wxEVT_LEFT_DOWN, [this](auto& e) { + if (m_filter_mode != FilterMode::Text) + apply_filter(m_filter_ctrl->GetValue()); + e.Skip(); + }); + f_sizer->Add(m_filter_box, 1, wxEXPAND); + + m_fb_sizer = new wxBoxSizer(wxHORIZONTAL); + auto create_btn = [this, f_bar](wxString title, bool select) { + auto btn = new wxStaticText(f_bar, wxID_ANY, title); + btn->SetForegroundColour("#009687"); + btn->SetCursor(wxCURSOR_HAND); + btn->SetFont(Label::Body_13); + btn->Bind(wxEVT_LEFT_DOWN, [this, select](wxMouseEvent&) { select_all(select); }); + m_fb_sizer->Add(btn, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, FromDIP(10)); + }; + f_sizer->Add(m_fb_sizer, 0, wxALIGN_CENTER_VERTICAL); + create_btn(_L("All"), true); + create_btn(_L("None"), false); + + // Indicator for the menu's pseudo filters: sits where All/None go while they are hidden. + // Labels reuse the menu entries (no new strings); clicking the chip returns to text + // filtering, keeping the search box contents. + m_pseudo_chip = new wxStaticText(f_bar, wxID_ANY, ""); + m_pseudo_chip->SetForegroundColour("#009687"); + m_pseudo_chip->SetCursor(wxCURSOR_HAND); + m_pseudo_chip->SetFont(Label::Body_13); + m_pseudo_chip->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent&) { apply_filter(m_filter_ctrl->GetValue()); }); + m_pseudo_chip->Hide(); + f_sizer->Add(m_pseudo_chip, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, FromDIP(10)); + + m_menu_button = new wxStaticBitmap(f_bar, wxID_ANY, m_menu.bmp()); + m_menu_button->SetCursor(wxCURSOR_HAND); + m_menu_button->Bind(wxEVT_LEFT_DOWN, &PublishSettingsDialog::show_menu, this); + f_sizer->Add(m_menu_button, 0, wxLEFT | wxALIGN_CENTER_VERTICAL, FromDIP(10)); + + f_bar->SetSizerAndFit(f_sizer); + + m_outer_tabs = new TabCtrl(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, s_tab_style); + m_outer_tabs->SetFont(Label::Body_14); + m_outer_tabs->SetBackgroundColour(GetBackgroundColour()); + + m_outer_host = new wxPanel(this, wxID_ANY); + m_outer_host->SetBackgroundColour(GetBackgroundColour()); + m_outer_host_sizer = new wxBoxSizer(wxVERTICAL); + m_outer_host->SetSizer(m_outer_host_sizer); + + wxBoxSizer* w_sizer = new wxBoxSizer(wxVERTICAL); + + wxStaticText* msg = new wxStaticText(this, wxID_ANY, _L("Select which settings to be published in the 3MF file")); + msg->SetFont(Label::Body_13); + msg->Wrap(-1); + w_sizer->Add(msg, 0, wxRIGHT | wxLEFT | wxTOP, FromDIP(10)); + + w_sizer->Add(f_bar, 0, wxRIGHT | wxLEFT | wxTOP | wxEXPAND, FromDIP(10)); + w_sizer->Add(m_outer_tabs, 0, wxRIGHT | wxLEFT | wxTOP | wxEXPAND, FromDIP(10)); + w_sizer->Add(m_outer_host, 1, wxRIGHT | wxLEFT | wxTOP | wxEXPAND, FromDIP(10)); + + build_option_model(); + + // Seed from a remembered session selection or a freshly loaded published 3MF (authoritative). + if (published_keys != nullptr || material_keys != nullptr) + apply_selection(published_keys != nullptr ? *published_keys : std::vector(), + material_keys != nullptr ? *material_keys : std::vector()); + + auto dlg_btns = new DialogButtons(this, {"OK", "Cancel"}); + + dlg_btns->GetOK()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { + // Publish is always allowed: no settings selected means no settings override. Warn only + // when an enabled mixed filament would ship without the material of one of its + // components; "Publish anyway" accepts that and publishes anyway. + if (const std::vector issues = unpublished_mixed_components(); !issues.empty()) { + const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); + MixedFilamentWarningDialog warn(this, full, issues); + if (warn.ShowModal() != wxID_OK) + return; // Cancel: dismiss the warning and stay in this dialog + } + EndModal(wxID_OK); + }); + dlg_btns->GetCANCEL()->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_CANCEL); }); + + // Guide links, bottom-left, sharing the footer row with the OK/Cancel buttons (pushed right). + auto make_link = [this](const wxString& label, const char* url) { + wxStaticText* link = new wxStaticText(this, wxID_ANY, label); + link->SetFont(Label::Body_13); + link->SetForegroundColour(wxColour(0x1F, 0x8E, 0xEA)); + link->SetCursor(wxCURSOR_HAND); + link->Bind(wxEVT_LEFT_DOWN, [url](wxMouseEvent&) { wxLaunchDefaultBrowser(url, wxBROWSER_NEW_WINDOW); }); + return link; + }; + 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, + wxTOP | wxALIGN_LEFT, FromDIP(4)); + + wxBoxSizer* footer = new wxBoxSizer(wxHORIZONTAL); + footer->Add(links_sizer, 0, wxALIGN_CENTER_VERTICAL); + footer->AddStretchSpacer(); + footer->Add(dlg_btns, 0, wxALIGN_CENTER_VERTICAL); + w_sizer->Add(footer, 0, wxRIGHT | wxLEFT | wxBOTTOM | wxEXPAND, FromDIP(10)); + + SetSizerAndFit(w_sizer); + fit_to_content(); // initial size only; the dialog is resizable + wxGetApp().UpdateDlgDarkUI(this); + // The dark-UI walk re-colours the tab strips after the compositions were baked; rebuild so + // they carry the theme-resolved background (the sidebar resolves its colours at paint time, + // this dialog bakes, so the bake has to happen after the walk). + refresh_mixed_tab_bitmaps(); +} + +// Size the window to its content: width follows the widest tab strip so no filament tab is +// hidden (TabCtrl::relayout hides overflowing buttons), height scales proportionally. Both +// are floored at the 600x500 base and capped at hard DIP limits - deliberately not the whole +// display - with one last-resort clamp so the dialog can never open larger than the screen. +// Also owns the resize floor: the window cannot be resized below what the tabs need, so +// shrinking never re-hides a filament tab. +void PublishSettingsDialog::fit_to_content() +{ + static const wxSize BASE{600, 500}; + static const wxSize CAP{1300, 850}; + + int strip = m_outer_tabs->GetFullSize(); + for (const SectionGroup& section : m_sections) { + strip = std::max(strip, section.tabs->GetFullSize()); + if (section.mixed_tabs != nullptr) + strip = std::max(strip, section.mixed_tabs->GetFullSize()); + } + + // Minimum width: whatever keeps every tab visible (never below the base). strip is device + // pixels (Button min sizes); BASE/CAP are DIP and converted over. + const int min_w = std::max(strip + 2 * FromDIP(10), FromDIP(BASE.x)); + + // Initial size: prefer proportional growth within the caps. + const int w = std::clamp(min_w, FromDIP(BASE.x), FromDIP(CAP.x)); + const double f = double(w) / FromDIP(BASE.x); + const int h = std::clamp(int(FromDIP(BASE.y) * f), FromDIP(BASE.y), FromDIP(CAP.y)); + + const wxRect area = wxDisplay(this).GetClientArea(); + const int max_w = area.width * 9 / 10; + const int max_h = area.height * 9 / 10; + + SetMinSize(wxSize(std::min(min_w, max_w), std::min(FromDIP(BASE.y), max_h))); + SetSize(std::min(w, max_w), std::min(h, max_h)); +} + +PublishSettingsDialog::~PublishSettingsDialog() {} + +void PublishSettingsDialog::build_option_model() +{ + // Structural / non-publishable keys, shared with the published-3MF overlay path. + const std::set& denylist = publish_structural_keys(); + // Base keys already added in the print section (dedup across pages/optgroups). The printer + // section keeps its own printer_added set keyed by the full per-extruder "#N" opt_id, so + // every extruder gets its own row (see Phase 1 below). + std::set added; + + PresetBundle* bundle = wxGetApp().preset_bundle; + DynamicPrintConfig full = bundle->full_config(); + + m_info_nonsel = _L("No selected items..."); + m_info_allsel = _L("All items selected..."); + m_info_empty = _L("No matching items..."); + m_info_mix = _L("Mixed filament - published as a whole when \"Enable\" above is selected"); + + // Tab order differs from Section's enum order (Print, Printer, Material): the dialog + // presents Printer, Filament, Process. + m_sections.reserve(3); + const Section tab_order[] = {Section::Printer, Section::Material, Section::Print}; + for (Section kind : tab_order) + section_group_for(kind); + bind_tab_events(); + + // Shared per-option label/value computation; returns false when the option must be skipped + // (denylisted / unknown / empty label). value is the stringified value; unit the translated + // sidetext (may be empty). + auto option_text = [&denylist, &full](const std::string& opt_id, const std::string& pure_key, wxString& label, wxString& value, + wxString& unit) -> bool { + if (denylist.count(pure_key) > 0) + return false; + const ConfigOptionDef* def = print_config_def.get(pure_key); + if (def == nullptr) + return false; + label = _(def->full_label.empty() ? def->label : def->full_label); + if (label.IsEmpty()) + return false; + value = get_string_value(opt_id, full); + unit = _(def->sidetext); + return true; + }; + + // --- Phase 1: printer per-extruder retraction settings (first, mirroring the sidebar's + // Printer group), from the printer tab's "Extruder"/"Extruder N" pages. One inner tab per + // extruder (e.g. "Left Extruder"/"Right Extruder" via Tab::translate_category), each holding + // that extruder's Retraction and Z-Hop rows with per-extruder "#N" values. + { + size_t g = section_group_for(Section::Printer); + std::set printer_added; + for (Tab* tab : wxGetApp().tabs_list) { + if (tab->m_type != Preset::TYPE_PRINTER) + continue; + for (const PageShp& page : tab->m_pages) { + if (!page->title().StartsWith("Extruder")) + continue; + // The extruder index of this page: its options are appended with the same + // "#N" opt_index (opt.second.second), so derive the tab's index from the first + // allowlisted option; skip the page when none is found (defensive). + int extruder_idx = -1; + for (const ConfigOptionsGroupShp& optgroup : page->m_optgroups) { + if (optgroup->title != "Retraction" && optgroup->title != "Z-Hop") + continue; + for (const auto& opt : optgroup->opt_map()) + if (extruder_idx < 0) + extruder_idx = opt.second.second; + if (extruder_idx >= 0) + break; + } + if (extruder_idx < 0) + continue; + const wxString page_title = Tab::translate_category(page->title(), tab->m_type); + for (const ConfigOptionsGroupShp& optgroup : page->m_optgroups) { + // Allowlist on the untranslated optgroup title; the "Retraction when + // switching material" group is intentionally skipped. + if (optgroup->title != "Retraction" && optgroup->title != "Z-Hop") + continue; + const wxString subcategory = _(optgroup->title); + for (const auto& opt : optgroup->opt_map()) { + const std::string& opt_id = opt.first; + const std::string& pure_key = opt.second.first; + // Rows are keyed by the full per-extruder "#N" opt_id so each extruder + // tab publishes its own value; GetPublishedKeys() emits the checked rows + // as-is. + if (!printer_added.insert(opt_id).second) + continue; + wxString label, value, unit; + if (!option_text(opt_id, pure_key, label, value, unit)) + continue; + size_t cat_index = category_index_for(page_title, Section::Printer, g, size_t(extruder_idx)); + size_t sub_index = subcategory_index_for(cat_index, subcategory, optgroup->icon); + add_row_ui(opt_id, label, value, unit, cat_index, sub_index); + } + } + } + } + } + + // --- Phase 2: per-material sections synthesized from the filament tab's "Setting + // Overrides" page, under the Filament group. + { + size_t g = section_group_for(Section::Material); + Tab* filament_tab = nullptr; + for (Tab* tab : wxGetApp().tabs_list) + if (tab->m_type == Preset::TYPE_FILAMENT) { + filament_tab = tab; + break; + } + if (filament_tab != nullptr) { + const Page* overrides_page = nullptr; + for (const PageShp& page : filament_tab->m_pages) + if (page->title() == "Setting Overrides") { + overrides_page = page.get(); + break; + } + + if (overrides_page != nullptr) { + // Mixed-color slots are virtual: they carry no per-key Material/Retraction + // settings; their "Enable" toggle always embeds the mix definition (components, + // ratios, gradient). Detect them via the project-level flag. + const ConfigOptionBools* is_mixed_opt = full.opt("filament_is_mixed"); + // Only the slots the project actually uses: base/MMU-painted volume extruders, + // support/wall/infill overrides and custom-gcode tool changes, plus the physical + // components of any in-use mixed slot. Unused slots get no section at all (and are + // therefore never published). One section per used slot, disambiguated by its + // colour chip and slot identity while showing the bare name. + const std::set used_slots = project_used_filament_slots(*bundle, full, wxGetApp().plater()->model()); + for (size_t slot = 0; slot < bundle->filament_presets.size(); ++slot) { + if (used_slots.find(slot) == used_slots.end()) + continue; + const bool is_mixed = is_mixed_opt != nullptr && slot < is_mixed_opt->size() && is_mixed_opt->values[slot]; + const PublishMaterialIdentity identity = material_identity(slot, full); + // A mixed slot's title is its component composition (e.g. "1 (60%) + 2 + // (40%)"), not the cloned preset's name shown in the main GUI. + const wxString title = is_mixed ? mixed_filament_label(full, slot) : material_title(slot, bundle, full); + const size_t category_index = category_index_for(title, Section::Material, g, slot, identity, is_mixed); + + if (is_mixed) { + // A mixed slot publishes as one unit (its definition); nothing to select + // per-key. Its component filaments are auto-enabled + Full Published when + // "Enable" is checked (see on_enable_toggle). Its page still shows what + // would be published: a ratio bar, or the gradient graph. + add_mixed_visual(category_index, make_mixed_visual_spec(full, slot)); + continue; + } + + // Material requirement rows: an optional filament colour and/or a + // vendor-agnostic material type for this slot, in their own optgroup so they + // stay visually separated from the setting rows. + { + const size_t req_sub = subcategory_index_for(category_index, _L("Material"), "custom-gcode_filament"); + add_row_ui("filament_colour", _L("Color"), from_u8(filament_color_hex(full, slot)), wxString(), category_index, + req_sub, RowKind::Color); + std::string type; + if (const auto* types = full.opt("filament_type")) + if (slot < types->size()) + type = types->get_at(slot); + add_row_ui("filament_type", _L("Type"), from_u8(normalize_filament_type(type)), wxString(), category_index, req_sub, + RowKind::Type); + } + + // A material section must not repeat a key; the same key may appear in other + // material sections - that is intended. + std::set material_added; + + for (const ConfigOptionsGroupShp& optgroup : overrides_page->m_optgroups) { + // Allowlist on the untranslated optgroup title; "Ironing" is skipped. + if (optgroup->title != "Retraction" && optgroup->title != "Retraction when switching material") + continue; + for (const auto& opt : optgroup->opt_map()) { + // Row keys are base keys; the load side applies them positionally. + const std::string& opt_id = opt.first; + std::string base = publish_base_key(opt_id); + if (!material_added.insert(base).second) + continue; + // Show the value of this slot; fall back to slot 0 if out of range. + std::string value_opt_id = base + "#" + std::to_string(slot); + if (const ConfigOption* opt_cfg = full.option(base)) + if (const auto* vec = dynamic_cast(opt_cfg)) + if (vec->size() > 0 && slot >= vec->size()) + value_opt_id = base + "#0"; + wxString label, value, unit; + if (!option_text(value_opt_id, base, label, value, unit)) + continue; + size_t sub_index = subcategory_index_for(category_index, _(optgroup->title), optgroup->icon); + add_row_ui(base, label, value, unit, category_index, sub_index); + } + } + } + } + } + } + + // --- Phase 3: process (print) settings; grouping matches the process tab. + { + size_t g = section_group_for(Section::Print); + for (Tab* tab : wxGetApp().tabs_list) { + if (tab->m_type != Preset::TYPE_PRINT) + continue; + size_t page_index = 0; + for (const PageShp& page : tab->m_pages) { + wxString category = Tab::translate_category(page->title(), tab->m_type); + const size_t category_index = category_index_for(category, Section::Print, g, page_index); + + for (const ConfigOptionsGroupShp& optgroup : page->m_optgroups) { + for (const auto& opt : optgroup->opt_map()) { + // opt_map() key is the opt_id (may carry "#N"); the value is + // (pure_opt_key, opt_index). + const std::string& opt_id = opt.first; + const std::string& pure_key = opt.second.first; + // A key may appear in more than one page/group; keep the first. + if (!added.insert(pure_key).second) + continue; + wxString label, value, unit; + if (!option_text(opt_id, pure_key, label, value, unit)) + continue; + size_t sub_index = subcategory_index_for(category_index, _(optgroup->title), optgroup->icon); + add_row_ui(opt_id, label, value, unit, category_index, sub_index); + } + } + ++page_index; + } + } + } + + // Pre-check the dirty (modified) settings and mark them bold (per-slot match, across all + // sections; collect_dirty_settings_keys unions the prints, printers and filaments). + // Dirty keys carry a "#N" per-extruder/per-slot suffix (deep_diff), so the base key alone + // cannot distinguish which extruder/filament slot changed: match the row's exact key, and + // for material rows (base key + per-slot value) the base key plus the section's slot. + std::set dirty_keys; + for (const std::string& key : collect_dirty_settings_keys(*wxGetApp().preset_bundle)) + dirty_keys.insert(key); + for (Row& row : m_rows) { + // The Color/Type requirement rows are not "dirty overrides": never auto-checked. + if (row.kind != RowKind::Setting) + continue; + bool dirty = dirty_keys.count(row.key) != 0; + if (!dirty && row.section == Section::Material) + dirty = dirty_keys.count(publish_base_key(row.key) + "#" + std::to_string(m_categories[row.inner_index].filament_slot)) != 0; + row.dirty = dirty; + if (row.dirty) { + row.check->SetValue(true); + set_row_bold(row, true); + } + } + + // Wire the "Enable" checkboxes: toggling one reveals/hides the slot's settings below the + // header (and, for a mixed slot, auto-selects its components). Bind by index so the lambda + // stays valid even if the vector is reallocated later. + for (size_t c = 0; c < m_categories.size(); ++c) + if (m_categories[c].enable_check != nullptr) + m_categories[c].enable_check->Bind(wxEVT_CHECKBOX, [this, c](wxCommandEvent&) { on_enable_toggle(c); }); + + // Wire the "Full Publish" checkboxes (physical slots): toggling one disables/enables the + // material's rows. + for (size_t c = 0; c < m_categories.size(); ++c) + if (m_categories[c].full_check != nullptr) + m_categories[c].full_check->Bind(wxEVT_CHECKBOX, [this, c](wxCommandEvent&) { on_full_toggle(c); }); + + // No filter is active at startup: every row matches until the user types. + for (Row& row : m_rows) + row.matches_filter = true; + apply_visibility(); + + for (Category& category : m_categories) { + category.scroll->FitInside(); + category.list_sizer->Layout(); + } + for (SectionGroup& section : m_sections) + if (!section.categories.empty()) + section.tabs->SelectItem(0); + if (!m_sections.empty()) { + m_outer_tabs->SelectItem(0); + show_outer_page(0); + } + refresh_tab_indicators(); +} + +size_t PublishSettingsDialog::section_group_for(Section kind) +{ + for (size_t i = 0; i < m_sections.size(); ++i) + if (m_sections[i].kind == kind) + return i; + + SectionGroup section; + section.kind = kind; + const size_t new_index = m_sections.size(); + switch (kind) { + case Section::Printer: + section.title = _L("Printer"); + section.icon_name = "printer"; + break; + case Section::Material: + section.title = _L("Filament"); + section.icon_name = "filament"; + break; + case Section::Print: + section.title = _L("Process"); + section.icon_name = "process"; + break; + } + section.icon_bmp = ScalableBitmap(this, section.icon_name, 16); + + section.page = new wxPanel(m_outer_host, wxID_ANY); + section.page->SetBackgroundColour(GetBackgroundColour()); + auto* page_sizer = new wxBoxSizer(wxVERTICAL); + section.tabs = new TabCtrl(section.page, wxID_ANY, wxDefaultPosition, wxDefaultSize, s_tab_style); + section.tabs->SetFont(Label::Body_14); + section.tabs->SetBackgroundColour(GetBackgroundColour()); + page_sizer->Add(section.tabs, 0, wxEXPAND); + // Mixed-color filament slots get a second tab strip below the physical filament tabs; only + // the Material section has them. + if (kind == Section::Material) { + section.mixed_tabs = new TabCtrl(section.page, wxID_ANY, wxDefaultPosition, wxDefaultSize, s_tab_style); + section.mixed_tabs->SetFont(Label::Body_14); + section.mixed_tabs->SetBackgroundColour(GetBackgroundColour()); + // The mixed tabs carry full swatch compositions: give them a touch more room than the + // filament tabs so neighbouring compositions stay distinguishable (must precede AppendItem). + section.mixed_tabs->SetItemSpace(FromDIP(3)); + page_sizer->Add(section.mixed_tabs, 0, wxEXPAND | wxTOP, FromDIP(2)); + section.mixed_tabs->Hide(); + } + section.page_host = new wxPanel(section.page, wxID_ANY); + section.page_host->SetBackgroundColour(GetBackgroundColour()); + section.page_host_sizer = new wxBoxSizer(wxVERTICAL); + section.page_host->SetSizer(section.page_host_sizer); + page_sizer->Add(section.page_host, 1, wxEXPAND | wxTOP, FromDIP(4)); + section.page->SetSizer(page_sizer); + + if (section.icon_bmp.bmp().IsOk()) + m_outer_tabs->AppendItem(section.title, section.icon_bmp.bmp()); + else + m_outer_tabs->AppendItem(section.title); + m_outer_host_sizer->Add(section.page, 1, wxEXPAND); + section.page->Hide(); + m_sections.push_back(std::move(section)); + return new_index; +} + +size_t PublishSettingsDialog::category_index_for( + const wxString& title, Section section, size_t group, size_t source_index, const PublishMaterialIdentity& identity, bool is_mixed) +{ + // Dedup across both tab rows (physical + mixed); mixed slots are never duplicated anyway. + auto match = [&](const Category& existing) { + return existing.title == title && existing.section == section && existing.source_index == source_index && + existing.filament_id == identity.id && existing.filament_type == identity.type && + existing.filament_vendor == identity.vendor; + }; + for (size_t i : m_sections[group].categories) + if (match(m_categories[i])) + return i; + for (size_t i : m_sections[group].mixed_categories) + if (match(m_categories[i])) + return i; + + Category category; + category.title = title; + category.section = section; + category.group = group; + category.source_index = source_index; + category.filament_type = identity.type; + category.filament_vendor = identity.vendor; + category.filament_id = identity.id; + category.filament_slot = source_index; + category.is_mixed = is_mixed; + category.page = new wxPanel(m_sections[group].page_host, wxID_ANY); + category.page->SetBackgroundColour(GetBackgroundColour()); + auto* page_sizer = new wxBoxSizer(wxVERTICAL); + + // The physical slot's colour chip decorates the page header and the inner tab; it carries + // the 1-based slot number, mirroring the main GUI's filament swatches. A mixed slot has no + // header at all: its page is just the Enable toggle above the (always visible) definition + // preview - the identification lives in the tab strip's composition bitmap. + std::string hex; + std::string chip_label; + if (section == Section::Material && !is_mixed) { + const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); + hex = filament_color_hex(full, source_index); + chip_label = std::to_string(source_index + 1); + } + + if (section == Section::Material) { + if (is_mixed) { + // No chip/title: the lone "Enable" checkbox tops the page. + auto* enable_sizer = new wxBoxSizer(wxHORIZONTAL); + category.enable_check = new wxCheckBox(category.page, wxID_ANY, _L("Enable")); + category.enable_check->SetFont(Label::Body_13); + category.enable_check->SetToolTip(_L("Publish this mixed filament and enable + Full Publish its component filaments")); + enable_sizer->Add(category.enable_check, 0, wxALIGN_CENTER_VERTICAL); + page_sizer->Add(enable_sizer, 0, wxEXPAND | wxTOP | wxLEFT | wxRIGHT, FromDIP(6)); + } else { + // Line 1: [chip] [title] [Enable]. The Enable checkbox gates the whole slot: while + // it is unchecked nothing below the title is shown and nothing of it is published. + auto* header_sizer = new wxBoxSizer(wxHORIZONTAL); + if (wxBitmap* chip = get_extruder_color_icon(hex, chip_label, FromDIP(20), FromDIP(20))) { + category.filament_color_chip = new wxStaticBitmap(category.page, wxID_ANY, *chip); + header_sizer->Add(category.filament_color_chip, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); + } + category.title_label = new wxStaticText(category.page, wxID_ANY, title); + category.title_label->SetFont(Label::Head_14); + header_sizer->Add(category.title_label, 0, wxALIGN_CENTER_VERTICAL); + category.enable_check = new wxCheckBox(category.page, wxID_ANY, _L("Enable")); + category.enable_check->SetFont(Label::Body_13); + category.enable_check->SetToolTip(_L("Publish this filament slot in the 3MF file")); + header_sizer->Add(category.enable_check, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(10)); + page_sizer->Add(header_sizer, 0, wxEXPAND | wxTOP | wxLEFT | wxRIGHT, FromDIP(6)); + + // Line 2: the "Full Publish" toggle, on its own line below the title (hidden until + // the slot is enabled), aligned with the colour chip above it. + auto* full_sizer = new wxBoxSizer(wxHORIZONTAL); + category.full_check = new wxCheckBox(category.page, wxID_ANY, _L("Full Publish")); + category.full_check->SetFont(Label::Body_13); + category.full_check->SetToolTip(_L("Embed the entire filament of this slot in the 3MF file")); + full_sizer->Add(category.full_check, 0, wxALIGN_CENTER_VERTICAL); + category.full_line_item = page_sizer->Add(full_sizer, 0, wxEXPAND | wxTOP | wxLEFT | wxRIGHT, FromDIP(6)); + } + } + + category.scroll = new wxScrolledWindow(category.page, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxVSCROLL); + category.scroll->SetScrollRate(0, 10); + category.scroll->SetBackgroundColour(GetBackgroundColour()); + category.list_sizer = new wxBoxSizer(wxVERTICAL); + category.scroll->SetSizer(category.list_sizer); + category.scroll->DisableFocusFromKeyboard(); + category.scroll->Bind(wxEVT_RIGHT_DOWN, &PublishSettingsDialog::show_menu, this); + category.info = new wxStaticText(category.scroll, wxID_ANY, is_mixed ? m_info_mix : m_info_empty); + category.info->SetFont(Label::Body_13); + category.list_sizer->Add(category.info, 1, wxALIGN_CENTER_HORIZONTAL | wxALL, FromDIP(10)); + category.info->Hide(); + page_sizer->Add(category.scroll, 1, wxEXPAND | wxALL, FromDIP(4)); + // A material slot starts disabled: its rows (and its Full Publish line) stay hidden until + // "Enable" is checked. + if (section == Section::Material) + category.scroll->Hide(); + category.page->SetSizer(page_sizer); + category.page->Hide(); + + const size_t category_index = m_categories.size(); + m_categories.push_back(std::move(category)); + // Mixed slots live in a second tab row below the physical filament tabs. + if (is_mixed) + m_sections[group].mixed_categories.push_back(category_index); + else + m_sections[group].categories.push_back(category_index); + if (section == Section::Material) { + TabCtrl* target = is_mixed ? m_sections[group].mixed_tabs : m_sections[group].tabs; + if (is_mixed) { + // The tab's whole composition (mix chip + components + percents) lives in one + // bitmap; the text is empty because a TabCtrl item cannot interleave images into + // its text. + const DynamicPrintConfig full_cfg = wxGetApp().preset_bundle->full_config(); + const wxBitmap tab_bmp = mixed_filament_tab_bitmap(full_cfg, source_index, FromDIP(20), target->GetBackgroundColour()); + if (tab_bmp.IsOk()) + target->AppendItem(wxString(), tab_bmp); + else + target->AppendItem(title); + } else if (wxBitmap* chip = get_extruder_color_icon(hex, chip_label, FromDIP(20), FromDIP(20))) { + target->AppendItem(title, *chip); + } else { + target->AppendItem(title); + } + } else { + m_sections[group].tabs->AppendItem(title); + } + m_sections[group].page_host_sizer->Add(m_categories[category_index].page, 1, wxEXPAND); + if (is_mixed) { + if (m_sections[group].selected_mixed < 0) + m_sections[group].selected_mixed = 0; + m_sections[group].mixed_tabs->Show(); + } else if (m_sections[group].selected_inner < 0) { + m_sections[group].selected_inner = 0; + } + return category_index; +} + +size_t PublishSettingsDialog::subcategory_index_for(size_t category_index, const wxString& title, const wxString& icon) +{ + Category& category = m_categories[category_index]; + for (size_t i = 0; i < category.subs.size(); ++i) + if (category.subs[i].title == title) + return i; + + Subcategory sub; + sub.title = title; + if (!title.IsEmpty()) { + sub.header = new ::StaticLine(category.scroll, false, title, icon); + sub.header->SetFont(Label::Head_14); + sub.header->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#363636"))); + auto* wrap = new wxBoxSizer(wxVERTICAL); + wrap->Add(sub.header, 0, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(6)); + sub.item = category.list_sizer->Add(wrap, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(22)); + } + category.subs.push_back(std::move(sub)); + return category.subs.size() - 1; +} + +void PublishSettingsDialog::add_row_ui(const std::string& key, + const wxString& label, + const wxString& value, + const wxString& unit, + size_t category_index, + size_t subcategory_index, + RowKind kind) +{ + Category& category = m_categories[category_index]; + Row row; + row.key = key; + row.label = label; + row.value = value; + row.unit = unit; + row.kind = kind; + row.category = category.title; + row.subcategory = category.subs[subcategory_index].title; + row.section = category.section; + row.section_title = m_sections[category.group].title; + row.outer_index = category.group; + row.inner_index = category_index; + const size_t row_index = m_rows.size(); + m_rows.push_back(std::move(row)); + Row& current = m_rows[row_index]; + current.check = new wxCheckBox(category.scroll, wxID_ANY, label); + current.check->SetFont(Label::Body_13); + current.check->Bind(wxEVT_CHECKBOX, [this](wxCommandEvent&) { refresh_tab_indicators(); }); + auto* row_sizer = new wxBoxSizer(wxHORIZONTAL); + row_sizer->Add(current.check, 0, wxALIGN_CENTER_VERTICAL); + // The value is read-only text (incl. the Type row: the published type is the slot's + // normalized type, not author-editable). + current.value_label = new wxStaticText(category.scroll, wxID_ANY, value, wxDefaultPosition, wxDefaultSize, wxST_ELLIPSIZE_END); + current.value_label->SetFont(Label::Body_13); + current.value_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#262E30"))); + current.value_label->SetToolTip(unit.IsEmpty() ? value : value + " " + unit); + if (kind == RowKind::Color && !value.IsEmpty()) { + // The colour swatch carries this slot's 1-based number, like the main GUI swatches. + const std::string chip_label = std::to_string(category.filament_slot + 1); + if (wxBitmap* chip = get_extruder_color_icon(value.ToStdString(), chip_label, FromDIP(20), FromDIP(20))) { + current.color_chip = new wxStaticBitmap(category.scroll, wxID_ANY, *chip); + row_sizer->Add(current.color_chip, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); + } + } + row_sizer->Add(current.value_label, 1, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); + if (!unit.IsEmpty()) { + current.unit_label = new wxStaticText(category.scroll, wxID_ANY, unit); + current.unit_label->SetFont(Label::Body_13); + current.unit_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#6B6B6B"))); + row_sizer->Add(current.unit_label, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(4)); + } + current.item = category.list_sizer->Add(row_sizer, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(38)); + category.rows.push_back(row_index); + category.subs[subcategory_index].rows.push_back(row_index); +} + +void PublishSettingsDialog::on_full_toggle(size_t category_index) +{ + Category& cat = m_categories[category_index]; + const bool full = cat.full_check->GetValue(); + for (size_t r : cat.rows) + m_rows[r].check->Enable(!full); + refresh_tab_indicators(); +} + +void PublishSettingsDialog::on_enable_toggle(size_t category_index) +{ + Category& cat = m_categories[category_index]; + const bool enabled = cat.enable_check->GetValue(); + + // A published mixed filament needs its component filaments published too: mark the slots it + // uses as "Enable"d and Full Published so the mix's physical components always ship their + // identities. + if (cat.is_mixed && enabled) { + const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); + const std::vector components = mixed_slot_components(full, cat.filament_slot); + for (const unsigned int component : components) { + // Components are 1-based physical filament indices. + const size_t component_slot = size_t(component) - 1; + for (size_t c = 0; c < m_categories.size(); ++c) { + Category& comp_cat = m_categories[c]; + if (comp_cat.section != Section::Material || comp_cat.is_mixed || comp_cat.filament_slot != component_slot) + continue; + if (comp_cat.enable_check != nullptr) + comp_cat.enable_check->SetValue(true); + if (comp_cat.full_check != nullptr) + comp_cat.full_check->SetValue(true); + on_full_toggle(c); + } + } + } + + // Reveal/hide everything below the slot's header (rows, info, Full Publish line) and refresh + // the visibility of the auto-selected component slots. + apply_visibility(); + if (cat.page != nullptr) + cat.page->GetSizer()->Layout(); + refresh_tab_indicators(); +} + +void PublishSettingsDialog::add_mixed_visual(size_t category_index, const MixedVisualSpec& spec) +{ + if (category_index >= m_categories.size() || !spec.valid) + return; + Category& category = m_categories[category_index]; + if (category.page == nullptr || category.page->GetSizer() == nullptr || category.scroll == nullptr || spec.component_colours.empty()) + return; + + auto* viz = new wxPanel(category.page, wxID_ANY); + viz->SetBackgroundStyle(wxBG_STYLE_PAINT); + // Theme colours and DIP metrics are resolved inside the paint handler so dark-mode toggles + // and DPI changes are picked up on the next repaint without any explicit listener. The + // ternary fill cache lives inside the shared triangle painter (FilamentBitmapUtils). + viz->Bind(wxEVT_PAINT, [this, panel = viz, spec](wxPaintEvent&) { + const wxColour bg = StateColor::darkModeColorFor(*wxWHITE); + wxBufferedPaintDC pdc(panel); + pdc.SetBackground(wxBrush(bg)); + pdc.Clear(); + const wxRect rc = panel->GetClientRect(); + if (rc.width <= 0 || rc.height <= 0) + return; + + const size_t n = spec.component_colours.size(); + + if (spec.tri_weights.size() == 3 && n == 3 && !spec.is_gradient) { + // Ternary mix: read-only miniature of the MixedFilamentDialog's triangle picker. + const MixedTriangleTheme tri_theme{StateColor::darkModeColorFor(*wxWHITE), StateColor::darkModeColorFor(wxColour("#CECECE")), + StateColor::darkModeColorFor(wxColour("#262E30")), + StateColor::darkModeColorFor(wxColour(107, 107, 107))}; + draw_mixed_triangle_picker(pdc, rc.GetSize(), {spec.component_colours[0], spec.component_colours[1], spec.component_colours[2]}, + {spec.tri_weights[0], spec.tri_weights[1], spec.tri_weights[2]}, tri_theme); + draw_mixed_triangle_labels(pdc, rc.GetSize(), {spec.tri_weights[0], spec.tri_weights[1], spec.tri_weights[2]}, tri_theme); + } else if (!spec.is_gradient) { + // Ratio bar. A 2-component slot matches the MixedFilamentDialog's continuous blend + + // divider (with its two end labels); wider non-gradient mixes (rare) fall back to one + // solid segment per component. + if (n == 2) { + const int bar_h = FromDIP(27); + draw_mixed_ratio_blend_bar(pdc, wxRect(rc.x, rc.y, rc.width, bar_h), spec.component_colours[0], spec.component_colours[1], + spec.ratios[1]); + // Read-only twin of the dialog's left/right percentage labels. + pdc.SetFont(::Label::Body_12); + pdc.SetTextForeground(StateColor::darkModeColorFor(wxColour(107, 107, 107))); + const wxString la = wxString::Format("%d%%", int(std::lround(spec.ratios[0] * 100.0))); + const wxString lb = wxString::Format("%d%%", int(std::lround(spec.ratios[1] * 100.0))); + const int lab_y = rc.y + bar_h + FromDIP(2); + pdc.DrawText(la, rc.x, lab_y); + pdc.DrawText(lb, rc.x + rc.width - pdc.GetTextExtent(lb).GetWidth(), lab_y); + } else { + draw_mixed_ratio_segments(pdc, rc, spec.component_colours, spec.ratios); + // Percent label centred in each segment wide enough to hold it. + std::vector shares = spec.ratios; + double total = 0.0; + for (double r : shares) + total += r; + if (total <= 0.0) { + shares.assign(n, 1.0 / n); + total = 1.0; + } + pdc.SetFont(::Label::Body_12); + int x0 = rc.x; + for (size_t i = 0; i < n; ++i) { + const int x1 = (i + 1 < n) ? rc.x + int(std::lround(std::accumulate(shares.begin(), shares.begin() + i + 1, 0.0) / + total * double(rc.width))) : + rc.x + rc.width; + const int w = std::max(1, x1 - x0); + const wxString text = wxString::Format("%d%%", int(std::lround(shares[i] / total * 100.0))); + const wxSize tsz = pdc.GetTextExtent(text); + if (tsz.GetWidth() + FromDIP(4) <= w) { + const wxColour& c = spec.component_colours[i]; + const double lum = 0.299 * c.Red() + 0.587 * c.Green() + 0.114 * c.Blue(); + pdc.SetTextForeground(lum > 140 ? wxColour("#262E30") : *wxWHITE); + pdc.DrawText(text, x0 + (w - tsz.GetWidth()) / 2, rc.y + (rc.height - tsz.GetHeight()) / 2); + } + x0 = x1; + } + } + } else { + // Gradient: read-only miniature of the GradientCurveEditor plot, drawn through the + // shared painter so it anti-aliases and keeps the square proportions and labels. + const MixedGradientTheme grad_theme{StateColor::darkModeColorFor(*wxWHITE), + StateColor::darkModeColorFor(wxColour(238, 238, 238)), + StateColor::darkModeColorFor(wxColour(107, 107, 107)), + StateColor::darkModeColorFor(wxColour(107, 107, 107)), + StateColor::darkModeColorFor(wxColour(38, 46, 48)), + StateColor::darkModeColorFor(wxColour(172, 172, 172)), + StateColor::darkModeColorFor(*wxWHITE)}; + std::vector curves; + std::vector anchors; + if (spec.gradient_samples.size() >= 2 && n >= 2) { + const wxRect plot = mixed_gradient_plot_rect(rc.GetSize()); + auto lift_alpha = [](wxColour c) { + if (c.Alpha() == 0) + c.Set(c.Red(), c.Green(), c.Blue(), 150); + return c; + }; + // First component's ratio solid, its mirror twin for the other. + const wxColour col_a = lift_alpha(spec.component_colours[0]); + const wxColour col_b = lift_alpha(spec.component_colours[1]); + std::vector pts_a, pts_b; + pts_a.reserve(spec.gradient_samples.size()); + pts_b.reserve(spec.gradient_samples.size()); + for (const auto& [t, r] : spec.gradient_samples) { + pts_a.push_back({plot.x + t * plot.width, plot.y + (1.0 - r) * plot.height}); + pts_b.push_back({plot.x + t * plot.width, plot.y + r * plot.height}); + } + // Control-point anchors of the stored curve on the first component's line. + anchors.reserve(spec.gradient_anchors.size()); + for (const auto& [t, r] : spec.gradient_anchors) + anchors.push_back({plot.x + t * plot.width, plot.y + (1.0 - r) * plot.height}); + curves.push_back({std::move(pts_a), col_a, 4}); + curves.push_back({std::move(pts_b), col_b, 2}); + } + draw_mixed_gradient_plot(pdc, rc.GetSize(), curves, anchors, grad_theme); + } + }); + + // Fixed DIP size, left-aligned: the visualization keeps its proportions no matter how the + // dialog is resized (the paint handler draws into whatever client rect the panel ends up + // with, so nothing else has to change). Sizes mirror the MixedFilamentDialog controls: the + // gradient plot and triangle picker match the editor's 260x200 / 160x160, the ratio bar is + // the dialog's 27px bar plus its label line. + int viz_h; + int viz_w; + if (spec.is_gradient) { + viz_w = 260; + viz_h = 200; + } else if (spec.tri_weights.size() == 3 && spec.component_colours.size() == 3) { + viz_w = 160; + viz_h = 160; + } else { + viz_w = 240; + viz_h = spec.component_colours.size() == 2 ? 50 : 30; + } + const wxSize viz_sz(FromDIP(viz_w), FromDIP(viz_h)); + viz->SetMinSize(viz_sz); + viz->SetMaxSize(viz_sz); + // Parented to the page right above the scroll area, so it is always shown with the tab: + // the "Enable" toggle keeps gating only the rows/info below, never this preview. + wxSizer* page_sizer = category.page->GetSizer(); + int scroll_idx = -1; + for (size_t i = 0; i < page_sizer->GetChildren().size(); ++i) + if (page_sizer->GetChildren()[i]->GetWindow() == category.scroll) { + scroll_idx = int(i); + break; + } + if (scroll_idx < 0) + page_sizer->Add(viz, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(10)); // defensive: no scroll item + else + page_sizer->Insert(scroll_idx, viz, 0, wxLEFT | wxRIGHT | wxTOP, FromDIP(10)); +} + +void PublishSettingsDialog::set_row_bold(Row& row, bool bold) +{ + // Rebase on the dialog's body font so clearing bold restores the exact original font. + row.check->SetFont(bold ? Label::Body_13.Bold() : Label::Body_13); +} + +void PublishSettingsDialog::save_scroll_position(Category& category) +{ + if (category.scroll != nullptr) + category.scroll->GetViewStart(&category.scroll_pos.x, &category.scroll_pos.y); +} + +void PublishSettingsDialog::show_outer_page(size_t section_index) +{ + if (section_index >= m_sections.size()) + return; + if (m_selected_outer >= 0 && m_selected_outer < static_cast(m_sections.size())) { + SectionGroup& old_section = m_sections[m_selected_outer]; + if (old_section.selected_inner >= 0 && old_section.selected_inner < static_cast(old_section.categories.size())) + save_scroll_position(m_categories[old_section.categories[old_section.selected_inner]]); + if (old_section.selected_mixed >= 0 && old_section.selected_mixed < static_cast(old_section.mixed_categories.size())) + save_scroll_position(m_categories[old_section.mixed_categories[old_section.selected_mixed]]); + m_sections[m_selected_outer].page->Hide(); + } + m_selected_outer = static_cast(section_index); + SectionGroup& section = m_sections[section_index]; + section.page->Show(); + // Restore whichever tab row (physical or mixed) was active. + if (section.selected_inner >= 0) + show_inner_page(section_index, section.selected_inner); + else if (section.selected_mixed >= 0) + show_mixed_page(section_index, section.selected_mixed); + m_outer_host_sizer->Layout(); +} + +void PublishSettingsDialog::show_inner_page(size_t section_index, int inner_index) +{ + if (section_index >= m_sections.size()) + return; + SectionGroup& section = m_sections[section_index]; + if (inner_index < 0 || inner_index >= static_cast(section.categories.size())) + return; + if (section.selected_inner >= 0 && section.selected_inner < static_cast(section.categories.size())) { + save_scroll_position(m_categories[section.categories[section.selected_inner]]); + m_categories[section.categories[section.selected_inner]].page->Hide(); + } + if (section.selected_mixed >= 0 && section.selected_mixed < static_cast(section.mixed_categories.size())) { + save_scroll_position(m_categories[section.mixed_categories[section.selected_mixed]]); + m_categories[section.mixed_categories[section.selected_mixed]].page->Hide(); + section.selected_mixed = -1; + } + section.selected_inner = inner_index; + Category& category = m_categories[section.categories[inner_index]]; + category.page->Show(); + category.scroll->FitInside(); + category.scroll->Scroll(category.scroll_pos.x, category.scroll_pos.y); + if (section.mixed_tabs != nullptr) + section.mixed_tabs->Unselect(); + section.page_host_sizer->Layout(); +} + +void PublishSettingsDialog::show_mixed_page(size_t section_index, int mixed_index) +{ + if (section_index >= m_sections.size()) + return; + SectionGroup& section = m_sections[section_index]; + if (mixed_index < 0 || mixed_index >= static_cast(section.mixed_categories.size())) + return; + if (section.selected_mixed >= 0 && section.selected_mixed < static_cast(section.mixed_categories.size())) { + save_scroll_position(m_categories[section.mixed_categories[section.selected_mixed]]); + m_categories[section.mixed_categories[section.selected_mixed]].page->Hide(); + } + if (section.selected_inner >= 0 && section.selected_inner < static_cast(section.categories.size())) { + save_scroll_position(m_categories[section.categories[section.selected_inner]]); + m_categories[section.categories[section.selected_inner]].page->Hide(); + section.selected_inner = -1; + } + section.selected_mixed = mixed_index; + Category& category = m_categories[section.mixed_categories[mixed_index]]; + category.page->Show(); + category.scroll->FitInside(); + category.scroll->Scroll(category.scroll_pos.x, category.scroll_pos.y); + section.tabs->Unselect(); + section.page_host_sizer->Layout(); +} + +void PublishSettingsDialog::on_outer_tab_changed(wxCommandEvent& event) +{ + const int selection = event.GetInt(); + if (selection >= 0 && selection < static_cast(m_sections.size())) + show_outer_page(static_cast(selection)); +} + +void PublishSettingsDialog::on_inner_tab_changed(size_t section_index, wxCommandEvent& event) +{ + const int selection = event.GetInt(); + if (section_index < m_sections.size() && selection >= 0 && selection < static_cast(m_sections[section_index].categories.size())) + show_inner_page(section_index, selection); +} + +void PublishSettingsDialog::on_mixed_tab_changed(size_t section_index, wxCommandEvent& event) +{ + const int selection = event.GetInt(); + if (section_index < m_sections.size() && selection >= 0 && + selection < static_cast(m_sections[section_index].mixed_categories.size())) + show_mixed_page(section_index, selection); +} + +void PublishSettingsDialog::bind_tab_events() +{ + m_outer_tabs->Bind(wxEVT_TAB_SEL_CHANGED, &PublishSettingsDialog::on_outer_tab_changed, this); + for (size_t section_index = 0; section_index < m_sections.size(); ++section_index) { + m_sections[section_index].tabs->Bind(wxEVT_TAB_SEL_CHANGED, + [this, section_index](wxCommandEvent& event) { on_inner_tab_changed(section_index, event); }); + if (m_sections[section_index].mixed_tabs != nullptr) + m_sections[section_index].mixed_tabs->Bind(wxEVT_TAB_SEL_CHANGED, [this, section_index](wxCommandEvent& event) { + on_mixed_tab_changed(section_index, event); + }); + } +} + +void PublishSettingsDialog::apply_filter(const wxString& filter_text) +{ + m_filter_mode = FilterMode::Text; + refresh_filter(filter_text.Lower()); +} + +void PublishSettingsDialog::apply_pseudo_filter(bool selected_only) +{ + m_filter_mode = selected_only ? FilterMode::SelectedOnly : FilterMode::UnselectedOnly; + refresh_filter(wxString()); // the pseudo modes ignore the search text +} + +void PublishSettingsDialog::refresh_filter(const wxString& filter) +{ + Freeze(); + const bool pseudo = m_filter_mode != FilterMode::Text; + const bool want_checked = m_filter_mode == FilterMode::SelectedOnly; + m_fb_sizer->Show(!pseudo); + if (pseudo) { + // "×" marks the chip as a dismissible filter state (U+00D7, present in all UI fonts). + m_pseudo_chip->SetLabel((want_checked ? _L("Filter selected") : _L("Filter non-selected")) + " " + wxString::FromUTF8("\u00d7")); + } + m_pseudo_chip->Show(pseudo); + + // Row matches are computed first; page and optgroup visibility is applied below. + if (pseudo) { + for (Row& row : m_rows) + row.matches_filter = row.check->IsEnabled() && row.check->GetValue() == want_checked; + } else { + const bool clear = filter.IsEmpty(); + for (Row& row : m_rows) { + row.matches_filter = clear || (row.section_title + " " + row.category + " " + row.subcategory + " " + row.label + " " + + row.value + " " + row.unit) + .Lower() + .Contains(filter); + } + } + + size_t first_outer = 0; + int first_inner = -1; + bool active_has_match = false; + for (size_t s = 0; s < m_sections.size(); ++s) { + for (size_t inner = 0; inner < m_sections[s].categories.size(); ++inner) { + Category& category = m_categories[m_sections[s].categories[inner]]; + bool has_match = false; + for (size_t r : category.rows) + has_match = has_match || m_rows[r].matches_filter; + category.info->Show(!has_match); + if (!has_match) + // A mixed slot has no selectable rows: always explain it is published whole. + category.info->SetLabel(category.is_mixed ? m_info_mix : + (pseudo ? (want_checked ? m_info_nonsel : m_info_allsel) : m_info_empty)); + if (has_match && first_inner < 0) { + first_outer = s; + first_inner = static_cast(inner); + } + if (static_cast(s) == m_selected_outer && static_cast(inner) == m_sections[s].selected_inner) + active_has_match = has_match; + } + // Mixed slots have no rows: they can never match a filter, so they always fall back to + // their explanatory hint (shown once the slot is enabled). + for (size_t mixed : m_sections[s].mixed_categories) { + Category& category = m_categories[mixed]; + category.info->Show(true); + category.info->SetLabel(m_info_mix); + } + } + if (!active_has_match && first_inner >= 0 && + (m_selected_outer != static_cast(first_outer) || m_sections[first_outer].selected_inner != first_inner)) { + if (m_selected_outer != static_cast(first_outer)) { + m_outer_tabs->SelectItem(static_cast(first_outer)); + show_outer_page(first_outer); + } + m_sections[first_outer].tabs->SelectItem(first_inner); + show_inner_page(first_outer, first_inner); + } + + apply_visibility(); + for (Category& category : m_categories) { + save_scroll_position(category); + category.scroll->FitInside(); + category.list_sizer->Layout(); + category.scroll->Scroll(category.scroll_pos.x, category.scroll_pos.y); + } + Layout(); + Thaw(); +} + +void PublishSettingsDialog::apply_visibility() +{ + Freeze(); + for (Category& category : m_categories) { + // A disabled material slot hides everything below its header (rows, info and the Full + // Publish line); enable it first to reveal its settings. + const bool enabled = category.section != Section::Material || + (category.enable_check != nullptr && category.enable_check->GetValue()); + if (category.scroll != nullptr) + category.scroll->Show(enabled); + if (category.full_line_item != nullptr) + category.full_line_item->Show(enabled); + bool category_any = false; + for (size_t r : category.rows) + category_any = category_any || m_rows[r].matches_filter; + category.info->Show(enabled && !category_any); + for (Subcategory& sub : category.subs) { + bool sub_any = false; + for (size_t r : sub.rows) + sub_any = sub_any || m_rows[r].matches_filter; + if (sub.header != nullptr) + sub.item->Show(enabled && sub_any); + for (size_t r : sub.rows) + m_rows[r].item->Show(enabled && m_rows[r].matches_filter); + } + category.list_sizer->Layout(); + category.scroll->FitInside(); + } + Thaw(); +} + +void PublishSettingsDialog::select_all(bool value) +{ + // "All" skips disabled (gated) rows; "None" leaves a gated row's preserved value. + for (Row& row : m_rows) + if (row.check->IsEnabled()) + row.check->SetValue(value); + // "All" also enables every material slot (so its rows/Full Publish become visible and the + // selection is actually exported); "None" disables them all again. + for (Category& cat : m_categories) + if (cat.section == Section::Material && cat.enable_check != nullptr) + cat.enable_check->SetValue(value); + // wxCheckBox::SetValue does not emit wxEVT_CHECKBOX, so re-run the enable handlers to + // propagate mixed-slot components and refresh visibility as if the user had clicked. + for (size_t c = 0; c < m_categories.size(); ++c) + if (m_categories[c].section == Section::Material) + on_enable_toggle(c); + apply_visibility(); + refresh_tab_indicators(); +} + +bool PublishSettingsDialog::row_is_visible(const Row& row) const +{ + if (m_selected_outer < 0 || m_selected_outer >= static_cast(m_sections.size()) || + row.outer_index != static_cast(m_selected_outer) || m_sections[m_selected_outer].selected_inner < 0 || + m_sections[m_selected_outer].selected_inner >= static_cast(m_sections[m_selected_outer].categories.size()) || + row.inner_index != m_sections[m_selected_outer].categories[m_sections[m_selected_outer].selected_inner] || !row.matches_filter || + !row.check->IsEnabled()) + return false; + return row.item->IsShown(); +} + +void PublishSettingsDialog::select_visible(bool value) +{ + // In a pseudo-filter view the rows being toggled would all disappear; drop the filter + // afterwards so the result stays visible. + const bool clear_pseudo = (m_filter_mode == FilterMode::UnselectedOnly && !value) || + (m_filter_mode == FilterMode::SelectedOnly && value); + + // Toggle the rows visible under the *current* filter. + for (Row& row : m_rows) + if (row_is_visible(row)) + row.check->SetValue(value); + + if (clear_pseudo) { + // Note: SetValue() may fire wxEVT_TEXT on some platforms, re-entering apply_filter() - + // that is fine; the rows above were already toggled and the trailing call is idempotent. + m_filter_ctrl->ChangeValue(""); + apply_filter(""); // resync visibility and the All/None bar + } + refresh_tab_indicators(); +} + +void PublishSettingsDialog::show_menu(wxMouseEvent& evt) +{ + bool filtering = !m_filter_ctrl->GetValue().IsEmpty() || m_filter_mode != FilterMode::Text; + bool list_empty = true; + if (m_selected_outer >= 0) { + for (const Row& row : m_rows) + if (row_is_visible(row)) { + list_empty = false; + break; + } + } + + wxMenu m; + m.Append(kPublishSelectAll, _L("Select All"))->Enable(!filtering); + m.Append(kPublishDeselectAll, _L("Deselect All"))->Enable(!filtering); + m.AppendSeparator(); + m.Append(kPublishSelectVisible, _L("Select visible"))->Enable(!list_empty && filtering); + m.Append(kPublishDeselectVisible, _L("Deselect visible"))->Enable(!list_empty && filtering); + m.AppendSeparator(); + m.AppendCheckItem(kPublishFilterSelected, _L("Filter selected")); + m.AppendCheckItem(kPublishFilterNonSelected, _L("Filter non-selected")); + m.Check(kPublishFilterSelected, m_filter_mode == FilterMode::SelectedOnly); + m.Check(kPublishFilterNonSelected, m_filter_mode == FilterMode::UnselectedOnly); + + m.Bind( + wxEVT_MENU, + [this](wxCommandEvent& e) { + switch (e.GetId()) { + case kPublishSelectAll: select_all(true); break; + case kPublishDeselectAll: select_all(false); break; + case kPublishSelectVisible: select_visible(true); break; + case kPublishDeselectVisible: select_visible(false); break; + case kPublishFilterSelected: + // Clicking the active entry again clears the pseudo filter. + if (m_filter_mode == FilterMode::SelectedOnly) + apply_filter(m_filter_ctrl->GetValue()); + else + apply_pseudo_filter(true); + break; + case kPublishFilterNonSelected: + if (m_filter_mode == FilterMode::UnselectedOnly) + apply_filter(m_filter_ctrl->GetValue()); + else + apply_pseudo_filter(false); + break; + default: break; + } + }, + kPublishSelectAll, kPublishFilterNonSelected); + + wxWindow* src = dynamic_cast(evt.GetEventObject()); + if (!src) + return; + wxPoint screen_pos = src->ClientToScreen(evt.GetPosition()); + wxPoint local_pos = ScreenToClient(screen_pos); + PopupMenu(&m, local_pos); +} + +void PublishSettingsDialog::apply_selection(const std::vector& published_keys, + const std::vector& material_keys) +{ + // The supplied selection is authoritative: clear the dirty-default pre-check first so a + // dirty key the user deselected stays off, then re-select exactly what the selection names. + for (Row& row : m_rows) + if (row.check != nullptr) + row.check->SetValue(false); + for (Category& cat : m_categories) { + if (cat.section != Section::Material) + continue; + if (cat.enable_check != nullptr) + cat.enable_check->SetValue(false); + if (cat.full_check != nullptr) + cat.full_check->SetValue(false); + } + + // Print / printer rows: match by exact row key (the full "#N" opt_id where present). + for (const std::string& key : published_keys) + for (Row& row : m_rows) + if ((row.section == Section::Print || row.section == Section::Printer) && row.check != nullptr && row.key == key) { + row.check->SetValue(true); + break; + } + + // Per-slot material selections, applied positionally by slot. + for (const Slic3r::PublishedMaterialEntry& entry : material_keys) { + if (entry.slot < 0) + continue; + bool entry_mixed = false; + for (const std::string& k : entry.keys) + if (publish_mixed_keys().count(publish_base_key(k)) != 0) { + entry_mixed = true; + break; + } + size_t cat_idx = size_t(-1); + for (size_t c = 0; c < m_categories.size(); ++c) { + const Category& cat = m_categories[c]; + if (cat.section != Section::Material || cat.is_mixed != entry_mixed || cat.filament_slot != size_t(entry.slot)) + continue; + cat_idx = c; + break; + } + if (cat_idx == size_t(-1)) + continue; // slot not present in the receiver (out of range / skipped) + Category& cat = m_categories[cat_idx]; + if (cat.enable_check != nullptr) { + cat.enable_check->SetValue(true); + on_enable_toggle(cat_idx); + } + if (entry.full && cat.full_check != nullptr) { + cat.full_check->SetValue(true); + on_full_toggle(cat_idx); + } else { + // Setting rows are keyed by the base key. + for (const std::string& key : entry.keys) { + const std::string base = publish_base_key(key); + for (const size_t r : cat.rows) { + Row& row = m_rows[r]; + if (row.kind == RowKind::Setting && row.key == base) { + row.check->SetValue(true); + break; + } + } + } + if (entry.publish_type && !entry.publish_type_value.empty()) + for (const size_t r : cat.rows) + if (m_rows[r].kind == RowKind::Type && m_rows[r].check != nullptr) { + m_rows[r].check->SetValue(true); + break; + } + if (entry.publish_color && !entry.color.empty()) + for (const size_t r : cat.rows) + if (m_rows[r].kind == RowKind::Color && m_rows[r].check != nullptr) { + m_rows[r].check->SetValue(true); + break; + } + } + } + + apply_visibility(); + refresh_tab_indicators(); +} + +std::vector PublishSettingsDialog::GetPublishedKeys() const +{ + std::vector out; + // Process and printer sections both travel through published_keys (the load-side overlay + // applies process keys to the prints edited preset and the allowlisted printer keys to the + // printers edited preset); material keys use a separate API. Printer rows carry the full + // per-extruder "#N" opt_id (built in Phase 1), so a checked row publishes exactly that + // extruder's value - per-extruder selection is independent. + for (const Row& row : m_rows) { + if ((row.section != Section::Print && row.section != Section::Printer) || !row.check->GetValue()) + continue; + out.push_back(row.key); + } + return out; +} + +std::vector PublishSettingsDialog::unpublished_mixed_components() const +{ + std::vector out; + for (const Category& cat : m_categories) { + if (!cat.is_mixed || cat.section != Section::Material) + continue; + // Only enabled mixed slots depend on their components being published. + if (cat.enable_check == nullptr || !cat.enable_check->GetValue()) + continue; + const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); + // Components are 1-based physical filament indices. + for (const unsigned int component : mixed_slot_components(full, cat.filament_slot)) { + const size_t component_slot = size_t(component) - 1; + // Find the component slot's material category (one exists per physical slot). + const Category* comp_cat = nullptr; + for (const Category& other : m_categories) { + if (other.section == Section::Material && !other.is_mixed && other.filament_slot == component_slot) { + comp_cat = &other; + break; + } + } + if (comp_cat == nullptr || comp_cat->enable_check == nullptr) + continue; + // Missing when the component's "Enable" is off, or it is enabled with neither + // "Full Publish" nor the "Type" requirement row checked. Colour never counts: + // the receiver renders the mix from its own components' colours. + if (!comp_cat->enable_check->GetValue()) { + out.push_back({cat.filament_slot, component_slot, MixedDependencyIssue::Reason::Disabled}); + continue; + } + if (comp_cat->full_check != nullptr && comp_cat->full_check->GetValue()) + continue; + bool type_checked = false; + for (const size_t r : comp_cat->rows) { + const Row& row = m_rows[r]; + if (row.kind == RowKind::Type && row.check->GetValue()) { + type_checked = true; + break; + } + } + if (!type_checked) + out.push_back({cat.filament_slot, component_slot, MixedDependencyIssue::Reason::MaterialNotPublished}); + } + } + // Deterministic order for the warning rows: by mixed slot, then component slot. The same + // (mix, component) pair cannot repeat: each mix's components come from a config list. + std::sort(out.begin(), out.end(), [](const MixedDependencyIssue& a, const MixedDependencyIssue& b) { + return std::tie(a.mixed_slot, a.component_slot) < std::tie(b.mixed_slot, b.component_slot); + }); + return out; +} + +std::vector PublishSettingsDialog::GetPublishedMaterialKeys() const +{ + std::vector out; + for (const Category& cat : m_categories) { + if (cat.section != Section::Material) + continue; + // A slot that is not "Enable"d publishes nothing at all. + if (cat.enable_check != nullptr && !cat.enable_check->GetValue()) + continue; + Slic3r::PublishedMaterialEntry entry; + entry.filament_type = cat.filament_type; + entry.filament_vendor = cat.filament_vendor; + entry.filament_id = cat.filament_id; + entry.slot = static_cast(cat.filament_slot); + // The author's preset id distinguishes exact variants that share filament_id + // ("Generic PLA" vs "Generic PLA Matte"), so the receiver can match precisely; the + // preset name is the most direct identity and is matched first on load. + PresetBundle* bundle = wxGetApp().preset_bundle; + if (bundle != nullptr && cat.filament_slot < bundle->filament_presets.size()) { + if (const Preset* preset = bundle->filaments.find_preset(bundle->filament_presets[cat.filament_slot], false, true)) { + entry.setting_id = preset->setting_id; + entry.preset_name = preset->name; + } + } + // A mixed slot publishes as one unit: its definition (components, ratios, gradient) + // always travels (its Enable implies this), and the component filaments are enabled + + // Full Published by on_enable_toggle into their own entries. + if (cat.is_mixed) { + Slic3r::PublishedMaterialEntry mixed_entry; + mixed_entry.filament_type = cat.filament_type; + mixed_entry.filament_vendor = cat.filament_vendor; + mixed_entry.filament_id = cat.filament_id; + mixed_entry.slot = static_cast(cat.filament_slot); + // The mix's own colour (blended) is a property of the definition, not a + // requirement row; carry it so the receiver renders the swatch. + const DynamicPrintConfig full_cfg = wxGetApp().preset_bundle->full_config(); + const std::string mix_color = filament_color_hex(full_cfg, cat.filament_slot); + if (!mix_color.empty()) { + mixed_entry.publish_color = true; + mixed_entry.color = mix_color; + } + for (const std::string& key : publish_mixed_keys()) + mixed_entry.keys.emplace_back(key); + out.push_back(std::move(mixed_entry)); + continue; + } + // "Full Publish": the whole filament preset is embedded; type and colour are implicitly + // published, and the per-key rows are disabled / their state ignored. + if (cat.full_check != nullptr && cat.full_check->GetValue()) { + entry.full = true; + entry.full_keys = full_keys_for_slot(); + entry.publish_type = true; + entry.publish_type_value = normalize_filament_type(cat.filament_type); + for (size_t r : cat.rows) { + const Row& row = m_rows[r]; + if (row.kind == RowKind::Color && !row.value.IsEmpty()) { + entry.publish_color = true; + entry.color = row.value.ToStdString(); + } + } + out.push_back(std::move(entry)); + continue; + } + for (size_t r : cat.rows) { + const Row& row = m_rows[r]; + if (!row.check->GetValue()) + continue; + if (row.kind == RowKind::Color) { + entry.publish_color = true; + entry.color = row.value.ToStdString(); + } else if (row.kind == RowKind::Type) { + entry.publish_type = true; + entry.publish_type_value = row.value.ToStdString(); + } else { + entry.keys.push_back(row.key); + } + } + // Nothing checked at all -> nothing to write. + if (!entry.keys.empty() || entry.publish_type || entry.publish_color) + out.push_back(std::move(entry)); + } + return out; +} + +bool PublishSettingsDialog::category_has_selection(const Category& cat) const +{ + // A material slot publishes (or not) as a whole, gated by "Enable". + if (cat.section == Section::Material) + return cat.enable_check != nullptr && cat.enable_check->GetValue(); + // Print/Printer categories have no gate: any checked row counts. + for (const size_t r : cat.rows) + if (m_rows[r].check->GetValue()) + return true; + return false; +} + +void PublishSettingsDialog::refresh_tab_indicators() +{ + for (size_t s = 0; s < m_sections.size(); ++s) { + SectionGroup& section = m_sections[s]; + bool any = false; + for (size_t i = 0; i < section.categories.size(); ++i) { + const bool on = category_has_selection(m_categories[section.categories[i]]); + section.tabs->SetItemIndicator(static_cast(i), on); + any = any || on; + } + if (section.mixed_tabs != nullptr) + for (size_t i = 0; i < section.mixed_categories.size(); ++i) { + const bool on = category_has_selection(m_categories[section.mixed_categories[i]]); + section.mixed_tabs->SetItemIndicator(static_cast(i), on); + any = any || on; + } + m_outer_tabs->SetItemIndicator(static_cast(s), any); + } +} + +std::vector PublishSettingsDialog::full_keys_for_slot() const +{ + // The canonical filament preset keys minus the structural keys the published overlay must + // never touch (inherits, compatibility, *_settings_id, ...), plus filament_colour (not a + // member of Preset::filament_options). Values travel in the exported config, masked to + // this slot, and are applied on load onto the receiver's slot. + const std::set& denylist = publish_structural_keys(); + std::vector keys; + for (const std::string& key : Preset::filament_options()) + if (denylist.count(key) == 0) + keys.emplace_back(key); + keys.emplace_back("filament_colour"); + return keys; +} + +void PublishSettingsDialog::on_dpi_changed(const wxRect& suggested_rect) +{ + // Rescale toolbar bitmaps and icons; collapse chevrons are vector-drawn and repaint. + m_search.msw_rescale(); + m_menu.msw_rescale(); + m_filter_box->SetIcon(m_search.bmp()); + m_menu_button->SetBitmap(m_menu.bmp()); + m_outer_tabs->Rescale(); + + const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); + + for (Category& cat : m_categories) { + if (cat.full_check != nullptr) + cat.full_check->Refresh(); + if (cat.enable_check != nullptr) + cat.enable_check->Refresh(); + if (cat.title_label != nullptr) + cat.title_label->Refresh(); + if (cat.filament_color_chip != nullptr) { + // Mixed pages have no header chip; this only ever fires for physical slots. + if (wxBitmap* chip = get_extruder_color_icon(filament_color_hex(full, cat.filament_slot), std::to_string(cat.filament_slot + 1), + FromDIP(20), FromDIP(20))) { + cat.filament_color_chip->SetBitmap(*chip); + } + } + cat.scroll->FitInside(); + cat.list_sizer->Layout(); + } + + for (size_t s = 0; s < m_sections.size(); ++s) { + SectionGroup& section = m_sections[s]; + section.icon_bmp.msw_rescale(); + if (section.icon_bmp.bmp().IsOk()) + m_outer_tabs->SetItemBitmap(s, section.icon_bmp.bmp()); + section.tabs->Rescale(); + if (section.mixed_tabs != nullptr) + section.mixed_tabs->Rescale(); + } + + // Refresh the per-row Color chips at the new DPI (they carry the slot number too). + for (Row& row : m_rows) { + if (row.color_chip != nullptr && !row.value.IsEmpty()) { + const std::string chip_label = (row.inner_index < m_categories.size()) ? + std::to_string(m_categories[row.inner_index].filament_slot + 1) : + ""; + if (wxBitmap* chip = get_extruder_color_icon(row.value.ToStdString(), chip_label, FromDIP(20), FromDIP(20))) + row.color_chip->SetBitmap(*chip); + } + } + + for (size_t category_index = 0; category_index < m_categories.size(); ++category_index) { + const Category& category = m_categories[category_index]; + if (category.section != Section::Material || category.is_mixed) + continue; + if (wxBitmap* chip = get_extruder_color_icon(filament_color_hex(full, category.filament_slot), + std::to_string(category.filament_slot + 1), FromDIP(20), FromDIP(20))) { + const SectionGroup& section = m_sections[category.group]; + const auto iter = std::find(section.categories.begin(), section.categories.end(), category_index); + if (iter != section.categories.end()) + m_sections[category.group].tabs->SetItemBitmap(static_cast(iter - section.categories.begin()), *chip); + } + } + refresh_mixed_tab_bitmaps(); + + fit_to_content(); // tab buttons' min widths grew with the DPI: re-fit (incl. resize floor) + Refresh(); +} + +void PublishSettingsDialog::refresh_mixed_tab_bitmaps() +{ + // The mixed-tab composition bitmaps bake the tab strip's background colour and DPI-scaled + // text, so both a theme flip and a DPI change have to rebuild them. + const DynamicPrintConfig full = wxGetApp().preset_bundle->full_config(); + for (SectionGroup& section : m_sections) { + if (section.mixed_tabs == nullptr) + continue; + const wxColour tab_bg = section.mixed_tabs->GetBackgroundColour(); + for (size_t i = 0; i < section.mixed_categories.size(); ++i) { + const Category& category = m_categories[section.mixed_categories[i]]; + const wxBitmap tab_bmp = mixed_filament_tab_bitmap(full, category.filament_slot, FromDIP(20), tab_bg); + if (tab_bmp.IsOk()) + section.mixed_tabs->SetItemBitmap(static_cast(i), tab_bmp); + } + } +} + +void PublishSettingsDialog::on_sys_color_changed() +{ + // The mixed-tab compositions bake the tab strip's background colour: rebuild them when the + // theme changes (on_dpi_changed covers rescales). + refresh_mixed_tab_bitmaps(); + Refresh(); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/PublishSettingsDialog.hpp b/src/slic3r/GUI/PublishSettingsDialog.hpp new file mode 100644 index 0000000000..f41b3e73a3 --- /dev/null +++ b/src/slic3r/GUI/PublishSettingsDialog.hpp @@ -0,0 +1,286 @@ +#pragma once + +#include "GUI_Utils.hpp" +#include "wxExtensions.hpp" +#include "Widgets/TabCtrl.hpp" + +#include "libslic3r/PublishSettings.hpp" + +#include +#include +#include +#include +#include +#include +#include + +// Forward declarations (all are global classes, see Widgets/TextInput.hpp and +// Widgets/StaticLine.hpp). +class TextInput; +class StaticLine; + +namespace Slic3r { namespace GUI { + +struct PublishMaterialIdentity +{ + std::string type; + std::string vendor; + std::string id; +}; + +// One unmet dependency of an enabled mixed-filament slot: the component filament the mix uses +// would ship without its material (either the component slot is not enabled at all, or it is +// enabled with neither "Full Publish" nor the "Type" requirement checked). Slots are 0-based. +struct MixedDependencyIssue +{ + enum class Reason { Disabled, MaterialNotPublished }; + size_t mixed_slot{0}; + size_t component_slot{0}; + Reason reason{Reason::Disabled}; +}; + +// Dialog letting a model author select which settings get embedded in a 3MF. Nested tab layout +// mirroring the Process settings (Printer / Filament / Process outer tabs, category or material +// tabs inside each). Dirty settings are pre-checked and shown bold; on OK the print rows become +// "orca_published_keys" and the material rows become "orca_published_material_keys". +class PublishSettingsDialog : public DPIDialog +{ +public: + // Optional published selection (Feature A/B): when the caller supplies one (either a + // remembered session selection or the payload of a freshly loaded published 3MF) the dialog + // is seeded from it, overriding the dirty-default pre-check. A non-null pointer to an empty + // selection means "publish nothing" (an intentional empty state); a null pointer means "no + // remembered selection" (keep the dirty defaults). + PublishSettingsDialog(wxWindow* parent = nullptr, + const std::vector* published_keys = nullptr, + const std::vector* material_keys = nullptr); + ~PublishSettingsDialog(); + + // The selected print/printer setting keys (in display order); printer keys carry a '#N' + // per-extruder suffix. + std::vector GetPublishedKeys() const; + + // The selected keys grouped per material section (base keys, no '#N' suffix). + std::vector GetPublishedMaterialKeys() const; + +protected: + void on_dpi_changed(const wxRect& suggested_rect) override; + void on_sys_color_changed() override; + +private: + void fit_to_content(); + void refresh_mixed_tab_bitmaps(); + + // Which part of the settings the row/category came from. + enum class Section { Print, Printer, Material }; + + // One selectable setting row: a checkbox (setting name) plus a value label and an optional + // grey unit label. key is the full config key, possibly with a "#N" variant suffix + // (print/printer rows); material rows carry the base key. + enum class RowKind { + Setting, // a regular setting key + Color, // material colour requirement (filament_colour) + Type, // material type requirement (read-only text) + }; + struct Row + { + std::string key; + wxString category; + wxString subcategory; + wxString label; + wxString value; + wxString unit; + wxString section_title; // outer tab title, for filter matching + Section section{Section::Print}; + RowKind kind{RowKind::Setting}; + size_t outer_index{0}; + size_t inner_index{0}; + bool dirty{false}; // matches a dirty base key: pre-checked + bold + bool matches_filter{false}; // survives the active filter (computed by apply_filter) + wxCheckBox* check{nullptr}; + wxStaticText* value_label{nullptr}; + wxStaticText* unit_label{nullptr}; + wxStaticBitmap* color_chip{nullptr}; // Color rows only; swatch next to the value + wxSizerItem* item{nullptr}; // sizer item of this row's h-sizer in its tab list sizer + }; + + // An optgroup heading. Rows store indices into m_rows. + struct Subcategory + { + wxString title; + ::StaticLine* header{nullptr}; // null when the title is empty + wxSizerItem* item{nullptr}; + std::vector rows; + }; + + // An inner TabCtrl page with its material controls and content. + struct Category + { + wxString title; + Section section{Section::Print}; + size_t group{0}; // index into m_sections / outer page + size_t source_index{0}; // stable source page or material slot index + wxPanel* page{nullptr}; + wxScrolledWindow* scroll{nullptr}; + wxBoxSizer* list_sizer{nullptr}; + wxStaticText* info{nullptr}; + wxPoint scroll_pos{0, 0}; + wxStaticBitmap* filament_color_chip{nullptr}; + wxStaticText* title_label{nullptr}; // material title (static text; Full Publish carries the label elsewhere) + // "Enable": while unchecked nothing of this slot is exported and everything below the + // header row is hidden. For physical slots the Full Publish toggle sits on a second + // line (full_line_item) visible only when enabled; for mixed slots Enable alone implies + // publishing the mix definition, so no Full Publish widget exists at all. + wxCheckBox* enable_check{nullptr}; + wxSizerItem* full_line_item{nullptr}; // sizer item of the Full Publish line (physical slots only) + // "Full Publish": while checked, the whole slot preset is serialized and its rows + // (incl. Color/Type) are disabled. + wxCheckBox* full_check{nullptr}; + // True for a mixed-color filament slot: no Material/Retraction rows; Enable publishes + // the slot's gradient/ratio definition as a whole. + bool is_mixed{false}; + // Material identity, only for Section::Material categories. + std::string filament_type; + std::string filament_vendor; + std::string filament_id; + // The author's 0-based filament slot this material section represents. + size_t filament_slot{0}; + std::vector subs; + std::vector rows; // flattened rows of this category + }; + + // Frozen snapshot of a mixed filament slot's definition for the read-only visualization + // painted on the slot's page. Plain data only: the paint handler must never touch the + // config. For gradient slots the curve is pre-sampled (t, ratio) pairs, where ratio is the + // first component's share over model height; anchors carry the raw control points. + struct MixedVisualSpec + { + bool valid{false}; + bool is_gradient{false}; + std::vector component_colours; // colour per component, in config order + std::vector ratios; // sublayer shares summing to ~1 (non-gradient) + std::vector tri_weights; // 3-component mixes: barycentric shares + std::vector> gradient_samples; + std::vector> gradient_anchors; + }; + + // One outer TabCtrl page. Category entries are its inner tabs. + struct SectionGroup + { + wxString title; // _L("Printer") / _L("Filament") / _L("Process") + Section kind{Section::Print}; // maps 1:1 to the display group + std::string icon_name; // "printer" / "filament" / "process" + ScalableBitmap icon_bmp; // tab icon next to the title; rescaled on DPI change + wxPanel* page{nullptr}; + TabCtrl* tabs{nullptr}; + // Second tab strip, below the main one, listing only the mixed-color filament slots. + // Present on the Material section only (null elsewhere). + TabCtrl* mixed_tabs{nullptr}; + wxPanel* page_host{nullptr}; + wxBoxSizer* page_host_sizer{nullptr}; + int selected_inner{-1}; + // Selected mixed tab (index into mixed_categories), valid while a mixed slot page is shown. + int selected_mixed{-1}; + std::vector categories; // indices into m_categories (physical slots) + std::vector mixed_categories; // indices into m_categories (mixed slots) + }; + + void build_option_model(); + // Seed the dialog from a published selection (print/printer keys + per-slot material keys): + // the supplied selection is authoritative - it is applied after the dirty pre-check and + // overrides it, so deselected dirty keys stay off. Rows/slots not present in the selection + // are left unselected. Unknown or out-of-range entries are skipped gracefully. + void apply_selection(const std::vector& published_keys, + const std::vector& material_keys); + // Frozen snapshot of a mixed slot's definition for the page visualization, resolved from + // the full config once at dialog-build time. Gradient slots pre-sample exactly what the + // slicer will print: the custom curve wins over the gradient_range endpoints over the + // 0.10 -> 0.90 default (the resolution FilamentBitmapUtils::mixed_gradient_curve mirrors). + static MixedVisualSpec make_mixed_visual_spec(const Slic3r::DynamicPrintConfig& full, size_t slot); + void apply_filter(const wxString& filter_text); + // Menu-only pseudo filters: show only the checked ("Filter selected") or only the + // unchecked ("Filter non-selected") rows. The search box keeps the user's text. + void apply_pseudo_filter(bool selected_only); + // Recompute row matches and visibility for the active filter mode. filter is the lowered + // search text; it is ignored by the pseudo modes. + void refresh_filter(const wxString& filter); + void select_all(bool value); + void select_visible(bool value); + void show_menu(wxMouseEvent& evt); + void set_row_bold(Row& row, bool bold); + // "Full Publish" toggled: disables/enables the material's rows. + void on_full_toggle(size_t category_index); + // "Enable" toggled on a material slot: reveals/hides everything below the header and, for a + // mixed slot, auto-selects its component filaments' "Enable" + "Full Publish" toggles. + void on_enable_toggle(size_t category_index); + // Whether a category currently publishes something, driving its tab's indicator dot. + // Print/Printer: any row checked. Material: the slot's "Enable" is on. + bool category_has_selection(const Category& cat) const; + // Recompute the indicator dot on every outer/inner tab from the current selection state. + void refresh_tab_indicators(); + // Unmet dependencies of enabled mixed-filament slots, one record per (mix, component) pair: + // "Enable" not checked on the component, or enabled with neither "Full Publish" nor the + // "Type" requirement row checked. Colour is deliberately ignored (the receiver renders the + // mix from its own components' colours). Sorted by mixed slot, then component slot. + std::vector unpublished_mixed_components() const; + // Read-only visualization of a mixed slot's definition (a stacked ratio bar, or the + // Material Ratio vs Model Height graph for a gradient), inserted above the info hint + // inside the category's scroll area. + void add_mixed_visual(size_t category_index, const MixedVisualSpec& spec); + // Return/create the fixed outer page for a Section kind. + size_t section_group_for(Section kind); + size_t category_index_for(const wxString& title, + Section section, + size_t group, + size_t source_index, + const PublishMaterialIdentity& identity = PublishMaterialIdentity(), + bool is_mixed = false); + size_t subcategory_index_for(size_t category_index, const wxString& title, const wxString& icon); + void add_row_ui(const std::string& key, + const wxString& label, + const wxString& value, + const wxString& unit, + size_t category_index, + size_t subcategory_index, + RowKind kind = RowKind::Setting); + // The non-structural filament keys of a slot's preset, for a "Full Publish" entry. + std::vector full_keys_for_slot() const; + void save_scroll_position(Category& category); + void show_outer_page(size_t section_index); + void show_inner_page(size_t section_index, int inner_index); + void show_mixed_page(size_t section_index, int mixed_index); + void on_outer_tab_changed(wxCommandEvent& event); + void on_inner_tab_changed(size_t section_index, wxCommandEvent& event); + void on_mixed_tab_changed(size_t section_index, wxCommandEvent& event); + bool row_is_visible(const Row& row) const; + void apply_visibility(); + void bind_tab_events(); + + TabCtrl* m_outer_tabs{nullptr}; + wxPanel* m_outer_host{nullptr}; + wxBoxSizer* m_outer_host_sizer{nullptr}; + int m_selected_outer{-1}; + wxBoxSizer* m_fb_sizer{nullptr}; // "All"/"None" buttons sizer + // Active filter mode: free text from the search box, or one of the menu's pseudo filters. + enum class FilterMode { Text, SelectedOnly, UnselectedOnly }; + FilterMode m_filter_mode{FilterMode::Text}; + TextInput* m_filter_box{nullptr}; + wxTextCtrl* m_filter_ctrl{nullptr}; + wxStaticBitmap* m_menu_button{nullptr}; + // Shown while a pseudo filter is active (the search box keeps the user's text, so the chip + // carries the visible state); clicking it returns to text filtering. + wxStaticText* m_pseudo_chip{nullptr}; + wxString m_info_nonsel; + wxString m_info_allsel; + wxString m_info_empty; + wxString m_info_mix; // body hint shown for a mixed slot (published as a whole) + + ScalableBitmap m_search; + ScalableBitmap m_menu; + + std::vector m_rows; + std::vector m_categories; + std::vector m_sections; +}; + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/RammingChart.cpp b/src/slic3r/GUI/RammingChart.cpp index 96cd3b65a7..29116b12cb 100644 --- a/src/slic3r/GUI/RammingChart.cpp +++ b/src/slic3r/GUI/RammingChart.cpp @@ -7,6 +7,7 @@ #include "GUI.hpp" #include "GUI_App.hpp" #include "I18N.hpp" +#include "Widgets/StateColor.hpp" wxDEFINE_EVENT(EVT_WIPE_TOWER_CHART_CHANGED, wxCommandEvent); diff --git a/src/slic3r/GUI/ReleaseNote.hpp b/src/slic3r/GUI/ReleaseNote.hpp index 0c11dc2f58..cfd372bc97 100644 --- a/src/slic3r/GUI/ReleaseNote.hpp +++ b/src/slic3r/GUI/ReleaseNote.hpp @@ -35,6 +35,7 @@ #include "Widgets/CheckBox.hpp" #include "Widgets/ComboBox.hpp" #include "Widgets/ScrolledWindow.hpp" +#include "Widgets/HyperLink.hpp" #include #include diff --git a/src/slic3r/GUI/SelectMachine.cpp b/src/slic3r/GUI/SelectMachine.cpp index 41ee523fbe..629aef7296 100644 --- a/src/slic3r/GUI/SelectMachine.cpp +++ b/src/slic3r/GUI/SelectMachine.cpp @@ -3073,7 +3073,7 @@ static bool _HasExt(const std::vector &ams_mapping_result) { }; for (const auto &info : ams_mapping_result) { - if (info.ams_id == VIRTUAL_AMS_MAIN_ID_STR || info.ams_id == VIRTUAL_AMS_DEPUTY_ID_STR && !info.ams_id.empty()) { + if (info.ams_id == VIRTUAL_AMS_MAIN_ID_STR || (info.ams_id == VIRTUAL_AMS_DEPUTY_ID_STR && !info.ams_id.empty())) { return true; } } diff --git a/src/slic3r/GUI/SelectMachine.hpp b/src/slic3r/GUI/SelectMachine.hpp index 46d6adf4f4..6a3acce54c 100644 --- a/src/slic3r/GUI/SelectMachine.hpp +++ b/src/slic3r/GUI/SelectMachine.hpp @@ -662,7 +662,6 @@ private: ScalableButton* m_button_question { nullptr }; wxStaticBitmap* m_bed_image{ nullptr }; - Label* m_text_bed_type; }; diff --git a/src/slic3r/GUI/SelectMachinePop.hpp b/src/slic3r/GUI/SelectMachinePop.hpp index 764ffd6af5..e34a23708c 100644 --- a/src/slic3r/GUI/SelectMachinePop.hpp +++ b/src/slic3r/GUI/SelectMachinePop.hpp @@ -181,13 +181,13 @@ private: PinCodePanel* m_panel_direct_connection{nullptr}; wxWindow* m_placeholder_panel{nullptr}; HyperLink* m_hyperlink{nullptr}; // ORCA - wxBoxSizer * m_sizer_body{nullptr}; wxBoxSizer * m_sizer_my_devices{nullptr}; wxBoxSizer * m_sizer_other_devices{nullptr}; +#if defined(__WINDOWS__) wxBoxSizer * m_sizer_search_bar{nullptr}; +#endif wxSearchCtrl* m_search_bar{nullptr}; wxScrolledWindow * m_scrolledWindow{nullptr}; - wxWindow * m_panel_body{nullptr}; wxTimer * m_refresh_timer{nullptr}; std::vector m_user_list_machine_panel; std::vector m_other_list_machine_panel; diff --git a/src/slic3r/GUI/Selection.cpp b/src/slic3r/GUI/Selection.cpp index b6d7abde21..84f4a0b836 100644 --- a/src/slic3r/GUI/Selection.cpp +++ b/src/slic3r/GUI/Selection.cpp @@ -1273,10 +1273,9 @@ void Selection::translate(const Vec3d &displacement, TransformationType transfor const Polygons bed_polys{wxGetApp().plater()->get_partplate_list().get_plate(plate_idx)->get_shared_printable_polygon()}; Vec3d tower_origin = m_cache.volumes_data[i].get_volume_position(); Vec3d actual_displacement = displacement; - bool show_read_wipe_tower = wxGetApp().plater()->get_partplate_list().get_plate(plate_idx)->fff_print()->is_step_done(psWipeTower); - float brim_width = wxGetApp().preset_bundle->prints.get_edited_preset().config.opt_float("prime_tower_brim_width"); - - const double margin = show_read_wipe_tower ? WIPE_TOWER_MARGIN : brim_width + 0.5; // 0.5 is the line width of wipe tower + // Both preview volumes carry the brim in their bounding box, and the release + // clamp holds it WIPE_TOWER_MARGIN inside — same margin, so drops don't snap. + const double margin = WIPE_TOWER_MARGIN; actual_displacement = (m_cache.volumes_data[i].get_instance_rotation_matrix() * m_cache.volumes_data[i].get_instance_scale_matrix() * m_cache.volumes_data[i].get_instance_mirror_matrix()) diff --git a/src/slic3r/GUI/SendMultiMachinePage.cpp b/src/slic3r/GUI/SendMultiMachinePage.cpp index 3a52caec3f..cafbb360e6 100644 --- a/src/slic3r/GUI/SendMultiMachinePage.cpp +++ b/src/slic3r/GUI/SendMultiMachinePage.cpp @@ -3,6 +3,7 @@ #include "I18N.hpp" #include "GUI_App.hpp" +#include "slic3r/Utils/bambu_networking.hpp" #include "MainFrame.hpp" #include "Widgets/RadioBox.hpp" #include @@ -814,13 +815,13 @@ wxBoxSizer* SendMultiMachinePage::create_item_title(wxString title, wxWindow* pa wxBoxSizer* m_sizer_title = new wxBoxSizer(wxHORIZONTAL); auto m_title = new wxStaticText(parent, wxID_ANY, title, wxDefaultPosition, wxDefaultSize, 0); - m_title->SetForegroundColour(DESIGN_GRAY800_COLOR); + m_title->SetForegroundColour(SEND_DESIGN_GRAY800_COLOR); m_title->SetFont(::Label::Head_13); m_title->Wrap(-1); m_title->SetToolTip(tooltip); auto m_line = new wxPanel(parent, wxID_ANY, wxDefaultPosition, wxSize(-1, 1), wxTAB_TRAVERSAL); - m_line->SetBackgroundColour(DESIGN_GRAY400_COLOR); + m_line->SetBackgroundColour(SEND_DESIGN_GRAY400_COLOR); m_sizer_title->Add(m_title, 0, wxALIGN_CENTER | wxALL, 3); m_sizer_title->Add(0, 0, 0, wxLEFT, 9); @@ -843,7 +844,7 @@ wxBoxSizer* SendMultiMachinePage::create_item_checkbox(wxString title, wxWindow* m_sizer_checkbox->Add(0, 0, 0, wxEXPAND | wxLEFT, 8); auto checkbox_title = new wxStaticText(parent, wxID_ANY, title, wxDefaultPosition, wxDefaultSize, 0); - checkbox_title->SetForegroundColour(DESIGN_GRAY900_COLOR); + checkbox_title->SetForegroundColour(SEND_DESIGN_GRAY900_COLOR); checkbox_title->SetFont(::Label::Body_13); auto size = checkbox_title->GetTextExtent(title); @@ -867,12 +868,12 @@ wxBoxSizer* SendMultiMachinePage::create_item_input(wxString str_before, wxStrin { wxBoxSizer* sizer_input = new wxBoxSizer(wxHORIZONTAL); auto input_title = new wxStaticText(parent, wxID_ANY, str_before); - input_title->SetForegroundColour(DESIGN_GRAY900_COLOR); + input_title->SetForegroundColour(SEND_DESIGN_GRAY900_COLOR); input_title->SetFont(::Label::Body_13); input_title->SetToolTip(tooltip); input_title->Wrap(-1); - auto input = new ::TextInput(parent, wxEmptyString, wxEmptyString, wxEmptyString, wxDefaultPosition, DESIGN_INPUT_SIZE, wxTE_PROCESS_ENTER); + auto input = new ::TextInput(parent, wxEmptyString, wxEmptyString, wxEmptyString, wxDefaultPosition, SEND_DESIGN_INPUT_SIZE, wxTE_PROCESS_ENTER); StateColor input_bg(std::pair(wxColour("#F0F0F1"), StateColor::Disabled), std::pair(*wxWHITE, StateColor::Enabled)); input->SetBackgroundColor(input_bg); input->GetTextCtrl()->SetValue(app_config->get(param)); @@ -880,7 +881,7 @@ wxBoxSizer* SendMultiMachinePage::create_item_input(wxString str_before, wxStrin input->GetTextCtrl()->SetValidator(validator); auto second_title = new wxStaticText(parent, wxID_ANY, str_after, wxDefaultPosition, wxDefaultSize, wxST_ELLIPSIZE_END); - second_title->SetForegroundColour(DESIGN_GRAY900_COLOR); + second_title->SetForegroundColour(SEND_DESIGN_GRAY900_COLOR); second_title->SetFont(::Label::Body_13); second_title->SetToolTip(tooltip); second_title->Wrap(-1); @@ -1337,7 +1338,7 @@ wxPanel* SendMultiMachinePage::create_page() m_tip_text->SetMinSize(wxSize(FromDIP(DEVICE_ITEM_MAX_WIDTH), -1)); m_tip_text->SetMaxSize(wxSize(FromDIP(DEVICE_ITEM_MAX_WIDTH), -1)); m_tip_text->SetLabel(_L("Please select the devices you would like to manage here (up to 6 devices)")); - m_tip_text->SetForegroundColour(DESIGN_GRAY800_COLOR); + m_tip_text->SetForegroundColour(SEND_DESIGN_GRAY800_COLOR); m_tip_text->SetFont(::Label::Head_20); m_tip_text->Wrap(-1); diff --git a/src/slic3r/GUI/SendMultiMachinePage.hpp b/src/slic3r/GUI/SendMultiMachinePage.hpp index 7d77849bf3..eadd78eb0a 100644 --- a/src/slic3r/GUI/SendMultiMachinePage.hpp +++ b/src/slic3r/GUI/SendMultiMachinePage.hpp @@ -15,6 +15,9 @@ #include "SelectMachine.hpp" namespace Slic3r { + +struct PrintParams; + namespace GUI { #define SEND_LEFT_PADDING_LEFT 15 #define SEND_LEFT_PRINTABLE 40 @@ -22,15 +25,15 @@ namespace GUI { #define SEND_LEFT_DEV_STATUS 250 #define SEND_LEFT_TAKS_STATUS 180 -#define DESIGN_SELECTOR_NOMORE_COLOR wxColour(248, 248, 248) -#define DESIGN_GRAY900_COLOR wxColour(38, 46, 48) -#define DESIGN_GRAY800_COLOR wxColour(50, 58, 61) -#define DESIGN_GRAY600_COLOR wxColour(144, 144, 144) -#define DESIGN_GRAY400_COLOR wxColour(166, 169, 170) -#define DESIGN_RESOUTION_PREFERENCES wxSize(FromDIP(540), -1) -#define DESIGN_COMBOBOX_SIZE wxSize(FromDIP(140), -1) -#define DESIGN_LARGE_COMBOBOX_SIZE wxSize(FromDIP(160), -1) -#define DESIGN_INPUT_SIZE wxSize(FromDIP(50), -1) +#define SEND_DESIGN_SELECTOR_NOMORE_COLOR wxColour(248, 248, 248) +#define SEND_DESIGN_GRAY900_COLOR wxColour(38, 46, 48) +#define SEND_DESIGN_GRAY800_COLOR wxColour(50, 58, 61) +#define SEND_DESIGN_GRAY600_COLOR wxColour(144, 144, 144) +#define SEND_DESIGN_GRAY400_COLOR wxColour(166, 169, 170) +#define SEND_DESIGN_RESOUTION_PREFERENCES wxSize(FromDIP(540), -1) +#define SEND_DESIGN_COMBOBOX_SIZE wxSize(FromDIP(140), -1) +#define SEND_DESIGN_LARGE_COMBOBOX_SIZE wxSize(FromDIP(160), -1) +#define SEND_DESIGN_INPUT_SIZE wxSize(FromDIP(50), -1) diff --git a/src/slic3r/GUI/SendToPrinter.hpp b/src/slic3r/GUI/SendToPrinter.hpp index 93215c4e06..24e483ae33 100644 --- a/src/slic3r/GUI/SendToPrinter.hpp +++ b/src/slic3r/GUI/SendToPrinter.hpp @@ -55,7 +55,6 @@ private: void init_timer(); int m_print_plate_idx; - int m_current_filament_id; int m_print_error_code = 0; int timeout_count = 0; int m_connect_try_times = 0; @@ -77,7 +76,6 @@ private: TextInput* m_rename_input{ nullptr }; wxSimplebook* m_rename_switch_panel{ nullptr }; Plater* m_plater{ nullptr }; - wxStaticBitmap* m_staticbitmap{ nullptr }; ThumbnailPanel* m_thumbnailPanel{ nullptr }; ComboBox* m_comboBox_printer{ nullptr }; Button* m_rename_button{ nullptr }; @@ -97,8 +95,6 @@ private: wxPanel * m_connecting_panel{nullptr}; wxSimplebook* m_simplebook{ nullptr }; wxStaticText* m_statictext_finish{ nullptr }; - wxStaticText* m_stext_sending{ nullptr }; - wxStaticText* m_staticText_bed_title{ nullptr }; wxStaticText* m_statictext_printer_msg{ nullptr }; wxStaticText * m_connecting_printer_msg{nullptr}; wxStaticText* m_stext_printer_title{ nullptr }; @@ -115,7 +111,6 @@ private: wxBoxSizer* sizer_thumbnail; wxBoxSizer* m_sizer_scrollable_region; wxBoxSizer* m_sizer_main; - wxStaticText* m_file_name; PrintDialogStatus m_print_status{ PrintStatusInit }; AnimaIcon * m_animaicon{nullptr}; @@ -134,8 +129,6 @@ private: std::vector m_storage_radioBox; std::string m_selected_storage; bool m_if_has_sdcard; - bool m_waiting_support{ false }; - bool m_waiting_enable{ false }; std::vector m_ability_list; public: diff --git a/src/slic3r/GUI/SliceInfoPanel.hpp b/src/slic3r/GUI/SliceInfoPanel.hpp index 8f1710022b..09708d68e4 100644 --- a/src/slic3r/GUI/SliceInfoPanel.hpp +++ b/src/slic3r/GUI/SliceInfoPanel.hpp @@ -28,7 +28,6 @@ public: private: wxScrolledWindow *m_panel; - BBLSliceInfo *m_info { nullptr }; void OnMouse(wxMouseEvent &event); void OnSize(wxSizeEvent &event); diff --git a/src/slic3r/GUI/SyncAmsInfoDialog.cpp b/src/slic3r/GUI/SyncAmsInfoDialog.cpp index 7ae48008c8..7f075dbbfa 100644 --- a/src/slic3r/GUI/SyncAmsInfoDialog.cpp +++ b/src/slic3r/GUI/SyncAmsInfoDialog.cpp @@ -631,7 +631,6 @@ void SyncAmsInfoDialog::updata_ui_when_priner_not_same() { SyncAmsInfoDialog::SyncAmsInfoDialog(wxWindow *parent, SyncInfo &info) : DPIDialog(static_cast(wxGetApp().mainframe), wxID_ANY, _L("Synchronize AMS Filament Information"), wxDefaultPosition, wxDefaultSize, wxCAPTION | wxCLOSE_BOX) , m_input_info(info) - , m_export_3mf_cancel(false) , m_mapping_popup(AmsMapingPopup(this,true)) , m_mapping_tip_popup(AmsMapingTipPopup(this)) , m_mapping_tutorial_popup(AmsTutorialPopup(this)) @@ -679,7 +678,7 @@ SyncAmsInfoDialog::SyncAmsInfoDialog(wxWindow *parent, SyncInfo &info) : wxBoxSizer *loading_Sizer = new wxBoxSizer(wxHORIZONTAL); m_gif_ctrl = new wxAnimationCtrl(m_loading_page, wxID_ANY, wxNullAnimation, wxDefaultPosition, wxDefaultSize, wxAC_DEFAULT_STYLE); - auto gif_path = Slic3r::var("loading.gif").c_str(); + const wxString gif_path = from_u8(Slic3r::var("loading.gif")); if (m_gif_ctrl->LoadFile(gif_path)){ m_gif_ctrl->SetSize(m_gif_ctrl->GetAnimation().GetSize()); m_gif_ctrl->Play(); @@ -1548,7 +1547,7 @@ bool SyncAmsInfoDialog::is_nozzle_type_match(DevExtderSystem data, wxString &err auto sai_nz_pt = wxGetApp().preset_bundle->printers.get_edited_preset().get_printer_type(wxGetApp().preset_bundle); if (target_machine_nozzle_id == DEPUTY_EXTRUDER_ID) { pos = _L(DevPrinterConfigUtil::get_toolhead_display_name(sai_nz_pt, DEPUTY_EXTRUDER_ID, ToolHeadComponent::Nozzle, ToolHeadNameCase::LowerCase)); - } else if ((target_machine_nozzle_id == MAIN_EXTRUDER_ID)) { + } else if (target_machine_nozzle_id == MAIN_EXTRUDER_ID) { pos = _L(DevPrinterConfigUtil::get_toolhead_display_name(sai_nz_pt, MAIN_EXTRUDER_ID, ToolHeadComponent::Nozzle, ToolHeadNameCase::LowerCase)); } diff --git a/src/slic3r/GUI/SyncAmsInfoDialog.hpp b/src/slic3r/GUI/SyncAmsInfoDialog.hpp index b0868f8c58..6f86af6032 100644 --- a/src/slic3r/GUI/SyncAmsInfoDialog.hpp +++ b/src/slic3r/GUI/SyncAmsInfoDialog.hpp @@ -21,15 +21,11 @@ class SyncAmsInfoDialog : public DPIDialog bool m_only_exist_ext_spool_flag{false}; int m_current_filament_id{0}; int m_print_plate_idx{0}; - int m_print_plate_total{0}; int m_timeout_count{0}; int m_print_error_code{0}; bool m_is_in_sending_mode{false}; bool m_ams_mapping_res{false}; bool m_ams_mapping_valid{false}; - bool m_export_3mf_cancel{false}; - bool m_is_canceled{false}; - bool m_is_rename_mode{false}; bool m_check_flag{false}; PrintPageMode m_print_page_mode{PrintPageMode::PrintPageModePrepare}; std::string m_print_error_msg; diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index e650c45126..2dedb23365 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -7,6 +7,7 @@ #include "libslic3r/FilamentMixer.hpp" #include "libslic3r/Utils.hpp" #include "libslic3r/Model.hpp" +#include "libslic3r/PublishSettings.hpp" #include "libslic3r/GCode/GCodeProcessor.hpp" #include "Search.hpp" @@ -5699,26 +5700,16 @@ if (is_marlin_flavor) optgroup->append_single_option_line("extruder_offset", "printer_extruder_basic_information#extruder-offset-position", extruder_idx); //BBS: don't show retract related config menu in machine page + // These optgroups are built from publishable_printer_retraction/z_hop_options() so the + // published-3MF printer allowlist (their union in libslic3r/PublishSettings.hpp) can + // never drift from what the machine page actually shows. optgroup = page->new_optgroup(L("Retraction"), L"param_retraction"); - optgroup->append_single_option_line("retraction_length", "printer_extruder_retraction#length", extruder_idx); - optgroup->append_single_option_line("retract_restart_extra", "printer_extruder_retraction#extra-length-on-restart", extruder_idx); - optgroup->append_single_option_line("retraction_speed", "printer_extruder_retraction#retraction-speed", extruder_idx); - optgroup->append_single_option_line("deretraction_speed", "printer_extruder_retraction#deretraction-speed", extruder_idx); - optgroup->append_single_option_line("retraction_minimum_travel", "printer_extruder_retraction#travel-distance-threshold", extruder_idx); - optgroup->append_single_option_line("retract_when_changing_layer", "printer_extruder_retraction#retract-on-layer-change", extruder_idx); - optgroup->append_single_option_line("wipe", "printer_extruder_retraction#wipe-while-retracting", extruder_idx); - optgroup->append_single_option_line("wipe_distance", "printer_extruder_retraction#wipe-distance", extruder_idx); - optgroup->append_single_option_line("retract_before_wipe", "printer_extruder_retraction#retract-amount-before-wipe", extruder_idx); - // Orca - optgroup->append_single_option_line("retract_after_wipe", "printer_extruder_retraction#retract-amount-after-wipe", extruder_idx); + for (const PublishablePrinterOption& opt : publishable_printer_retraction_options()) + optgroup->append_single_option_line(opt.key, opt.icon, extruder_idx); optgroup = page->new_optgroup(L("Z-Hop"), L"param_extruder_lift_enforcement"); - optgroup->append_single_option_line("retract_lift_enforce", "printer_extruder_z_hop#on-surfaces", extruder_idx); - optgroup->append_single_option_line("z_hop_types", "printer_extruder_z_hop#z-hop-type", extruder_idx); - optgroup->append_single_option_line("z_hop", "printer_extruder_z_hop#z-hop-height", extruder_idx); - optgroup->append_single_option_line("travel_slope", "printer_extruder_z_hop#traveling-angle", extruder_idx); - optgroup->append_single_option_line("retract_lift_above", "printer_extruder_z_hop#only-lift-z-above", extruder_idx); - optgroup->append_single_option_line("retract_lift_below", "printer_extruder_z_hop#only-lift-z-below", extruder_idx); + for (const PublishablePrinterOption& opt : publishable_printer_z_hop_options()) + optgroup->append_single_option_line(opt.key, opt.icon, extruder_idx); optgroup = page->new_optgroup(L("Retraction when switching material"), L"param_retraction_material_change"); optgroup->append_single_option_line("retract_length_toolchange", "printer_extruder_retraction#retraction-when-switching-materials", extruder_idx); diff --git a/src/slic3r/GUI/Tab.hpp b/src/slic3r/GUI/Tab.hpp index 5069b76417..30a06b4eb5 100644 --- a/src/slic3r/GUI/Tab.hpp +++ b/src/slic3r/GUI/Tab.hpp @@ -30,7 +30,6 @@ #include //#include "BedShapeDialog.hpp" -#include "Event.hpp" #include "wxExtensions.hpp" #include "ConfigManipulation.hpp" #include "OptionsGroup.hpp" @@ -38,7 +37,6 @@ //BBS: GUI refactor #include "Notebook.hpp" #include "ParamsPanel.hpp" -#include "Widgets/RoundedRectangle.hpp" #include "Widgets/TextInput.hpp" #include "Widgets/CheckBox.hpp" // ORCA @@ -472,6 +470,7 @@ protected: std::string m_last_sparse_infill_rotate_template_value; ConfigManipulation get_config_manipulation(); friend class EditGCodeDialog; + friend class PublishSettingsDialog; }; class TabPrint : public Tab @@ -630,7 +629,6 @@ private: std::vector m_pages_fff; std::vector m_pages_sla; - wxBoxSizer* m_presets_sizer {nullptr}; public: ScalableButton* m_reset_to_filament_color = nullptr; diff --git a/src/slic3r/GUI/TextureImportDialog.cpp b/src/slic3r/GUI/TextureImportDialog.cpp index 1a24799a15..2e5c1145e1 100644 --- a/src/slic3r/GUI/TextureImportDialog.cpp +++ b/src/slic3r/GUI/TextureImportDialog.cpp @@ -134,7 +134,9 @@ public: } private: +#if defined(__WXMSW__) || defined(__APPLE__) int m_suspended_count = 0; +#endif }; static bool needs_filament_swatch_border(const wxColour& colour) @@ -519,7 +521,6 @@ public: , m_entries(entries) , m_colors_rgba(colors_rgba) , m_names(names) - , m_existing_count(existing_count) , m_dialog_anchor(dialog_anchor) , m_on_select(std::move(on_select)) , m_on_add_filament(std::move(on_add_filament)) @@ -877,7 +878,6 @@ private: std::vector m_entries; std::vector> m_colors_rgba; std::vector m_names; - size_t m_existing_count = 0; wxWindow* m_dialog_anchor = nullptr; std::function m_on_select; std::function m_on_add_filament; diff --git a/src/slic3r/GUI/TroubleshootDialog.cpp b/src/slic3r/GUI/TroubleshootDialog.cpp index 5acb75c6ea..7cbb00b5d8 100644 --- a/src/slic3r/GUI/TroubleshootDialog.cpp +++ b/src/slic3r/GUI/TroubleshootDialog.cpp @@ -6,6 +6,8 @@ #include "GUI_App.hpp" #include "MainFrame.hpp" +#include +#include #include #include #include "wx/clipbrd.h" diff --git a/src/slic3r/GUI/UnsavedChangesDialog.cpp b/src/slic3r/GUI/UnsavedChangesDialog.cpp index 5c69466cfa..65b678953d 100644 --- a/src/slic3r/GUI/UnsavedChangesDialog.cpp +++ b/src/slic3r/GUI/UnsavedChangesDialog.cpp @@ -12,6 +12,7 @@ #include "libslic3r/PresetBundle.hpp" #include "libslic3r/Color.hpp" #include "format.hpp" +#include "ConfigValueFormatter.hpp" #include "GUI_App.hpp" #include "Plater.hpp" #include "Tab.hpp" @@ -22,7 +23,6 @@ #include "MsgDialog.hpp" #include "PresetComboBoxes.hpp" -#include "Widgets/RoundedRectangle.hpp" #include "Widgets/CheckBox.hpp" #include "Widgets/DialogButtons.hpp" #include "Widgets/HyperLink.hpp" @@ -571,14 +571,6 @@ void DiffModel::Clear() } -static std::string get_pure_opt_key(std::string opt_key) -{ - const int pos = opt_key.find("#"); - if (pos > 0) - boost::erase_tail(opt_key, opt_key.size() - pos); - return opt_key; -} - // ---------------------------------------------------------------------------- // DiffViewCtrl // ---------------------------------------------------------------------------- @@ -1212,32 +1204,6 @@ bool UnsavedChangesDialog::save(PresetCollection* dependent_presets, bool show_s return true; } -wxString get_string_from_enum(const std::string& opt_key, const DynamicPrintConfig& config, bool is_infill = false, int idx = -1) -{ - const ConfigOptionDef& def = config.def()->options.at(opt_key); - const std::vector& names = def.enum_labels;//ConfigOptionEnum::get_enum_names(); - int val = 0; - - if (idx >= 0) - val = dynamic_cast(config.option(opt_key))->get_at(idx); - else - val = config.option(opt_key)->getInt(); - - // Each infill doesn't use all list of infill declared in PrintConfig.hpp. - // So we should "convert" val to the correct one - if (is_infill) { - for (auto key_val : *def.enum_keys_map) - if (int(key_val.second) == val) { - auto it = std::find(def.enum_values.begin(), def.enum_values.end(), key_val.first); - if (it == def.enum_values.end()) - return ""; - return from_u8(_utf8(names[it - def.enum_values.begin()])); - } - return _L("Undefined"); - } - return from_u8(_utf8(names[val])); -} - // BBS #if 0 static size_t get_id_from_opt_key(std::string opt_key) @@ -1251,194 +1217,6 @@ static size_t get_id_from_opt_key(std::string opt_key) } #endif -static wxString get_full_label(std::string opt_key, const DynamicPrintConfig& config) -{ - opt_key = get_pure_opt_key(opt_key); - auto option = config.option(opt_key); - - if (!option || option->is_nil()) - return _L("N/A"); - - const ConfigOptionDef* opt = config.def()->get(opt_key); - return opt->full_label.empty() ? opt->label : opt->full_label; -} - -static wxString get_string_value(std::string opt_key, const DynamicPrintConfig& config) -{ - int orig_opt_idx = -1; - int opt_idx = -1; - int pos = opt_key.find("#"); - std::string temp_str = opt_key; - if (pos > 0) { - boost::erase_head(temp_str, pos + 1); - orig_opt_idx = static_cast(atoi(temp_str.c_str())); - } - opt_idx = orig_opt_idx >= 0 ? orig_opt_idx : 0; - opt_key = get_pure_opt_key(opt_key); - auto option = config.option(opt_key); - if (!option) { - return _L("N/A"); - } - auto opt_vector = dynamic_cast(option); - - if (option->is_scalar() && config.option(opt_key)->is_nil() || - option->is_vector() && opt_vector && opt_idx >= 0 && opt_idx < opt_vector->size() && opt_vector->is_nil(opt_idx)) - return _L("N/A"); - - wxString out; - - const ConfigOptionDef* opt = config.def()->get(opt_key); - bool is_nullable = opt->nullable; - - switch (opt->type) { - case coInt: - return from_u8((boost::format("%1%") % config.opt_int(opt_key)).str()); - case coInts: { - if (is_nullable) { - auto values = config.opt(opt_key); - if (opt_idx < values->size()) - return from_u8((boost::format("%1%") % values->get_at(opt_idx)).str()); - } - else { - auto values = config.opt(opt_key); - if (orig_opt_idx >= 0 && orig_opt_idx < values->size()) { - return from_u8((boost::format("%1%") % values->get_at(opt_idx)).str()); - } - else { - std::string value_str; - for (int i = 0; i < values->size(); i++) { - value_str += std::to_string(values->get_at(i)); - if (i != values->size() - 1) { - value_str += ","; - } - } - return from_u8(value_str); - } - } - return _L("Undefined"); - } - case coBool: - return config.opt_bool(opt_key) ? "true" : "false"; - case coBools: { - if (is_nullable) { - auto values = config.opt(opt_key); - if (opt_idx < values->size()) - return values->get_at(opt_idx) ? "true" : "false"; - } - else { - auto values = config.opt(opt_key); - if (opt_idx < values->size()) - return values->get_at(opt_idx) ? "true" : "false"; - } - return _L("Undefined"); - } - case coPercent: - return from_u8((boost::format("%1%%%") % int(config.optptr(opt_key)->getFloat())).str()); - case coPercents: { - if (is_nullable) { - auto values = config.opt(opt_key); - if (opt_idx < values->size()) - return from_u8((boost::format("%1%%%") % values->get_at(opt_idx)).str()); - } - else { - auto values = config.opt(opt_key); - if (opt_idx < values->size()) - return from_u8((boost::format("%1%%%") % values->get_at(opt_idx)).str()); - } - return _L("Undefined"); - } - case coFloat: - return double_to_string(config.opt_float(opt_key)); - case coFloats: { - if (is_nullable) { - auto values = config.opt(opt_key); - if (opt_idx < values->size()) - return double_to_string(values->get_at(opt_idx)); - } - else { - auto values = config.opt(opt_key); - if (values && opt_idx < values->size()) - return double_to_string(values->get_at(opt_idx)); - } - return _L("Undefined"); - } - case coString: - return from_u8(config.opt_string(opt_key)); - case coStrings: { - const ConfigOptionStrings* strings = config.opt(opt_key); - if (strings) { - if (opt_key == "compatible_printers" || opt_key == "compatible_prints") { - if (strings->empty()) - return _L("All"); - for (size_t id = 0; id < strings->size(); id++) - out += from_u8(strings->get_at(id)) + "\n"; - out.RemoveLast(1); - return out; - } - if (!strings->empty() && opt_idx < strings->values.size()) - return from_u8(strings->get_at(opt_idx)); - } - break; - } - case coFloatOrPercent: { - const ConfigOptionFloatOrPercent* opt = config.opt(opt_key); - if (opt) - out = double_to_string(opt->value) + (opt->percent ? "%" : ""); - return out; - } - case coEnum: { - return get_string_from_enum(opt_key, config, - opt_key == "top_surface_pattern" || - opt_key == "bottom_surface_pattern" || - opt_key == "internal_solid_infill_pattern" || - opt_key == "sparse_infill_pattern" || - opt_key == "ironing_pattern" || - opt_key == "support_ironing_pattern" || - opt_key == "support_pattern" || - opt_key == "support_interface_pattern") - ; - } - case coEnums: { - return get_string_from_enum(opt_key, config, - opt_key == "top_surface_pattern" || - opt_key == "bottom_surface_pattern" || - opt_key == "internal_solid_infill_pattern" || - opt_key == "sparse_infill_pattern" || - opt_key == "ironing_pattern" || - opt_key == "support_ironing_pattern" || - opt_key == "support_pattern" || - opt_key == "support_interface_pattern" - , opt_idx); - } - case coPoint: { - Vec2d val = config.opt(opt_key)->value; - return from_u8((boost::format("[%1%]") % ConfigOptionPoint(val).serialize()).str()); - } - case coPoints: { - //BBS: add bed_exclude_area - if (opt_key == "printable_area" || opt_key == "thumbnails") { - ConfigOptionPoints points = *config.option(opt_key); - //BuildVolume build_volume = {points.values, 0.}; - return get_thumbnails_string(points.values); - } - else if (opt_key == "bed_exclude_area") { - return get_thumbnails_string(config.option(opt_key)->values); - } - else if (opt_key == "head_wrap_detect_zone") { - return get_thumbnails_string(config.option(opt_key)->values); - } - else if (opt_key == "wrapping_exclude_area") { - return get_thumbnails_string(config.option(opt_key)->values); - } - Vec2d val = config.opt(opt_key)->get_at(opt_idx); - return from_u8((boost::format("[%1%]") % ConfigOptionPoint(val).serialize()).str()); - } - default: - break; - } - return out; -} - void UnsavedChangesDialog::update(Preset::Type type, PresetCollection* dependent_presets, const std::string& new_selected_preset, const wxString& header) { PresetCollection* presets = dependent_presets; diff --git a/src/slic3r/GUI/UpdateDialogs.hpp b/src/slic3r/GUI/UpdateDialogs.hpp index 04eb4ffb3c..784c10f09a 100644 --- a/src/slic3r/GUI/UpdateDialogs.hpp +++ b/src/slic3r/GUI/UpdateDialogs.hpp @@ -33,7 +33,6 @@ public: void on_hyperlink(wxHyperlinkEvent& evt); private: - wxCheckBox *cbox; }; diff --git a/src/slic3r/GUI/UserManager.cpp b/src/slic3r/GUI/UserManager.cpp index 456582e896..e874c2158d 100644 --- a/src/slic3r/GUI/UserManager.cpp +++ b/src/slic3r/GUI/UserManager.cpp @@ -1,9 +1,11 @@ #include "libslic3r/libslic3r.h" #include "UserManager.hpp" #include "DeviceManager.hpp" +#include "BindDialog.hpp" #include "NetworkAgent.hpp" #include "GUI.hpp" #include "GUI_App.hpp" +#include "I18N.hpp" #include "MsgDialog.hpp" #include "DeviceCore/DevManager.h" diff --git a/src/slic3r/GUI/WebGuideDialog.cpp b/src/slic3r/GUI/WebGuideDialog.cpp index ae259f23d1..78030c68da 100644 --- a/src/slic3r/GUI/WebGuideDialog.cpp +++ b/src/slic3r/GUI/WebGuideDialog.cpp @@ -23,6 +23,7 @@ #include #include +#include #include #include #include @@ -562,6 +563,78 @@ void GuideFrame::OnScriptMessage(wxWebViewEvent &evt) m_ProfileJson["filament"][fName]["selected"] = 1; } } + else if (strCmd == "check_for_new_printers") { + json response = json::object(); + response["command"] = "check_new_printers_result"; + // Guide pages currently send sequence_id as a number, while older + // pages may send it as a string. Preserve the value without + // forcing either representation. + if (j.contains("sequence_id")) + response["sequence_id"] = j["sequence_id"]; + else + response["sequence_id"] = ""; + + if (!m_MainPtr->preset_updater) { + response["error"] = "Printer update service is unavailable."; + wxString strJS = wxString::Format("HandleStudio(%s)", response.dump(-1, ' ', true)); + wxGetApp().CallAfter([this, strJS] { RunScript(strJS); }); + } else { + // Orca: enumerate vendors directly from disk rather than from m_ProfileJson["model"] + // — a vendor with no machine models (e.g. a filament-only bundle, or a test fixture + // like "test123" with an empty machine_model_list) never gets a "vendor" entry + // pushed into "model" by LoadProfileFamily(), so it would be invisible to the + // request body and get endlessly re-offered by the server. Scan both the system dir + // (already-installed vendors) and the bundled resources dir (shipped-but-not-yet- + // installed vendors), same as LoadProfileData() does when building loaded_vendors. + std::set system_vendors; + for (const auto& dir : {vendor_dir, rsrc_vendor_dir}) { + if (!boost::filesystem::exists(dir)) + continue; + for (const auto& entry : boost::filesystem::directory_iterator(dir)) { + if (!boost::filesystem::is_directory(entry) && boost::iequals(entry.path().extension().string(), ".json")) + system_vendors.insert(entry.path().stem().string()); + } + } + // Orca: check_new_vendors() is async (network + confirmation dialog + download + // all happen off the calling thread apart from the dialog itself); guard against + // this dialog being closed before the callback fires. + wxWeakRef weak_this(this); + try { + m_MainPtr->preset_updater->check_new_vendors( + system_vendors, [weak_this, response](std::vector installed_vendors, bool declined) mutable { + if (!weak_this) + return; + + // Orca: append the newly installed vendor(s) into the in-memory + // profile data (instead of a full LoadProfileData() rescan of every + // vendor) and push the refreshed list to the webview, the same way + // request_userguide_profile does, so the printer list picks them up + // without needing to reopen the guide. + for (const auto& vendor_id : installed_vendors) { + weak_this->LoadProfileFamily(vendor_id, (weak_this->vendor_dir / (vendor_id + ".json")).string()); + } + if (!installed_vendors.empty()) { + json profile_response = json::object(); + profile_response["command"] = "response_userguide_profile"; + profile_response["sequence_id"] = "10001"; + profile_response["response"] = weak_this->m_ProfileJson; + wxString profileJS = wxString::Format("HandleStudio(%s)", profile_response.dump(-1, ' ', true)); + weak_this->RunScript(profileJS); + } + + response["vendors"] = installed_vendors; + response["declined"] = declined; + wxString strJS = wxString::Format("HandleStudio(%s)", response.dump(-1, ' ', true)); + weak_this->RunScript(strJS); + }); + } catch (const std::exception &e) { + BOOST_LOG_TRIVIAL(warning) << "Failed to check for new printers: " << e.what(); + response["error"] = "Failed to check for new printers."; + wxString strJS = wxString::Format("HandleStudio(%s)", response.dump(-1, ' ', true)); + wxGetApp().CallAfter([this, strJS] { RunScript(strJS); }); + } + } + } else if (strCmd == "user_guide_finish") { SaveProfile(); @@ -1644,13 +1717,15 @@ int GuideFrame::LoadProfileFamily(std::string strVendor, std::string strFilePath OneModel["materials"] = pm["default_materials"]; // wxString strCoverPath = wxString::Format("%s\\%s\\%s_cover.png", strFolder, strVendor, std::string(s1.mb_str())); - std::string cover_file = s1 + "_cover.png"; - boost::filesystem::path cover_path = boost::filesystem::absolute(boost::filesystem::path(resources_dir()) / "/profiles/" / strVendor / cover_file).make_preferred(); + std::string cover_file = s1 + "_cover.png"; + boost::filesystem::path cover_path = boost::filesystem::absolute(vendor_dir / cover_file).make_preferred(); + BOOST_LOG_TRIVIAL(info) << "[WebGuideDialog] " << cover_path; if (!boost::filesystem::exists(cover_path)) { - cover_path = - (boost::filesystem::absolute(boost::filesystem::path(resources_dir()) / "/web/image/printer/") / - cover_file) - .make_preferred(); + cover_path = boost::filesystem::absolute(boost::filesystem::path(resources_dir()) / "/profiles/" / strVendor / cover_file) + .make_preferred(); + if (!boost::filesystem::exists(cover_path)) + cover_path = (boost::filesystem::absolute(boost::filesystem::path(resources_dir()) / "/web/image/printer/") / cover_file) + .make_preferred(); } OneModel["cover"] = cover_path.string(); diff --git a/src/slic3r/GUI/WebGuideDialog.hpp b/src/slic3r/GUI/WebGuideDialog.hpp index c4cfc8bf6d..1ad60175ae 100644 --- a/src/slic3r/GUI/WebGuideDialog.hpp +++ b/src/slic3r/GUI/WebGuideDialog.hpp @@ -43,6 +43,8 @@ namespace Slic3r { namespace GUI { class GuideFrame : public DPIDialog { public: + using json = nlohmann::json; + GuideFrame(GUI_App *pGUI, long style = wxCAPTION | wxCLOSE_BOX | wxSYSTEM_MENU); virtual ~GuideFrame(); diff --git a/src/slic3r/GUI/WebViewDialog.hpp b/src/slic3r/GUI/WebViewDialog.hpp index 123025ef85..bad0192115 100644 --- a/src/slic3r/GUI/WebViewDialog.hpp +++ b/src/slic3r/GUI/WebViewDialog.hpp @@ -108,13 +108,16 @@ public: private: wxWebView* m_browser; + wxButton * m_button_stop; + wxTextCtrl *m_url; +#if !BBL_RELEASE_TO_PUBLIC + // Created only by the internal-build toolbar in the constructor. wxBoxSizer *bSizer_toolbar; wxButton * m_button_back; wxButton * m_button_forward; - wxButton * m_button_stop; wxButton * m_button_reload; - wxTextCtrl *m_url; wxButton * m_button_tools; +#endif //BBL_RELEASE_TO_PUBLIC wxMenu* m_tools_menu; wxMenuItem* m_tools_handle_navigation; @@ -143,7 +146,6 @@ private: wxMenuItem* m_dev_tools; wxInfoBar *m_info; - wxStaticText* m_info_text; long m_zoomFactor; diff --git a/src/slic3r/GUI/Widgets/AMSItem.hpp b/src/slic3r/GUI/Widgets/AMSItem.hpp index c43c71f580..ac4283b7e8 100644 --- a/src/slic3r/GUI/Widgets/AMSItem.hpp +++ b/src/slic3r/GUI/Widgets/AMSItem.hpp @@ -641,17 +641,11 @@ private: AMSRoadShowMode m_road_mode = {AMSRoadShowMode::AMS_ROAD_MODE_FOUR}; AMSPassRoadSTEP m_load_step = {AMSPassRoadSTEP::AMS_ROAD_STEP_NONE}; - bool m_selected = {false}; - int m_passroad_width = {6}; - double m_radius = {4}; wxColour m_road_def_color; wxColour m_road_color; std::vector ams_humidity_img; - int m_humidity = {0}; - bool m_show_humidity = {false}; - bool m_vams_loading{false}; AMSModel m_ams_model; }; @@ -690,14 +684,10 @@ private: int m_left_road_length = {-1}; int m_right_road_length = {-1}; - int m_passroad_width = {6}; - double m_radius = {4}; AMSPassRoadSTEP m_pass_road_left_step = {AMSPassRoadSTEP::AMS_ROAD_STEP_NONE}; AMSPassRoadSTEP m_pass_road_right_step = {AMSPassRoadSTEP::AMS_ROAD_STEP_NONE}; std::map m_road_color; - bool m_vams_loading{false}; - AMSModel m_ams_model; }; /************************************************* diff --git a/src/slic3r/GUI/Widgets/Button.cpp b/src/slic3r/GUI/Widgets/Button.cpp index 5a5bd89403..22b1c34cab 100644 --- a/src/slic3r/GUI/Widgets/Button.cpp +++ b/src/slic3r/GUI/Widgets/Button.cpp @@ -162,6 +162,15 @@ void Button::SetCenter(bool isCenter) { this->isCenter = isCenter; } +void Button::SetIndicator(bool on) +{ + if (m_show_indicator == on) + return; + m_show_indicator = on; + messureSize(); + Refresh(); +} + void Button::SetVertical(bool vertical) { this->vertical = vertical; @@ -324,6 +333,13 @@ void Button::render(wxDC& dc) szContent.x -= d; } } + if (m_show_indicator) { + const int dot = FromDIP(6); + if (vertical) + szContent.y += dot + FromDIP(6); + else + szContent.x += dot + FromDIP(6); + } // move to center wxRect rcContent = { {0, 0}, size }; if (isCenter) { @@ -364,6 +380,17 @@ void Button::render(wxDC& dc) #endif dc.DrawText(text, pt); } + if (m_show_indicator) { + const int dot = FromDIP(6); // diameter + wxPoint dot_pt; + dot_pt.x = pt.x + (text.IsEmpty() ? 0 : textSize.x) + FromDIP(6) + dot / 2; + // Centre on the content vertically; a bitmap-only (empty-label) tab has no text row. + dot_pt.y = text.IsEmpty() ? rcContent.y + rcContent.height / 2 : pt.y + textSize.y / 2; + const wxColour c = StateColor::darkModeColorFor(m_indicator_color); + dc.SetBrush(wxBrush(c)); + dc.SetPen(wxPen(c)); + dc.DrawCircle(dot_pt, dot / 2); + } } void Button::messureSize() @@ -388,6 +415,14 @@ void Button::messureSize() if (szIcon.y > szContent.y) szContent.y = szIcon.y; } } + if (m_show_indicator) { + // Indicator dot sits to the right of the label: its diameter plus the gap from the text. + const int dot = FromDIP(6); + if (vertical) + szContent.y += dot + FromDIP(6); + else + szContent.x += dot + FromDIP(6); + } wxSize size = szContent + paddingSize * 2; if (minSize.GetHeight() > 0) size.SetHeight(minSize.GetHeight()); diff --git a/src/slic3r/GUI/Widgets/Button.hpp b/src/slic3r/GUI/Widgets/Button.hpp index 2991edd425..758a9a4009 100644 --- a/src/slic3r/GUI/Widgets/Button.hpp +++ b/src/slic3r/GUI/Widgets/Button.hpp @@ -4,28 +4,30 @@ #include "../wxExtensions.hpp" #include "StaticBox.hpp" #include +#include class ButtonProps { public: - static int ChoiceButtonGap(){return 10;}; - static int WindowButtonGap(){return 10;}; + static int ChoiceButtonGap() { return 10; }; + static int WindowButtonGap() { return 10; }; }; -enum class ButtonStyle{ +enum class ButtonStyle { Regular, Confirm, Alert, Disabled, }; -enum class ButtonType{ - Compact , // Font10 FullyRounded For spaces with less areas - Window , // Font12 FullyRounded For regular buttons in windows and not related with parameter boxes - Choice , // Font14 Semi-Rounded For dialog/window choice buttons +enum class ButtonType { + Compact, // Font10 FullyRounded For spaces with less areas + Window, // Font12 FullyRounded For regular buttons in windows and not related with parameter boxes + Choice, // Font14 Semi-Rounded For dialog/window choice buttons Parameter, // Font14 Semi-Rounded For buttons that near parameter boxes - Icon , // ------ Semi-Rounded For buttons that only has icons. icons should be 16x16 and iconSize has to be defined as 16 while creation of button - Expanded , // Font14 Semi-Rounded For full length buttons. ex. buttons in static box + Icon, // ------ Semi-Rounded For buttons that only has icons. icons should be 16x16 and iconSize has to be defined as 16 while + // creation of button + Expanded, // Font14 Semi-Rounded For full length buttons. ex. buttons in static box }; class Button : public StaticBox @@ -36,15 +38,17 @@ class Button : public StaticBox wxSize paddingSize; ScalableBitmap active_icon; - StateColor text_color; + StateColor text_color; bool pressedDown = false; bool m_selected = true; - bool canFocus = true; + bool canFocus = true; bool isCenter = true; bool vertical = false; + bool m_show_indicator = false; + wxColour m_indicator_color = wxColour("#009688"); - static const int buttonWidth = 200; + static const int buttonWidth = 200; static const int buttonHeight = 50; public: @@ -68,19 +72,23 @@ public: void SetStyle(const ButtonStyle style /*= ButtonStyle::Regular*/, const ButtonType type /*= ButtonType::None*/); - void SetTextColor(StateColor const &color); + void SetTextColor(StateColor const& color); - void SetTextColorNormal(wxColor const &color); + void SetTextColorNormal(wxColor const& color); void SetSelected(bool selected = true) { m_selected = selected; } + // Show a small coloured dot to the right of the label (used by TabCtrl tabs to flag that + // the tab's category has a selected/toggled setting). + void SetIndicator(bool on); + // Only meant to be used by inspector, not public API ButtonStyle GetStyle() const { return m_style; } - ButtonType GetType() const { return m_type; } - bool IsSelected() const { return m_selected; } + ButtonType GetType() const { return m_type; } + bool IsSelected() const { return m_selected; } bool Enable(bool enable = true) override; - void EnableTooltipEvenDisabled();// The tip will be shown even if the button is disabled + void EnableTooltipEvenDisabled(); // The tip will be shown even if the button is disabled void SetCanFocus(bool canFocus) override; @@ -104,7 +112,7 @@ protected: private: bool m_has_style = false; ButtonStyle m_style; - ButtonType m_type; + ButtonType m_type; void paintEvent(wxPaintEvent& evt); @@ -115,10 +123,10 @@ private: // some useful events void mouseDown(wxMouseEvent& event); void mouseReleased(wxMouseEvent& event); - void mouseCaptureLost(wxMouseCaptureLostEvent &event); - void keyDownUp(wxKeyEvent &event); + void mouseCaptureLost(wxMouseCaptureLostEvent& event); + void keyDownUp(wxKeyEvent& event); - // + // void sendButtonEvent(); // parent motion diff --git a/src/slic3r/GUI/Widgets/CheckList.cpp b/src/slic3r/GUI/Widgets/CheckList.cpp index cd0dcebff3..c0101441ff 100644 --- a/src/slic3r/GUI/Widgets/CheckList.cpp +++ b/src/slic3r/GUI/Widgets/CheckList.cpp @@ -1,6 +1,7 @@ #include "CheckList.hpp" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/I18N.hpp" CheckList::CheckList( wxWindow* parent, diff --git a/src/slic3r/GUI/Widgets/FanControl.cpp b/src/slic3r/GUI/Widgets/FanControl.cpp index f10553814e..7efdfabf07 100644 --- a/src/slic3r/GUI/Widgets/FanControl.cpp +++ b/src/slic3r/GUI/Widgets/FanControl.cpp @@ -995,7 +995,7 @@ void FanControlPopupNew::init_names(MachineObject* obj) { radio_btn_name[AIR_DUCT::AIR_DUCT_HEATING_INTERNAL_FILT] = _L("Heating"); radio_btn_name[AIR_DUCT::AIR_DUCT_EXHAUST] = _L("Exhaust"); radio_btn_name[AIR_DUCT::AIR_DUCT_FULL_COOLING] = _L("Full Cooling"); - radio_btn_name[AIR_DUCT::AIR_DUCT_INIT] = L("Init"); + radio_btn_name[AIR_DUCT::AIR_DUCT_INIT] = _L("Init"); air_door_func_name[AIR_DOOR::AIR_DOOR_FUNC_CHAMBER] = _L("Chamber"); air_door_func_name[AIR_DOOR::AIR_DOOR_FUNC_INNERLOOP] = _L("Innerloop"); diff --git a/src/slic3r/GUI/Widgets/FanControl.hpp b/src/slic3r/GUI/Widgets/FanControl.hpp index b13ce19d41..2badbf8594 100644 --- a/src/slic3r/GUI/Widgets/FanControl.hpp +++ b/src/slic3r/GUI/Widgets/FanControl.hpp @@ -212,7 +212,6 @@ private: wxGridSizer* m_sizer_fanControl { nullptr }; wxBoxSizer *m_mode_sizer{ nullptr }; - wxBoxSizer *m_bottom_sizer{ nullptr }; // mode switch buttons std::unordered_map m_mode_switch_btns; // diff --git a/src/slic3r/GUI/Widgets/MultiNozzleSync.cpp b/src/slic3r/GUI/Widgets/MultiNozzleSync.cpp index 20944df553..9278565c47 100644 --- a/src/slic3r/GUI/Widgets/MultiNozzleSync.cpp +++ b/src/slic3r/GUI/Widgets/MultiNozzleSync.cpp @@ -20,6 +20,8 @@ #include #include +#include +#include #include #include diff --git a/src/slic3r/GUI/Widgets/MultiNozzleSync.hpp b/src/slic3r/GUI/Widgets/MultiNozzleSync.hpp index 3baea061dc..3e2e41ccb7 100644 --- a/src/slic3r/GUI/Widgets/MultiNozzleSync.hpp +++ b/src/slic3r/GUI/Widgets/MultiNozzleSync.hpp @@ -89,8 +89,6 @@ private: bool m_right_on{ true }; wxStaticBitmap* badget; - Label* left; - Label* right; Label* left_diameter_desp; Label* right_diameter_desp; Label* left_flow_desp; diff --git a/src/slic3r/GUI/Widgets/ProgressDialog.cpp b/src/slic3r/GUI/Widgets/ProgressDialog.cpp index 53842ecaea..c16f3bcf1f 100644 --- a/src/slic3r/GUI/Widgets/ProgressDialog.cpp +++ b/src/slic3r/GUI/Widgets/ProgressDialog.cpp @@ -178,7 +178,7 @@ bool ProgressDialog::Create(const wxString &title, const wxString &message, int wxBoxSizer *sizer_1line = new wxBoxSizer(wxHORIZONTAL); m_msg = new wxStaticText(m_panel_1line, wxID_ANY, wxEmptyString, wxDefaultPosition, PROGRESSDIALOG_SIMPLEBOOK_SIZE, 0); m_msg->Wrap(-1); - m_msg->SetFont(::Label::Body_13); + m_msg->SetFont(::Label::Body_14); m_msg->SetForegroundColour(PROGRESSDIALOG_GREY_700); sizer_1line->Add(m_msg, 0, wxALIGN_CENTER, 0); m_panel_1line->SetSizer(sizer_1line); @@ -188,7 +188,7 @@ bool ProgressDialog::Create(const wxString &title, const wxString &message, int wxBoxSizer *sizer_2line = new wxBoxSizer(wxVERTICAL); m_msg_2line = new wxStaticText(m_panel_2line, wxID_ANY, wxEmptyString, wxDefaultPosition, PROGRESSDIALOG_SIMPLEBOOK_SIZE, 0); m_msg_2line->Wrap(PROGRESSDIALOG_SIMPLEBOOK_SIZE.x); - m_msg_2line->SetFont(::Label::Body_13); + m_msg_2line->SetFont(::Label::Body_14); m_msg_2line->SetForegroundColour(PROGRESSDIALOG_GREY_700); m_msg_2line->SetMaxSize(wxSize(PROGRESSDIALOG_SIMPLEBOOK_SIZE.x, -1)); sizer_2line->Add(m_msg_2line, 1, wxALL, 0); @@ -204,7 +204,7 @@ bool ProgressDialog::Create(const wxString &title, const wxString &message, int m_msg = new wxStaticText(m_msg_scrolledWindow, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize(PROGRESSDIALOG_SIMPLEBOOK_SIZE.x, -1), 0); m_msg->Wrap(PROGRESSDIALOG_SIMPLEBOOK_SIZE.x); - m_msg->SetFont(::Label::Body_13); + m_msg->SetFont(::Label::Body_14); m_msg->SetForegroundColour(PROGRESSDIALOG_GREY_700); m_msg_sizer->Add(m_msg, 0, wxEXPAND | wxALL, 0); @@ -228,6 +228,9 @@ bool ProgressDialog::Create(const wxString &title, const wxString &message, int if (!HasPDFlag(wxPD_NO_PROGRESS)) { m_gauge = new wxGauge(this, wxID_ANY, maximum, wxDefaultPosition, PROGRESSDIALOG_GAUGE_SIZE, gauge_style); m_gauge->SetValue(0); + m_gauge->SetForegroundColour(wxColour("#009688")); + m_gauge->SetBackgroundColour(wxColour("#D9D9D9")); + wxGetApp().UpdateDarkUI(m_gauge); m_sizer_main->Add(m_gauge, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(28)); } diff --git a/src/slic3r/GUI/Widgets/ProgressDialog.hpp b/src/slic3r/GUI/Widgets/ProgressDialog.hpp index bb770298a9..9ebf31b194 100644 --- a/src/slic3r/GUI/Widgets/ProgressDialog.hpp +++ b/src/slic3r/GUI/Widgets/ProgressDialog.hpp @@ -18,7 +18,7 @@ class WXDLLIMPEXP_FWD_CORE wxWindowDisabler; #define PROGRESSDIALOG_GAUGE_SIZE wxSize(FromDIP(320), FromDIP(6)) #define PROGRESSDIALOG_CANCEL_BUTTON_SIZE wxSize(FromDIP(60), FromDIP(24)) #define PROGRESSDIALOG_DEF_BK wxColour(255,255,255) -#define PROGRESSDIALOG_GREY_700 wxColour(107,107,107) +#define PROGRESSDIALOG_GREY_700 wxColour(54,54,54) // #363636 label color #define wxPD_NO_PROGRESS 0x0100 diff --git a/src/slic3r/GUI/Widgets/StaticBox.cpp b/src/slic3r/GUI/Widgets/StaticBox.cpp index 6391c1a8c4..f6e7ca67f6 100644 --- a/src/slic3r/GUI/Widgets/StaticBox.cpp +++ b/src/slic3r/GUI/Widgets/StaticBox.cpp @@ -7,6 +7,7 @@ BEGIN_EVENT_TABLE(StaticBox, wxWindow) // catch paint events //EVT_ERASE_BACKGROUND(StaticBox::eraseEvent) +EVT_SIZE(StaticBox::sizeEvent) EVT_PAINT(StaticBox::paintEvent) END_EVENT_TABLE() @@ -140,6 +141,12 @@ void StaticBox::eraseEvent(wxEraseEvent& evt) #endif } +void StaticBox::sizeEvent(wxSizeEvent& evt) +{ + Refresh(); + evt.Skip(); +} + void StaticBox::paintEvent(wxPaintEvent& evt) { // depending on your system you may need to look at double-buffered dcs diff --git a/src/slic3r/GUI/Widgets/StaticBox.hpp b/src/slic3r/GUI/Widgets/StaticBox.hpp index b7cdee34ef..363d431c8e 100644 --- a/src/slic3r/GUI/Widgets/StaticBox.hpp +++ b/src/slic3r/GUI/Widgets/StaticBox.hpp @@ -46,6 +46,8 @@ public: protected: void eraseEvent(wxEraseEvent& evt); + void sizeEvent(wxSizeEvent& evt); + void paintEvent(wxPaintEvent& evt); void render(wxDC& dc); diff --git a/src/slic3r/GUI/Widgets/TabCtrl.cpp b/src/slic3r/GUI/Widgets/TabCtrl.cpp index 882d2f6897..34de109b8f 100644 --- a/src/slic3r/GUI/Widgets/TabCtrl.cpp +++ b/src/slic3r/GUI/Widgets/TabCtrl.cpp @@ -2,8 +2,8 @@ #include -wxDEFINE_EVENT( wxEVT_TAB_SEL_CHANGING, wxCommandEvent ); -wxDEFINE_EVENT( wxEVT_TAB_SEL_CHANGED, wxCommandEvent ); +wxDEFINE_EVENT(wxEVT_TAB_SEL_CHANGING, wxCommandEvent); +wxDEFINE_EVENT(wxEVT_TAB_SEL_CHANGED, wxCommandEvent); BEGIN_EVENT_TABLE(TabCtrl, StaticBox) @@ -22,11 +22,7 @@ END_EVENT_TABLE() #define TAB_BUTTON_PADDING_Y 2 #define TAB_BUTTON_PADDING TAB_BUTTON_PADDING_X, TAB_BUTTON_PADDING_Y -TabCtrl::TabCtrl(wxWindow * parent, - wxWindowID id, - const wxPoint & pos, - const wxSize & size, - long style) +TabCtrl::TabCtrl(wxWindow* parent, wxWindowID id, const wxPoint& pos, const wxSize& size, long style) : StaticBox(parent, id, pos, size, style) { #if 0 @@ -42,14 +38,11 @@ TabCtrl::TabCtrl(wxWindow * parent, hsizer->Add(sizer, 0, wxEXPAND | wxBOTTOM, border_width * 4); SetSizer(hsizer); Bind(wxEVT_COMMAND_BUTTON_CLICKED, &TabCtrl::buttonClicked, this); - //wxString reason; - //IsTransparentBackgroundSupported(&reason); + // wxString reason; + // IsTransparentBackgroundSupported(&reason); } -TabCtrl::~TabCtrl() -{ - delete images; -} +TabCtrl::~TabCtrl() { delete images; } int TabCtrl::GetSelection() const { return sel; } @@ -75,15 +68,13 @@ void TabCtrl::SelectItem(int item) Refresh(); } -void TabCtrl::Unselect() -{ - SelectItem(-1); -} +void TabCtrl::Unselect() { SelectItem(-1); } void TabCtrl::Rescale() { - for (auto & b : btns) + for (auto& b : btns) b->Rescale(); + relayout(); } bool TabCtrl::SetFont(wxFont const& font) @@ -95,28 +86,32 @@ bool TabCtrl::SetFont(wxFont const& font) return true; } -int TabCtrl::AppendItem(const wxString &item, - int image, int selImage, - void * clientData) +int TabCtrl::AppendItem(const wxString& item, int image, int selImage, void* clientData) { - Button * btn = new Button(); + Button* btn = new Button(); btn->Create(this, item, "", wxBORDER_NONE); btn->SetFont(GetFont()); - btn->SetTextColor(StateColor( - std::make_pair(0x6B6B6C, (int) StateColor::NotChecked), - std::make_pair(*wxLIGHT_GREY, (int) StateColor::Normal))); + btn->SetTextColor( + StateColor(std::make_pair(0x6B6B6C, (int) StateColor::NotChecked), std::make_pair(wxColour("#262E30"), (int) StateColor::Normal))); btn->SetBackgroundColor(StateColor()); btn->SetCornerRadius(0); btn->SetPaddingSize({TAB_BUTTON_PADDING}); btns.push_back(btn); if (btns.size() > 1) sizer->GetItem(sizer->GetItemCount() - 1)->SetMinSize({0, 0}); - sizer->Add(btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, TAB_BUTTON_SPACE); + sizer->Add(btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, item_space); sizer->AddStretchSpacer(1); relayout(); return btns.size() - 1; } +int TabCtrl::AppendItem(const wxString& item, const wxBitmap& bitmap, void* clientData) +{ + const int index = AppendItem(item, -1, -1, clientData); + SetItemBitmap(index, bitmap); + return index; +} + bool TabCtrl::DeleteItem(int item) { if (item < 0 || item >= btns.size()) { @@ -136,7 +131,7 @@ bool TabCtrl::DeleteItem(int item) sizer->GetItem(sizer->GetItemCount() - 1)->SetMinSize({0, 0}); if (selection_changed) { - sel--; // `relayout()` uses `sel` so we need to update this before calling `relayout()` + sel--; // `relayout()` uses `sel` so we need to update this before calling `relayout()` } relayout(); if (selection_changed) { @@ -159,75 +154,87 @@ void TabCtrl::DeleteAllItems() unsigned int TabCtrl::GetCount() const { return btns.size(); } -wxString TabCtrl::GetItemText(unsigned int item) const +wxString TabCtrl::GetItemText(unsigned int item) const { return item < btns.size() ? btns[item]->GetLabel() : wxString{}; } + +void TabCtrl::SetItemText(unsigned int item, wxString const& value) { - return item < btns.size() ? btns[item]->GetLabel() : wxString{}; + if (item >= btns.size()) + return; + btns[item]->SetLabel(value); } -void TabCtrl::SetItemText(unsigned int item, wxString const &value) +void TabCtrl::SetItemBitmap(unsigned int item, const wxBitmap& bitmap) { - if (item >= btns.size()) return; - btns[item]->SetLabel(value); + if (item >= btns.size()) + return; + btns[item]->SetIcon(bitmap); + relayout(); +} + +void TabCtrl::SetItemIndicator(unsigned int item, bool on) +{ + if (item >= btns.size()) + return; + btns[item]->SetIndicator(on); + relayout(); } bool TabCtrl::GetItemBold(unsigned int item) const { - if (item >= btns.size()) return false; + if (item >= btns.size()) + return false; return btns[item]->GetFont() == bold; } void TabCtrl::SetItemBold(unsigned int item, bool bold) { - if (item >= btns.size()) return; + if (item >= btns.size()) + return; btns[item]->SetFont(bold ? this->bold : GetFont()); btns[item]->Rescale(); } void* TabCtrl::GetItemData(unsigned int item) const { - if (item >= btns.size()) return nullptr; + if (item >= btns.size()) + return nullptr; return btns[item]->GetClientData(); } void TabCtrl::SetItemData(unsigned int item, void* clientData) { - if (item >= btns.size()) return; + if (item >= btns.size()) + return; btns[item]->SetClientData(clientData); } void TabCtrl::AssignImageList(wxImageList* imageList) { - if (images == imageList) return; + if (images == imageList) + return; delete images; images = imageList; } -void TabCtrl::SetItemTextColour(unsigned int item, const StateColor &col) +void TabCtrl::SetItemTextColour(unsigned int item, const StateColor& col) { - if (item >= btns.size()) return; + if (item >= btns.size()) + return; btns[item]->SetTextColor(col); } -int TabCtrl::GetFirstVisibleItem() const -{ - return btns.size() == 0 ? -1 : 0; -} +int TabCtrl::GetFirstVisibleItem() const { return btns.size() == 0 ? -1 : 0; } -int TabCtrl::GetNextVisible(int item) const -{ - return ++item < btns.size() ? item : -1; -} +int TabCtrl::GetNextVisible(int item) const { return ++item < btns.size() ? item : -1; } -bool TabCtrl::IsVisible(unsigned int item) const -{ - return true; -} +bool TabCtrl::IsVisible(unsigned int item) const { return true; } void TabCtrl::DoSetSize(int x, int y, int width, int height, int sizeFlags) { auto size = GetSize(); wxWindow::DoSetSize(x, y, width, height, sizeFlags); - if (size == GetSize()) return; + if (size == GetSize()) + return; relayout(); } @@ -235,7 +242,9 @@ void TabCtrl::DoSetSize(int x, int y, int width, int height, int sizeFlags) WXLRESULT TabCtrl::MSWWindowProc(WXUINT nMsg, WXWPARAM wParam, WXLPARAM lParam) { - if (nMsg == WM_GETDLGCODE) { return DLGC_WANTARROWS; } + if (nMsg == WM_GETDLGCODE) { + return DLGC_WANTARROWS; + } return wxWindow::MSWWindowProc(nMsg, wParam, lParam); } @@ -244,15 +253,15 @@ WXLRESULT TabCtrl::MSWWindowProc(WXUINT nMsg, WXWPARAM wParam, WXLPARAM lParam) void TabCtrl::relayout() { int offset = 10; - int item = sel + 1; - int first = 0; + int item = sel + 1; + int first = 0; for (int i = 0; i < item; ++i) - offset += btns[i]->GetMinSize().x + TAB_BUTTON_SPACE * 2; + offset += btns[i]->GetMinSize().x + item_space * 2; if (item < btns.size()) - offset += btns[item]->GetMinSize().x + TAB_BUTTON_SPACE * 2; - int width = GetSize().x; + offset += btns[item]->GetMinSize().x + item_space * 2; + int width = GetSize().x; for (int i = 0; i < btns.size(); ++i) { - auto size = btns[i]->GetMinSize().x + TAB_BUTTON_SPACE * 2; + auto size = btns[i]->GetMinSize().x + item_space * 2; if (i < sel && offset > width) { sizer->Show(i * 2 + 1, false); sizer->Show(i * 2 + 2, false); @@ -273,14 +282,32 @@ void TabCtrl::relayout() sizer->GetItem(i * 2 + 2)->SetMinSize({0, 0}); } if (item >= btns.size()) - -- item; + --item; // Keep spacing 2 ~ 10 TAB_BUTTON_SPACE - int b = GetSize().x - offset - 10 - (item + 1 - first) * TAB_BUTTON_SPACE * 8; + int b = GetSize().x - offset - 10 - (item + 1 - first) * item_space * 8; sizer->GetItem(item * 2 + 2)->SetMinSize({b > 0 ? b : 0, 0}); Layout(); } -void TabCtrl::buttonClicked(wxCommandEvent &event) +void TabCtrl::SetItemSpace(int space) +{ + if (space < 0 || space == item_space) + return; + item_space = space; + relayout(); + Refresh(); +} + +int TabCtrl::GetFullSize() const +{ + // Mirrors relayout(): a 10px leading spacer plus every button's min width and spacing. + int width = 10; + for (const Button* btn : btns) + width += btn->GetMinSize().x + item_space * 2; + return width; +} + +void TabCtrl::buttonClicked(wxCommandEvent& event) { SetFocus(); auto btn = event.GetEventObject(); @@ -288,7 +315,7 @@ void TabCtrl::buttonClicked(wxCommandEvent &event) SelectItem(iter == btns.end() ? -1 : iter - btns.begin()); } -void TabCtrl::keyDown(wxKeyEvent &event) +void TabCtrl::keyDown(wxKeyEvent& event) { switch (event.GetKeyCode()) { case WXK_UP: @@ -307,11 +334,13 @@ void TabCtrl::keyDown(wxKeyEvent &event) void TabCtrl::doRender(wxDC& dc) { wxSize size = GetSize(); - int states = state_handler.states(); - if (sel < 0) { return; } + int states = state_handler.states(); + if (sel < 0) { + return; + } - auto x1 = btns[sel]->GetPosition().x; - auto x2 = x1 + btns[sel]->GetSize().x; + auto x1 = btns[sel]->GetPosition().x; + auto x2 = x1 + btns[sel]->GetSize().x; const int BS2 = (1 + border_width) / 2; #if 0 const int BS = border_width / 2; diff --git a/src/slic3r/GUI/Widgets/TabCtrl.hpp b/src/slic3r/GUI/Widgets/TabCtrl.hpp index 04b5b8e24e..493c4edee5 100644 --- a/src/slic3r/GUI/Widgets/TabCtrl.hpp +++ b/src/slic3r/GUI/Widgets/TabCtrl.hpp @@ -3,32 +3,30 @@ #include "Button.hpp" -wxDECLARE_EVENT( wxEVT_TAB_SEL_CHANGING, wxCommandEvent ); -wxDECLARE_EVENT( wxEVT_TAB_SEL_CHANGED, wxCommandEvent ); +wxDECLARE_EVENT(wxEVT_TAB_SEL_CHANGING, wxCommandEvent); +wxDECLARE_EVENT(wxEVT_TAB_SEL_CHANGED, wxCommandEvent); class TabCtrl : public StaticBox { std::vector btns; wxImageList* images = nullptr; - wxBoxSizer * sizer = nullptr; + wxBoxSizer* sizer = nullptr; int sel = -1; wxFont bold; + int item_space = 2; // space around each button, both sides (SetItemSpace) public: - TabCtrl(wxWindow * parent, - wxWindowID id, - const wxPoint & pos = wxDefaultPosition, - const wxSize & size = wxDefaultSize, - long style = 0); + TabCtrl(wxWindow* parent, wxWindowID id, const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize, long style = 0); ~TabCtrl(); public: - virtual bool SetFont(wxFont const & font) override; + virtual bool SetFont(wxFont const& font) override; public: - int AppendItem(const wxString &item, int image = -1, int selImage = -1, void *clientData = nullptr); + int AppendItem(const wxString& item, int image = -1, int selImage = -1, void* clientData = nullptr); + int AppendItem(const wxString& item, const wxBitmap& bitmap, void* clientData = nullptr); bool DeleteItem(int item); @@ -36,7 +34,7 @@ public: unsigned int GetCount() const; - int GetSelection() const; + int GetSelection() const; void SelectItem(int item); @@ -45,15 +43,19 @@ public: virtual void Rescale(); wxString GetItemText(unsigned int item) const; - void SetItemText(unsigned int item, wxString const &value); + void SetItemText(unsigned int item, wxString const& value); + void SetItemBitmap(unsigned int item, const wxBitmap& bitmap); - bool GetItemBold(unsigned int item) const; - void SetItemBold(unsigned int item, bool bold); + // Show/hide the small "has selection" dot next to a tab's text. + void SetItemIndicator(unsigned int item, bool on); - void* GetItemData(unsigned int item) const; - void SetItemData(unsigned int item, void *clientData); - - void AssignImageList(wxImageList *imageList); + bool GetItemBold(unsigned int item) const; + void SetItemBold(unsigned int item, bool bold); + + void* GetItemData(unsigned int item) const; + void SetItemData(unsigned int item, void* clientData); + + void AssignImageList(wxImageList* imageList); void SetItemTextColour(unsigned int item, const StateColor& col); @@ -62,6 +64,12 @@ public: int GetNextVisible(int item) const; bool IsVisible(unsigned int item) const; + // Extra space around each tab button (in px on both sides). Defaults to the control-wide + // standard; call before appending items so every button picks it up. + void SetItemSpace(int space); + + int GetFullSize() const; + private: virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO) override; @@ -71,10 +79,10 @@ private: void relayout(); - void buttonClicked(wxCommandEvent & event); - void keyDown(wxKeyEvent &event); + void buttonClicked(wxCommandEvent& event); + void keyDown(wxKeyEvent& event); - void doRender(wxDC & dc) override; + void doRender(wxDC& dc) override; // some useful events bool sendTabCtrlEvent(bool changing = false); diff --git a/src/slic3r/GUI/Widgets/WebView.cpp b/src/slic3r/GUI/Widgets/WebView.cpp index e281d97407..a29a3cb725 100644 --- a/src/slic3r/GUI/Widgets/WebView.cpp +++ b/src/slic3r/GUI/Widgets/WebView.cpp @@ -1,9 +1,13 @@ #include "WebView.hpp" +#include "slic3r/GUI/Widgets/StateColor.hpp" #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/Utils/MacDarkMode.hpp" #include +#include +#include + #include #include #if wxUSE_WEBVIEW_EDGE @@ -12,6 +16,8 @@ #include #endif #include +#include +#include #if defined(__WIN32__) || defined(__WXMAC__) #include "wx/private/jsscriptwrapper.h" #endif @@ -73,7 +79,7 @@ DWORD DownloadAndInstallWV2RT() { }) .perform_sync(); // Sleep for 1 second to wait for the buffer writen into disk - std::this_thread::sleep_for(1000ms); + std::this_thread::sleep_for(std::chrono::milliseconds(1000)); if (downloaded) { // Either Package the WebView2 Bootstrapper with your app or download it using fwlink // Then invoke install at Runtime. diff --git a/src/slic3r/GUI/WipeTowerDialog.cpp b/src/slic3r/GUI/WipeTowerDialog.cpp index d4fbcc6fe3..9a473c7c8a 100644 --- a/src/slic3r/GUI/WipeTowerDialog.cpp +++ b/src/slic3r/GUI/WipeTowerDialog.cpp @@ -6,6 +6,7 @@ #include "GUI.hpp" #include "I18N.hpp" #include "GUI_App.hpp" +#include "WebViewDialog.hpp" #include "MsgDialog.hpp" #include "format.hpp" #include "libslic3r/Color.hpp" diff --git a/src/slic3r/Utils/3DPrinterOS.cpp b/src/slic3r/Utils/3DPrinterOS.cpp index 61fcc80d5b..503dbe63e3 100755 --- a/src/slic3r/Utils/3DPrinterOS.cpp +++ b/src/slic3r/Utils/3DPrinterOS.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -30,6 +31,8 @@ #include +using json = nlohmann::json; + namespace fs = boost::filesystem; namespace pt = boost::property_tree; diff --git a/src/slic3r/Utils/BBLCloudServiceAgent.cpp b/src/slic3r/Utils/BBLCloudServiceAgent.cpp index 846e4ce509..801ad1cf57 100644 --- a/src/slic3r/Utils/BBLCloudServiceAgent.cpp +++ b/src/slic3r/Utils/BBLCloudServiceAgent.cpp @@ -8,6 +8,9 @@ #include #include #include + +using json = nlohmann::json; + namespace Slic3r { diff --git a/src/slic3r/Utils/BBLNetworkPlugin.cpp b/src/slic3r/Utils/BBLNetworkPlugin.cpp index 607e7d16d1..d795abf354 100644 --- a/src/slic3r/Utils/BBLNetworkPlugin.cpp +++ b/src/slic3r/Utils/BBLNetworkPlugin.cpp @@ -349,7 +349,7 @@ void* BBLNetworkPlugin::get_function(const char* name) return function; #if defined(_MSC_VER) || defined(_WIN32) - function = GetProcAddress(m_networking_module, name); + function = reinterpret_cast(GetProcAddress(m_networking_module, name)); #else function = dlsym(m_networking_module, name); #endif diff --git a/src/slic3r/Utils/BBLPrinterAgent.cpp b/src/slic3r/Utils/BBLPrinterAgent.cpp index 5e73edf84c..0c6225cc55 100644 --- a/src/slic3r/Utils/BBLPrinterAgent.cpp +++ b/src/slic3r/Utils/BBLPrinterAgent.cpp @@ -8,6 +8,7 @@ #include using json = nlohmann::json; +#include #include namespace Slic3r { @@ -90,6 +91,16 @@ OnMessageFn to_orca_messages(OnMessageFn fn) return [fn = std::move(fn)](std::string dev_id, std::string msg) { fn(std::move(dev_id), BBLPrinterAgent::to_orca_payload(std::move(msg))); }; } +// Retypes a plug-in entry point for an older plug-in generation. The detour through the +// generic function pointer marks the signature change as deliberate, which a direct cast +// between two signatures does not. +template +To as_abi(From fn) +{ + static_assert(std::is_function_v>, "as_abi retypes a function pointer"); + return reinterpret_cast(reinterpret_cast(fn)); +} + } // namespace std::string BBLPrinterAgent::to_orca_filament_id(const std::string& printer_filament_id) const @@ -141,7 +152,7 @@ int BBLPrinterAgent::send_message(std::string dev_id, std::string json_str, int // series through the legacy form would silently drop MessageFlag sign/encrypt. switch (plugin.network_abi()) { case NetworkAbi::Legacy: { - auto legacy_func = reinterpret_cast(func); + auto legacy_func = as_abi(func); return legacy_func(agent, std::move(dev_id), std::move(json_str), qos); } case NetworkAbi::V0203: @@ -185,7 +196,7 @@ int BBLPrinterAgent::send_message_to_printer(std::string dev_id, std::string jso if (func && agent) { switch (plugin.network_abi()) { case NetworkAbi::Legacy: { - auto legacy_func = reinterpret_cast(func); + auto legacy_func = as_abi(func); return legacy_func(agent, std::move(dev_id), std::move(json_str), qos); } case NetworkAbi::V0203: @@ -275,7 +286,7 @@ int BBLPrinterAgent::bind(std::string dev_ip, std::string dev_id, std::string de switch (plugin.network_abi()) { case NetworkAbi::Legacy: case NetworkAbi::V0203: { - auto older_func = reinterpret_cast(func); + auto older_func = as_abi(func); return older_func(agent, dev_ip, dev_id, sec_link, timezone, improved, update_fn); } case NetworkAbi::Current: @@ -436,9 +447,9 @@ int dispatch_start(CurrentFn func, PrintParams& params, const CallbackFns&... ca params.ams_mapping_info = BBLPrinterAgent::from_orca_payload(std::move(params.ams_mapping_info)); switch (plugin.network_abi()) { case NetworkAbi::Legacy: - return reinterpret_cast(func)(agent, BBLNetworkPlugin::as_legacy(params), callbacks...); + return as_abi(func)(agent, BBLNetworkPlugin::as_legacy(params), callbacks...); case NetworkAbi::V0203: - return reinterpret_cast(func)(agent, BBLNetworkPlugin::as_0203(params), callbacks...); + return as_abi(func)(agent, BBLNetworkPlugin::as_0203(params), callbacks...); case NetworkAbi::Current: return func(agent, std::move(params), callbacks...); default: diff --git a/src/slic3r/Utils/CalibUtils.cpp b/src/slic3r/Utils/CalibUtils.cpp index 499228d13c..25aad85d2f 100644 --- a/src/slic3r/Utils/CalibUtils.cpp +++ b/src/slic3r/Utils/CalibUtils.cpp @@ -3,6 +3,7 @@ #include "../GUI/GUI_App.hpp" #include "../GUI/DeviceCore/DevStorage.h" #include "../GUI/DeviceManager.hpp" +#include "NetworkAgent.hpp" #include "../GUI/Jobs/ProgressIndicator.hpp" #include "../GUI/PartPlate.hpp" #include "libslic3r/CutUtils.hpp" diff --git a/src/slic3r/Utils/CloudProvider.hpp b/src/slic3r/Utils/CloudProvider.hpp new file mode 100644 index 0000000000..0f03683222 --- /dev/null +++ b/src/slic3r/Utils/CloudProvider.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace Slic3r { + +// Identifiers of the cloud services an ICloudServiceAgent can stand for. +static const std::string ORCA_CLOUD_PROVIDER("orca"); +static const std::string BBL_CLOUD_PROVIDER("bbl"); + +} // namespace Slic3r diff --git a/src/slic3r/Utils/CrealityPrintAgent.cpp b/src/slic3r/Utils/CrealityPrintAgent.cpp index 9b3bd5843e..f340a61267 100644 --- a/src/slic3r/Utils/CrealityPrintAgent.cpp +++ b/src/slic3r/Utils/CrealityPrintAgent.cpp @@ -12,6 +12,8 @@ #include #include +using json = nlohmann::json; + namespace Slic3r { namespace { diff --git a/src/slic3r/Utils/Http.cpp b/src/slic3r/Utils/Http.cpp index f1ff056d10..6f43df74e4 100644 --- a/src/slic3r/Utils/Http.cpp +++ b/src/slic3r/Utils/Http.cpp @@ -254,10 +254,8 @@ int Http::priv::xfercb(void *userp, curl_off_t dltotal, curl_off_t dlnow, curl_o bool cb_cancel = false; if (self->progressfn) { - double speed; + double speed = 0.; curl_easy_getinfo(self->curl, CURLINFO_SPEED_UPLOAD, &speed); - if (speed > 0.01) - speed = speed; Progress progress(dltotal, dlnow, ultotal, ulnow, self->buffer, speed); self->progressfn(progress, cb_cancel); } @@ -323,8 +321,10 @@ void Http::priv::form_add_file(const char *name, const fs::path &path, const cha // We can't use CURLFORM_FILECONTENT, because curl doesn't support Unicode filenames on Windows // and so we use CURLFORM_STREAM with boost ifstream to read the file. + std::string filename_str; if (filename == nullptr) { - filename = path.string().c_str(); + filename_str = path.string(); + filename = filename_str.c_str(); } form_files.emplace_back(path, offset, length); diff --git a/src/slic3r/Utils/ICloudServiceAgent.hpp b/src/slic3r/Utils/ICloudServiceAgent.hpp index 556c253641..7d326eb4c6 100644 --- a/src/slic3r/Utils/ICloudServiceAgent.hpp +++ b/src/slic3r/Utils/ICloudServiceAgent.hpp @@ -2,6 +2,7 @@ #define __I_CLOUD_SERVICE_AGENT_HPP__ #include "bambu_networking.hpp" +#include "CloudProvider.hpp" #include "../../libslic3r/ProjectTask.hpp" #include #include @@ -37,9 +38,6 @@ namespace Slic3r { * implementation. */ -static const std::string ORCA_CLOUD_PROVIDER("orca"); -static const std::string BBL_CLOUD_PROVIDER("bbl"); - struct CloudEvent { std::string provider; // ORCA_CLOUD_PROVIDER or BBL_CLOUD_PROVIDER }; diff --git a/src/slic3r/Utils/MacDarkMode.mm b/src/slic3r/Utils/MacDarkMode.mm index cecd90044b..2bce7835e8 100644 --- a/src/slic3r/Utils/MacDarkMode.mm +++ b/src/slic3r/Utils/MacDarkMode.mm @@ -57,7 +57,7 @@ void set_miniaturizable(void * window) while(viewObject = (NSView *)[viewEnum nextObject]) { if([viewObject class] == [NSTextField self]) { //[(NSTextField*)viewObject setTextColor : NSColor.whiteColor]; - mainframe_text_field = viewObject; + mainframe_text_field = (NSTextField*)viewObject; } } } @@ -74,7 +74,7 @@ void set_title_colour_after_set_title(void * window) while(viewObject = (NSView *)[viewEnum nextObject]) { if([viewObject class] == [NSTextField self]) { [(NSTextField*)viewObject setTextColor : NSColor.whiteColor]; - mainframe_text_field = viewObject; + mainframe_text_field = (NSTextField*)viewObject; } } diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp index 571d707a9f..384a69d51e 100644 --- a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp +++ b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp @@ -3,6 +3,7 @@ #include "libslic3r/Preset.hpp" #include "libslic3r/PresetBundle.hpp" #include "slic3r/GUI/GUI_App.hpp" +#include "slic3r/GUI/DeviceManager.hpp" #include "slic3r/GUI/DeviceCore/DevFilaSystem.h" #include "slic3r/GUI/DeviceCore/DevManager.h" #include "../GUI/DeviceCore/DevStorage.h" diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp index 4bfe429cd3..5d129a490e 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include diff --git a/src/slic3r/Utils/PresetUpdater.cpp b/src/slic3r/Utils/PresetUpdater.cpp index 663a4ce8c3..69bfa4217e 100644 --- a/src/slic3r/Utils/PresetUpdater.cpp +++ b/src/slic3r/Utils/PresetUpdater.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -29,6 +30,7 @@ #include "libslic3r/format.hpp" #include "libslic3r/Utils.hpp" #include "libslic3r/PresetBundle.hpp" +#include "libslic3r/PresetCacheFormat.hpp" #include "libslic3r_version.h" #include "slic3r/GUI/GUI.hpp" #include "slic3r/GUI/GUI_App.hpp" @@ -40,6 +42,7 @@ #include "slic3r/GUI/format.hpp" #include "slic3r/GUI/NotificationManager.hpp" #include "slic3r/Utils/Http.hpp" +#include "slic3r/Utils/bambu_networking.hpp" #include "slic3r/Config/Version.hpp" #include "slic3r/Config/Snapshot.hpp" #include "slic3r/GUI/MarkdownTip.hpp" @@ -96,6 +99,8 @@ struct Update bool forced_update; //BBS: add directory support bool is_directory {false}; + // Orca: a vendor update may be the cache-only form. + bool is_opc {false}; Update() {} //BBS: add directory support @@ -126,13 +131,24 @@ struct Update //BBS: add directory support void install() const { - if (is_directory) { + if (is_directory) { copy_directory_recursively(source, target, file_filter); - } - else { + } else { copy_file_fix(source, target); + + // A vendor must be installed in exactly one form. Remove the + // representation that would otherwise be stale or shadow this one. + boost::system::error_code ec; + if (is_opc) { + fs::remove(target.parent_path() / (vendor + ".json"), ec); + ec.clear(); + fs::remove_all(target.parent_path() / vendor, ec); + } + else { + fs::remove(target.parent_path() / (vendor + ".opc"), ec); + } } - } + } friend std::ostream& operator<<(std::ostream& os, const Update &self) { @@ -180,6 +196,8 @@ struct Updates std::vector updates; }; +static bool reload_configs_update_gui(); + wxDEFINE_EVENT(EVT_SLIC3R_VERSION_ONLINE, wxCommandEvent); wxDEFINE_EVENT(EVT_SLIC3R_EXPERIMENTAL_VERSION_ONLINE, wxCommandEvent); @@ -207,6 +225,10 @@ struct PresetUpdater::priv // Per-vendor update checking std::set checked_vendors; + // Orca (PR #130): changelog text for each vendor, captured in memory during + // sync_vendor_config()/check_new_vendors() instead of written beside the cache. + std::unordered_map vendor_changelogs; + mutable std::mutex vendor_changelogs_mutex; std::vector vendor_check_threads; std::atomic vendor_check_cancel{false}; @@ -231,6 +253,8 @@ struct PresetUpdater::priv void parse_version_string(const std::string& body) const; void sync_resources(std::string http_url, std::map &resources, bool check_patch = false, std::string current_version="", std::string changelog_file=""); void sync_vendor_config(const std::string& vendor_id); + void check_new_vendors(const std::set& system_vendors, + std::function, bool)> callback); void sync_tooltip(std::string http_url, std::string language); void sync_plugins(std::string http_url, std::string plugin_version); void sync_printer_config(std::string http_url); @@ -268,7 +292,7 @@ void PresetUpdater::priv::set_download_prefs(AppConfig *app_config) version_check_url = app_config->version_check_url(); auto profile_update_url = app_config->profile_update_url(); - if (!profile_update_url.empty()) + if (!profile_update_url.empty() && app_config->get_bool("enable_ota")) enabled_config_update = true; else enabled_config_update = false; @@ -671,6 +695,9 @@ void PresetUpdater::priv::sync_vendor_config(const std::string& vendor_id) std::string online_version_str; // this represents the PROFILE VERSION, not ORCA VERSION std::string download_url_str; + std::string changelog; + + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] fetching vendor update status from " << url; Http::get(url) .timeout_connect(5) @@ -683,9 +710,12 @@ void PresetUpdater::priv::sync_vendor_config(const std::string& vendor_id) if (http_status != 200) return; try { json j = json::parse(body); + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] url: " << url << " returned:" << body; + if (j.contains("vendor_version") && j.contains("download_url")) { online_version_str = j["vendor_version"].get(); download_url_str = j["download_url"].get(); + changelog = j.value("changelog", std::string()); } } catch (const std::exception& e) { BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] vendor check JSON parse failed: " << e.what(); @@ -707,6 +737,10 @@ void PresetUpdater::priv::sync_vendor_config(const std::string& vendor_id) boost::system::error_code ec; fs::remove_all(cache_profile_path / vendor_id, ec); fs::remove(cache_profile_path / (vendor_id + ".json"), ec); + // Orca: the OPC cache is the vendor's whole installation in one file; clear it too. + fs::remove(cache_profile_path / (vendor_id + ".opc"), ec); + // Best-effort cleanup of the legacy on-disk changelog written by older builds + // (changelogs are now kept in memory - see vendor_changelogs). fs::remove(cache_profile_path / (vendor_id + ".changelog"), ec); // Download the zip @@ -735,7 +769,7 @@ void PresetUpdater::priv::sync_vendor_config(const std::string& vendor_id) if (!download_ok || cancel || vendor_check_cancel) return; // Extract vendor profile bundles under ota/profiles. The downloaded zip contains - // the vendor json/folder at its root. + // either the vendor json/folder or the vendor cache at its root. BOOST_LOG_TRIVIAL(info) << "[Orca Updater] extracting update for " << vendor_id; if (!extract_file(download_file, cache_profile_path)) { BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] extraction failed for " << vendor_id; @@ -745,6 +779,27 @@ void PresetUpdater::priv::sync_vendor_config(const std::string& vendor_id) if (cancel || vendor_check_cancel) return; + const fs::path cached_vendor_json = cache_profile_path / (vendor_id + ".json"); + const fs::path cached_vendor_folder = cache_profile_path / vendor_id; + + const fs::path cached_vendor_opc = cache_profile_path / (vendor_id + ".opc"); + + bool is_json_update = fs::is_regular_file(cached_vendor_json) && fs::is_directory(cached_vendor_folder) && !fs::is_empty(cached_vendor_folder); + bool is_opc_update = fs::is_regular_file(cached_vendor_opc); + if (!is_json_update && !is_opc_update) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] rejected update for " << vendor_id + << ": expected " << vendor_id << ".json and a non-empty " + << vendor_id << " directory, or OPC update format."; + fs::remove_all(cached_vendor_folder, ec); + fs::remove(cached_vendor_json, ec); + return; + } + + { + std::lock_guard lock(vendor_changelogs_mutex); + vendor_changelogs[vendor_id] = std::move(changelog); + } + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] vendor " << vendor_id << " update cached, notifying UI"; GUI::wxGetApp().CallAfter([] { GUI::wxGetApp().check_config_updates_from_updater(); @@ -1039,6 +1094,7 @@ void PresetUpdater::priv::check_installed_vendor_profiles() const BOOST_LOG_TRIVIAL(info) << "[Orca Updater]:Checking whether the profile from resource is newer"; AppConfig *app_config = GUI::wxGetApp().app_config; + const auto enabled_vendors = app_config->vendors(); std::set bundles; @@ -1051,8 +1107,8 @@ void PresetUpdater::priv::check_installed_vendor_profiles() const const auto is_vendor_enabled = (vendor_name == PresetBundle::ORCA_DEFAULT_BUNDLE) // always update configs from resource to vendor for ORCA_DEFAULT_BUNDLE || (enabled_vendors.find(vendor_name) != enabled_vendors.end()); - if (enabled_config_update) { - if (is_vendor_installed(vendor_name)) { + if (is_vendor_installed(vendor_name)) { + if (enabled_config_update) { if (is_vendor_enabled) { // Orca: whichever form of the vendor resources ships at the newer // version is the one installing lays down, and the one to judge @@ -1067,17 +1123,12 @@ void PresetUpdater::priv::check_installed_vendor_profiles() const << resource_ver.to_string() << " from resource, old version " << vendor_ver.to_string(); bundles.insert(vendor_name); } - } - else { - //need to be removed because not installed + } else { + // need to be removed because not installed remove_installed_vendor(vendor_name); } } - else if (is_vendor_enabled) { - bundles.insert(vendor_name); - } - } - else if (is_vendor_enabled) { + } else if (is_vendor_enabled) { bundles.insert(vendor_name); } } @@ -1147,68 +1198,110 @@ Updates PresetUpdater::priv::get_config_updates(const Semver &old_slic3r_version if (!fs::exists(cache_profile_path)) return updates; - for (auto &dir_entry : boost::filesystem::directory_iterator(cache_profile_path)) { - const auto &path = dir_entry.path(); - std::string file_path = path.string(); - if (is_json_file(file_path)) { - const auto path_in_vendor = vendor_path / path.filename(); - std::string vendor_name = path.filename().string(); - // Remove the .json suffix. - vendor_name.erase(vendor_name.size() - 5); - auto print_in_cache = (cache_profile_path / vendor_name / PRESET_PRINT_NAME); - auto filament_in_cache = (cache_profile_path / vendor_name / PRESET_FILAMENT_NAME); - auto machine_in_cache = (cache_profile_path / vendor_name / PRESET_PRINTER_NAME); + // Orca (PR #130): vendor changelogs are captured in memory during + // sync_vendor_config()/check_new_vendors(), not written beside the cache. + std::unordered_map changelogs; + { + std::lock_guard lock(vendor_changelogs_mutex); + changelogs = vendor_changelogs; + } - if (is_vendor_installed(vendor_name) - || fs::exists(print_in_cache) - || fs::exists(filament_in_cache) - || fs::exists(machine_in_cache)) { - // Orca: a vendor installed as a preset cache carries its version there. - Semver vendor_ver = installed_vendor_version(vendor_name); + for (auto &dir_entry : boost::filesystem::directory_iterator(cache_profile_path)) { + const auto &path = dir_entry.path(); + std::string file_path = path.string(); + const bool is_opc_file = boost::iequals(path.extension().string(), ".opc"); + if (!is_json_file(file_path) && !is_opc_file) + continue; - std::map key_values; - std::vector keys(3); - Semver cache_ver; - keys[0] = BBL_JSON_KEY_VERSION; - keys[1] = BBL_JSON_KEY_DESCRIPTION; - keys[2] = BBL_JSON_KEY_FORCE_UPDATE; - get_values_from_json(file_path, keys, key_values); - std::string description = key_values[BBL_JSON_KEY_DESCRIPTION]; - bool force_update = false; - if (key_values.find(BBL_JSON_KEY_FORCE_UPDATE) != key_values.end()) - force_update = (key_values[BBL_JSON_KEY_FORCE_UPDATE] == "1")?true:false; - auto config_version = Semver::parse(key_values[BBL_JSON_KEY_VERSION]); - if (config_version) - cache_ver = *config_version; + const std::string vendor_name = path.stem().string(); + auto print_in_cache = (cache_profile_path / vendor_name / PRESET_PRINT_NAME); + auto filament_in_cache = (cache_profile_path / vendor_name / PRESET_FILAMENT_NAME); + auto machine_in_cache = (cache_profile_path / vendor_name / PRESET_PRINTER_NAME); - std::string changelog; - std::string changelog_file = (cache_profile_path / (vendor_name + ".changelog")).string(); - boost::nowide::ifstream ifs(changelog_file); - if (ifs) { - std::ostringstream oss; - oss<< ifs.rdbuf(); - changelog = oss.str(); - ifs.close(); - } + // Orca (PR #130): a JSON cache entry is only meaningful next to a non-empty + // / preset directory; a stray or half-downloaded .json is + // skipped. An .opc cache is a single self-contained file (validated below), + // so this check does not apply to it. + if (!is_opc_file) { + const auto vendor_folder_in_cache = cache_profile_path / vendor_name; + if (!fs::is_regular_file(path) || !fs::is_directory(vendor_folder_in_cache) || + fs::is_empty(vendor_folder_in_cache)) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater]:ignoring invalid cached update for " + << vendor_name << ": expected " << vendor_name + << ".json and a non-empty " << vendor_name << " directory"; + continue; + } + } - if (vendor_ver < cache_ver) { - BOOST_LOG_TRIVIAL(info) << "[Orca Updater]:need to update settings from " << vendor_ver.to_string() - << " to newer version " << cache_ver.to_string() << ", app version " << SLIC3R_VERSION; - Version version; - version.config_version = cache_ver; - version.comment = description; - // Orca: update vendor.json - updates.updates.emplace_back(std::move(file_path), path_in_vendor.string(), std::move(version), vendor_name, changelog, "", force_update, false); - //Orca: update vendor folder - updates.updates.emplace_back(cache_profile_path / vendor_name, vendor_path / vendor_name, Version(), vendor_name, "", "", force_update, true); - } else { - BOOST_LOG_TRIVIAL(info) << "[Orca Updater]:cached settings for " << vendor_name - << " are not newer than installed version, installed " << vendor_ver.to_string() - << ", cached " << cache_ver.to_string(); - } - } - } - } + if (is_vendor_installed(vendor_name) + || is_opc_file + || fs::exists(print_in_cache) + || fs::exists(filament_in_cache) + || fs::exists(machine_in_cache)) { + // Orca: a vendor installed as a preset cache carries its version there. + Semver vendor_ver = installed_vendor_version(vendor_name); + + Semver cache_ver; + std::string description; + bool force_update = false; + if (is_opc_file) { + cache_ver = VendorCacheFile::usable_version(file_path, vendor_name); + if (!cache_ver.valid()) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater]:ignoring unreadable vendor cache " << file_path; + continue; + } + } + else { + std::map key_values; + std::vector keys(3); + keys[0] = BBL_JSON_KEY_VERSION; + keys[1] = BBL_JSON_KEY_DESCRIPTION; + keys[2] = BBL_JSON_KEY_FORCE_UPDATE; + get_values_from_json(file_path, keys, key_values); + description = key_values[BBL_JSON_KEY_DESCRIPTION]; + if (key_values.find(BBL_JSON_KEY_FORCE_UPDATE) != key_values.end()) + force_update = (key_values[BBL_JSON_KEY_FORCE_UPDATE] == "1")?true:false; + auto config_version = Semver::parse(key_values[BBL_JSON_KEY_VERSION]); + if (config_version) + cache_ver = *config_version; + } + + // Orca (PR #130): changelog for this vendor was captured in memory at sync time. + const auto changelog_it = changelogs.find(vendor_name); + std::string changelog = changelog_it != changelogs.end() ? changelog_it->second : std::string(); + + if (vendor_ver < cache_ver) { + BOOST_LOG_TRIVIAL(info) << "[Orca Updater]:need to update settings from " << vendor_ver.to_string() + << " to newer version " << cache_ver.to_string() << ", app version " << SLIC3R_VERSION; + Version version; + version.config_version = cache_ver; + version.comment = description; + if (is_opc_file) { + // A cache contains the vendor profile and all presets. + // Install it directly; Update::install removes any + // superseded JSON representation. + auto &update = updates.updates.emplace_back( + std::move(file_path), vendor_path / (vendor_name + ".opc"), + std::move(version), vendor_name, changelog, "", force_update, false); + update.is_opc = true; + } + else { + // JSON profile and its preset directory are installed + // separately, as before. + updates.updates.emplace_back(std::move(file_path), + vendor_path / (vendor_name + ".json"), std::move(version), + vendor_name, changelog, "", force_update, false); + updates.updates.emplace_back(cache_profile_path / vendor_name, + vendor_path / vendor_name, Version(), vendor_name, + "", "", force_update, true); + } + } else { + BOOST_LOG_TRIVIAL(info) << "[Orca Updater]:cached settings for " << vendor_name + << " are not newer than installed version, installed " << vendor_ver.to_string() + << ", cached " << cache_ver.to_string(); + } + } + } return updates; } @@ -1317,6 +1410,9 @@ void PresetUpdater::sync(std::string http_url, std::string language, std::string // after the startup printer preset has been restored. this->p->sync_plugins(http_url, plugin_version); this->p->sync_printer_config(http_url); + // Orca (PR #130): the filament library is always installed, so refresh it + // from the updater on every startup sync rather than deferring to check_vendor_update(). + this->p->sync_vendor_config(PresetBundle::ORCA_FILAMENT_LIBRARY); //if (p->cancel) // return; //remove the tooltip currently @@ -1349,6 +1445,302 @@ void PresetUpdater::check_vendor_update(const std::string& vendor_id) }); } +// Orca: ask the server which vendors from `system_vendors` have a profile bundle available that +// isn't installed yet (or is newer than what's installed). Request body maps vendor id -> currently +// installed profile version (unknown/not-yet-installed vendors report "0.0.0"). Response maps +// vendor id -> {version, download_url, changelog} for each vendor the server has an update for. +// Any such vendor is downloaded and cached under ota/profiles the same way sync_vendor_config() +// does; the caller's check_config_updates_from_updater() -> get_config_updates()/perform_updates() +// flow then installs the cached profiles into data_dir()/system. +// +// Mirrors check_vendor_update()/sync_vendor_config(): the network query and the download/extract +// work run on a background thread (vendor_check_threads), never on the calling (UI) thread. Only +// the confirmation dialog (which must run on the UI thread) and the final callback are marshaled +// back via CallAfter(). +void PresetUpdater::priv::check_new_vendors(const std::set& system_vendors, + std::function, bool)> callback) +{ + vendor_check_threads.emplace_back([this, system_vendors, callback]() { + AppConfig* app_config = GUI::wxGetApp().app_config; + std::string url = app_config->profile_update_url() + "/new?orcaslicer_version=" + Http::url_encode(SoftFever_VERSION); + + auto check_cancel = [this](Http::Progress, bool& cancel_http) { + if (cancel || vendor_check_cancel) + cancel_http = true; + }; + + json request_body = json::object(); + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] checking new vendors for:"; + for (const auto& vendor_id : system_vendors) { + // Orca: installed_vendor_version() reads whichever form the vendor is + // installed as - the .json profile or the .opc preset cache stamp - + // so a cache-only vendor is not reported as version 0.0.0 and then + // endlessly re-offered by the server. + Semver installed_ver = installed_vendor_version(vendor_id); + request_body[vendor_id] = installed_ver.to_string(); + BOOST_LOG_TRIVIAL(info) << vendor_id << " (installed version " << installed_ver.to_string() << ")"; + } + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] new vendor check request url: " << url; + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] new vendor check request body: " << request_body.dump(2); + + json response_json; + bool got_response = false; + + auto post = Http::post(url); + + post.timeout_connect(5); + post.on_progress(check_cancel); + post.header("Content-Type", "application/json"); + post.on_error([](std::string body, std::string error, unsigned http_status) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] new vendor check HTTP error: " << error; + }) + .on_complete([&response_json, &got_response](std::string body, unsigned http_status) { + if (http_status != 200) + return; + try { + json j = json::parse(body); + if (j.is_object()) { + response_json = std::move(j); + got_response = true; + } + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] new vendor check JSON parse failed: " << e.what(); + } + }); + + post.set_post_body(request_body.dump()); + post.perform_sync(); + + if (cancel || vendor_check_cancel) + return; + + if (!got_response) { + GUI::wxGetApp().CallAfter([callback]() { callback({}, false); }); + return; + } + + // Collect candidates before touching the filesystem or network again. + struct NewVendorCandidate + { + std::string vendor_id; + Semver version; + std::string download_url; + std::string changelog; + }; + std::vector candidates; + for (auto it = response_json.begin(); it != response_json.end(); ++it) { + const json& entry = it.value(); + if (!entry.is_object()) + continue; + std::string download_url_str = entry.value("download_url", std::string()); + if (download_url_str.empty()) + continue; + + NewVendorCandidate candidate; + candidate.vendor_id = it.key(); + auto parsed_ver = Semver::parse(entry.value("version", std::string())); + candidate.version = parsed_ver ? *parsed_ver : Semver(); + candidate.download_url = std::move(download_url_str); + candidate.changelog = entry.value("changelog", std::string()); + candidates.push_back(std::move(candidate)); + } + + if (candidates.empty()) { + GUI::wxGetApp().CallAfter([callback]() { callback({}, false); }); + return; + } + + // Orca: the confirmation dialog must run on the UI thread; if confirmed, the actual + // download/install work is dispatched back onto a new background thread from there, + // same as check_vendor_update() does for a single vendor. + GUI::wxGetApp().CallAfter([this, candidates, callback]() { + std::vector updates_msg; + for (const auto& candidate : candidates) + updates_msg.emplace_back(candidate.vendor_id, candidate.version, std::string(), candidate.changelog); + + GUI::MsgUpdateConfig dlg(updates_msg); + if (dlg.ShowModal() != wxID_OK) { + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] user declined installing new vendors"; + callback({}, true); + return; + } + + // Orca: the actual download runs on a background thread below (so it doesn't block the + // UI), but that also means nothing visibly happens for the several seconds it can take + // (longer still if a retry kicks in) — push a notification so it's clear work is + // ongoing rather than looking hung. + { + std::string vendor_list; + for (const auto& candidate : candidates) { + if (!vendor_list.empty()) + vendor_list += ", "; + vendor_list += candidate.vendor_id; + } + GUI::wxGetApp().plater()->get_notification_manager()->push_notification( + _u8L("Downloading new vendor profile(s): ") + vendor_list + _u8L("...")); + } + + vendor_check_threads.emplace_back([this, candidates, callback]() { + auto check_cancel = [this](Http::Progress, bool& cancel_http) { + if (cancel || vendor_check_cancel) + cancel_http = true; + }; + + std::vector new_vendor_ids; + std::vector failed_vendor_ids; + auto cache_profile_path = cache_path / "profiles"; + fs::create_directories(cache_profile_path); + boost::system::error_code ec; + + for (const auto& candidate : candidates) { + if (cancel || vendor_check_cancel) + break; + + const std::string& vendor_id = candidate.vendor_id; + const std::string& download_url_str = candidate.download_url; + std::string changelog = candidate.changelog; + + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] downloading new vendor " << vendor_id << " version " << candidate.version.to_string(); + + // Clear only this vendor's cached data, same as sync_vendor_config(). + fs::remove_all(cache_profile_path / vendor_id, ec); + fs::remove(cache_profile_path / (vendor_id + ".json"), ec); + + fs::path download_file = cache_path / (vendor_id + TMP_EXTENSION); + bool download_ok = false; + + // Orca: same retry pattern as Plater.cpp's project download — a single-shot + // 5s connect timeout against GitHub's redirect chain is prone to transient + // failures (DNS/connect hiccups) that succeed a moment later, so retry a few + // times before giving up rather than failing the whole vendor on one blip. + int retry_count = 0; + const int max_retries = 3; + bool keep_trying = true; + while (keep_trying && retry_count < max_retries) { + retry_count++; + Http::get(download_url_str) + .timeout_connect(5) + .on_progress(check_cancel) + .on_error([&vendor_id, &retry_count](std::string body, std::string error, unsigned http_status) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] download failed for new vendor " << vendor_id + << " (attempt " << retry_count << "/" << max_retries << "): " << error; + }) + .on_complete([&](std::string body, unsigned http_status) { + if (http_status != 200) + return; + fs::fstream file(download_file, std::ios::out | std::ios::binary | std::ios::trunc); + if (!file.good()) + return; + file.write(body.c_str(), body.size()); + file.close(); + if (file.good()) + download_ok = true; + }) + .perform_sync(); + + keep_trying = !download_ok && !(cancel || vendor_check_cancel); + } + + if (!download_ok || cancel || vendor_check_cancel) { + if (!download_ok) + failed_vendor_ids.push_back(vendor_id); + continue; + } + + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] extracting new vendor " << vendor_id; + if (!extract_file(download_file, cache_profile_path)) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] extraction failed for new vendor " << vendor_id; + fs::remove(download_file, ec); + failed_vendor_ids.push_back(vendor_id); + continue; + } + fs::remove(download_file, ec); + + const fs::path cached_vendor_json = cache_profile_path / (vendor_id + ".json"); + const fs::path cached_vendor_folder = cache_profile_path / vendor_id; + if (!fs::is_regular_file(cached_vendor_json) || !fs::is_directory(cached_vendor_folder) || + fs::is_empty(cached_vendor_folder)) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] rejected new vendor " << vendor_id << ": expected " << vendor_id + << ".json and a non-empty " << vendor_id << " directory"; + fs::remove_all(cached_vendor_folder, ec); + fs::remove(cached_vendor_json, ec); + failed_vendor_ids.push_back(vendor_id); + continue; + } + + { + std::lock_guard lock(vendor_changelogs_mutex); + vendor_changelogs[vendor_id] = std::move(changelog); + } + + new_vendor_ids.push_back(vendor_id); + } + + if (!new_vendor_ids.empty()) { + // Orca: the user already confirmed via the dialog above, so install right away + // instead of routing through check_config_updates_from_updater(), which only + // queues a passive notification (meant for the silent background per-vendor + // check) requiring yet another click + confirmation before anything is copied + // into data_dir()/system. + GUI::wxGetApp().CallAfter([this, new_vendor_ids] { + AppConfig* app_config = GUI::wxGetApp().app_config; + Updates updates = get_config_updates(app_config->orig_version()); + + // Only install the vendors just confirmed; leave any other unrelated + // pending cached update (from a background sync_vendor_config()) alone, + // still gated behind its own notification/confirmation. + std::set confirmed(new_vendor_ids.begin(), new_vendor_ids.end()); + Updates filtered; + for (auto& update : updates.updates) + if (confirmed.count(update.vendor)) + filtered.updates.push_back(std::move(update)); + + if (filtered.updates.empty()) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] new vendors cached but no updates detected"; + return; + } + + if (!perform_updates(std::move(filtered)) || !reload_configs_update_gui()) { + BOOST_LOG_TRIVIAL(warning) << "[Orca Updater] failed to install new vendors"; + return; + } + + BOOST_LOG_TRIVIAL(info) << "[Orca Updater] new vendors installed"; + for (const auto& vendor_id : new_vendor_ids) { + Semver cur_ver = GUI::wxGetApp().preset_bundle->get_vendor_profile_version(vendor_id); + GUI::wxGetApp().plater()->get_notification_manager()->push_notification( + GUI::NotificationType::PresetUpdateFinished, + GUI::NotificationManager::NotificationLevel::ImportantNotificationLevel, + _u8L("Configuration package: ") + vendor_id + _u8L(" updated to ") + cur_ver.to_string()); + } + }); + } + + if (!failed_vendor_ids.empty()) { + GUI::wxGetApp().CallAfter([failed_vendor_ids] { + std::string vendor_list; + for (const auto& vendor_id : failed_vendor_ids) { + if (!vendor_list.empty()) + vendor_list += ", "; + vendor_list += vendor_id; + } + GUI::wxGetApp().plater()->get_notification_manager()->push_notification( + _u8L("Failed to download vendor profile(s): ") + vendor_list); + }); + } + + GUI::wxGetApp().CallAfter([callback, new_vendor_ids]() { callback(new_vendor_ids, false); }); + }); + }); + }); +} + +void PresetUpdater::check_new_vendors(const std::set& system_vendors, + std::function, bool)> callback) +{ + p->check_new_vendors(system_vendors, std::move(callback)); +} + void PresetUpdater::slic3r_update_notify() { if (! p->enabled_version_check) @@ -1370,10 +1762,9 @@ static bool reload_configs_update_gui() GUI::wxGetApp().load_current_presets(); GUI::wxGetApp().plater()->set_bed_shape(); - return true; + return true; } - PresetUpdater::UpdateResult PresetUpdater::config_update(const Semver& old_slic3r_version, UpdateParams params) const { if (! p->enabled_config_update) { return R_NOOP; } diff --git a/src/slic3r/Utils/PresetUpdater.hpp b/src/slic3r/Utils/PresetUpdater.hpp index 27ec6748f2..ccb0902fd5 100644 --- a/src/slic3r/Utils/PresetUpdater.hpp +++ b/src/slic3r/Utils/PresetUpdater.hpp @@ -1,7 +1,9 @@ #ifndef slic3r_PresetUpdate_hpp_ #define slic3r_PresetUpdate_hpp_ +#include #include +#include #include #include @@ -59,6 +61,12 @@ public: void on_update_notification_confirm(); void do_printer_config_update(); void check_vendor_update(const std::string& vendor_id); + // Orca: async, mirrors check_vendor_update()/sync_vendor_config() — the network query and any + // download/install work happen on a background thread; only the confirmation dialog runs on + // the UI thread. `callback` is invoked on the UI thread with the ids of vendors that were + // installed (empty if none were found, or the user declined) and whether the user declined. + void check_new_vendors(const std::set& system_vendors, + std::function installed_vendors, bool declined)> callback); bool version_check_enabled() const; diff --git a/src/slic3r/Utils/Process.cpp b/src/slic3r/Utils/Process.cpp index 518462bbc9..96da521114 100644 --- a/src/slic3r/Utils/Process.cpp +++ b/src/slic3r/Utils/Process.cpp @@ -21,6 +21,7 @@ #include #endif +#include #include namespace Slic3r { diff --git a/src/slic3r/Utils/QidiPrinterAgent.cpp b/src/slic3r/Utils/QidiPrinterAgent.cpp index 6b05480194..1f437853ba 100644 --- a/src/slic3r/Utils/QidiPrinterAgent.cpp +++ b/src/slic3r/Utils/QidiPrinterAgent.cpp @@ -9,6 +9,8 @@ #include #include +using json = nlohmann::json; + namespace Slic3r { namespace { diff --git a/src/slic3r/Utils/Serial.cpp b/src/slic3r/Utils/Serial.cpp index 4db1acc6b6..f8c03ceb26 100644 --- a/src/slic3r/Utils/Serial.cpp +++ b/src/slic3r/Utils/Serial.cpp @@ -331,7 +331,9 @@ void Serial::set_baud_rate(unsigned baud_rate) speed_t c_ispeed; speed_t c_ospeed; }; +#ifndef BOTHER #define BOTHER CBAUDEX +#endif termios2 ios; handle_errno(::ioctl(handle, TCGETS2, &ios)); diff --git a/src/slic3r/Utils/SnapmakerPrinterAgent.cpp b/src/slic3r/Utils/SnapmakerPrinterAgent.cpp index 9b9a6809fd..5783738af7 100644 --- a/src/slic3r/Utils/SnapmakerPrinterAgent.cpp +++ b/src/slic3r/Utils/SnapmakerPrinterAgent.cpp @@ -6,6 +6,8 @@ #include "nlohmann/json.hpp" #include +using json = nlohmann::json; + namespace Slic3r { namespace { @@ -39,7 +41,7 @@ std::string find_closest_color_preset_by_vendor_and_type(const PresetCollection& std::string p_color = p.config.opt_string("default_filament_colour", 0u); unsigned int p_color_value; if (!p_color.empty()) { - unsigned int hash_pos = p_color.find("#"); + size_t hash_pos = p_color.find("#"); p_color_value = std::stoul(p_color.substr(hash_pos != std::string::npos ? hash_pos + 1 : 0), nullptr, 16); } else { // Default to black if no color specified in profile. Assume other profiles might be a closer color match. diff --git a/src/slic3r/plugin/PluginResolver.cpp b/src/slic3r/plugin/PluginResolver.cpp index 4ba58ee550..6e72929c63 100644 --- a/src/slic3r/plugin/PluginResolver.cpp +++ b/src/slic3r/plugin/PluginResolver.cpp @@ -3,6 +3,7 @@ #include "PluginManager.hpp" #include "../Utils/Http.hpp" #include "../Utils/OrcaCloudServiceAgent.hpp" +#include "../Utils/NetworkAgent.hpp" #include "../GUI/GUI.hpp" #include "../GUI/GUI_App.hpp" #include "../GUI/I18N.hpp" diff --git a/tests/fff_print/test_fill.cpp b/tests/fff_print/test_fill.cpp index 04e5b61831..aa81570e56 100644 --- a/tests/fff_print/test_fill.cpp +++ b/tests/fff_print/test_fill.cpp @@ -9,6 +9,7 @@ #include #include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/AABBTreeLines.hpp" #include "libslic3r/Fill/Fill.hpp" #include "libslic3r/Flow.hpp" #include "libslic3r/Geometry.hpp" @@ -1229,3 +1230,68 @@ TEST_CASE("Smoothing multiline lightning infill keeps its outlines connected", " REQUIRE(smooth.point_count > sharp.point_count); REQUIRE(smooth.sharp_turns < sharp.sharp_turns); } + +TEST_CASE("Sparse plane-path anchors match the printed infill", "[Fill][InternalBridge][Regression]") +{ + // Orca: Compare generated anchors with actual extrusion across plane-path patterns, + // smoothing, multiline and rotations; an origin shift must not pass as valid support. + const std::string pattern = GENERATE("hilbertcurve", "octagramspiral", "archimedeanchords"); + const std::string smoothing = GENERATE("0%", "100%"); + const int multiline = GENERATE(1, 2); + const bool rotated = GENERATE(false, true); + const bool separated = GENERATE(false, true); + CAPTURE(pattern, smoothing, multiline, rotated, separated); + + auto config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({{"sparse_infill_pattern", pattern}, + {"sparse_infill_density", "15%"}, + {"sparse_infill_smooth_factor", smoothing}, + {"fill_multiline", multiline}, + {"infill_direction", 45}, + {"sparse_infill_rotate_template", rotated ? "0,25,50" : ""}, + {"align_infill_direction_to_model", rotated}, + {"separated_infills", separated}, + {"top_shell_layers", 0}, + {"bottom_shell_layers", 0}, + {"top_shell_thickness", 0}, + {"bottom_shell_thickness", 0}, + {"layer_height", 0.2}, + {"initial_layer_print_height", 0.2}, + {"resolution", 0.012}}); + Print print; + Model model; + TriangleMesh mesh = make_cube(30, 24, 1); + if (separated) { + // Orca: Two disconnected bodies in one object must each use their own infill origin. + TriangleMesh second = make_cube(30, 24, 1); + second.translate(50, 0, 0); + mesh.merge(second); + } + Slic3r::Test::init_print({mesh}, print, model, config, nullptr, false); + if (rotated) { + model.objects.front()->instances.front()->set_rotation(Vec3d(0., 0., Geometry::deg2rad(23.))); + print.apply(model, config); + } + print.process(); + + const Layer &layer = *print.objects().front()->get_layer(4); + Polylines printed; + for (const LayerRegion *region : layer.regions()) + for (const ExtrusionEntity *entity : region->fills.flatten().entities) + if (entity->role() == erInternalInfill) + entity->collect_polylines(printed); + REQUIRE_FALSE(printed.empty()); + const AABBTreeLines::LinesDistancer printed_tree(to_lines(printed)); + + // Orca: Exclude perimeter connections: anchoring and extrusion can trim those differently. + const Polylines anchors = intersection_pl(layer.generate_sparse_infill_polylines_for_anchoring(nullptr, nullptr, nullptr), + shrink(to_polygons(layer.lslices), scale_(3.))); + REQUIRE_FALSE(anchors.empty()); + double max_distance = 0.; + for (const Polyline &path : anchors) + for (const Point &point : path.equally_spaced_points(scale_(0.25))) + max_distance = std::max(max_distance, printed_tree.distance_from_lines(point)); + // Orca: Allow only the configured simplification tolerance; infill-scale offsets + // would hide anchors that no longer coincide with printed lines. + CHECK(unscale(max_distance) <= config.opt_float("resolution")); +} diff --git a/tests/fff_print/test_gcodewriter.cpp b/tests/fff_print/test_gcodewriter.cpp index b09b38e794..c0c9e794b9 100644 --- a/tests/fff_print/test_gcodewriter.cpp +++ b/tests/fff_print/test_gcodewriter.cpp @@ -574,6 +574,9 @@ static DynamicPrintConfig dual_extruder_toolchange_config() config.set_key_value("nozzle_temperature_range_high", new ConfigOptionInts({240, 240})); config.set_key_value("flush_multiplier", new ConfigOptionFloats({1})); config.set_key_value("flush_volumes_matrix", new ConfigOptionFloats({0, 140, 140, 0})); + // Inside the 200x200 test bed; the default y, 220, is not, and generation rejects that. + config.set_key_value("wipe_tower_x", new ConfigOptionFloats({50.})); + config.set_key_value("wipe_tower_y", new ConfigOptionFloats({50.})); return config; } diff --git a/tests/fff_print/test_printobject.cpp b/tests/fff_print/test_printobject.cpp index fb7fe2c1fd..a373a1ad39 100644 --- a/tests/fff_print/test_printobject.cpp +++ b/tests/fff_print/test_printobject.cpp @@ -4,11 +4,18 @@ #include "libslic3r/Print.hpp" #include "libslic3r/Layer.hpp" #include "libslic3r/GCodeReader.hpp" +#include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/AABBTreeLines.hpp" #include "test_helpers.hpp" +#include #include +#include #include +#include +#include +#include using namespace Slic3r; using namespace Slic3r::Test; @@ -130,3 +137,438 @@ TEST_CASE("Initial layer height is honored", "[PrintObject]") REQUIRE_THAT(*layer_zs.begin(), Catch::Matchers::WithinAbs(0.3, 1e-4)); REQUIRE_THAT(*std::next(layer_zs.begin()), Catch::Matchers::WithinAbs(0.5, 1e-4)); } + +static TriangleMesh internal_bridge_step() +{ + // Orca: The smaller tower leaves a shoulder whose solid skin needs internal bridges + // over the sparse infill in the base, without relying on an external model file. + TriangleMesh mesh = make_cube(30, 24, 3); + TriangleMesh tower = make_cube(14, 10, 1); + tower.translate(8, 7, 3); + mesh.merge(tower); + return mesh; +} + +static DynamicPrintConfig internal_bridge_config(const std::string &pattern, int multiline) +{ + auto config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({{"sparse_infill_pattern", pattern}, + {"fill_multiline", multiline}, + {"sparse_infill_density", "15%"}, + {"sparse_infill_smooth_factor", "100%"}, + {"infill_direction", 45}, + {"internal_bridge_angle", 0}, + {"thick_internal_bridges", true}, + {"top_shell_layers", 3}, + {"bottom_shell_layers", 2}, + {"top_shell_thickness", 0}, + {"bottom_shell_thickness", 0}, + {"layer_height", 0.2}, + {"initial_layer_print_height", 0.2}}); + return config; +} + +TEST_CASE("Internal bridge angles follow the lower infill layer and model rotation", "[PrintObject][InternalBridge][Regression]") +{ + const std::string pattern = GENERATE("hilbertcurve", "octagramspiral"); + // Orca: Cover both a central line (odd counts) and offset pairs (even counts). + const int multiline = GENERATE(1, 2, 3); + CAPTURE(multiline); + const double rotation = GENERATE(23., -123.); + const std::vector cycle{10., 30., 70.}; + auto config = internal_bridge_config(pattern, multiline); + config.set_deserialize_strict({{"sparse_infill_rotate_template", "10,30,70"}, + {"align_infill_direction_to_model", true}, + {"separated_infills", false}}); + Print print; + Model model; + init_print({internal_bridge_step()}, print, model, config, nullptr, false); + model.objects.front()->instances.front()->set_rotation(Vec3d(0., 0., Geometry::deg2rad(rotation))); + print.apply(model, config); + print.process(); + const PrintObject &object = *print.objects().front(); + size_t bridges = 0; + for (size_t i = 1; i < object.layer_count(); ++i) { + // Orca: The support is one layer below the bridge. Check the template and model + // rotation together, including normalization when the resulting angle is negative. + double expected = std::fmod(cycle[(i - 1) % cycle.size()] + 90. + rotation, 180.); + if (expected < 0.) expected += 180.; + for (const LayerRegion *region : object.get_layer(i)->regions()) + for (const Surface *surface : region->fill_surfaces.filter_by_type(stInternalBridge)) { + CAPTURE(pattern, rotation, i); + CHECK_THAT(Geometry::rad2deg(surface->bridge_angle), Catch::Matchers::WithinAbs(expected, 0.001)); + ++bridges; + } + } + REQUIRE(bridges > 0); +} + +TEST_CASE("Turning infill does not replace the anchors of another region", "[PrintObject][InternalBridge][Regression]") +{ + // Orca: Keep the right-hand region fixed while changing the left-hand pattern in the + // same object. Its bridge areas must be independent of a previous candidate's anchors. + const int multiline = GENERATE(1, 2, 3); + CAPTURE(multiline); + auto right_bridges = [multiline](const std::string &left_pattern) { + auto config = internal_bridge_config(left_pattern, multiline); + Print print; + Model model; + init_print({internal_bridge_step()}, print, model, config, nullptr, false); + TriangleMesh right = internal_bridge_step(); + right.translate(50, 0, 0); + ModelVolume *volume = model.objects.front()->add_volume(std::move(right)); + volume->config.set_key_value("sparse_infill_pattern", new ConfigOptionEnum(ipRectilinear)); + volume->config.set_key_value("infill_direction", new ConfigOptionFloat(17.)); + print.apply(model, config); + print.process(); + std::map result; + const PrintObject &object = *print.objects().front(); + for (size_t i = 0; i < object.layer_count(); ++i) + for (const LayerRegion *region : object.get_layer(i)->regions()) + if (region->region().config().infill_direction == 17.) + polygons_append(result[i], to_polygons(region->fill_surfaces.filter_by_type(stInternalBridge))); + return result; + }; + const auto baseline = right_bridges("rectilinear"); + const auto actual = right_bridges(GENERATE("hilbertcurve", "octagramspiral")); + REQUIRE(actual.size() == baseline.size()); + double total_area = 0.; + for (const auto &[layer, expected] : baseline) { + CAPTURE(layer); + const auto &polys = actual.at(layer); + CHECK(area(diff(expected, polys)) < scaled(1.) * scaled(1.) * 1e-6); + CHECK(area(diff(polys, expected)) < scaled(1.) * scaled(1.) * 1e-6); + total_area += area(expected); + } + REQUIRE(total_area > 0.); +} + +TEST_CASE("Rounded internal bridges end on printed support", "[PrintObject][InternalBridge][Regression]") +{ + const std::string pattern = GENERATE("hilbertcurve", "octagramspiral"); + const bool separated = GENERATE(false, true); + CAPTURE(pattern, separated); + auto config = internal_bridge_config(pattern, 1); + config.set_deserialize_strict({{"infill_wall_overlap", "0%"}, {"separated_infills", separated}}); + TriangleMesh mesh = internal_bridge_step(); + if (separated) { + TriangleMesh second = internal_bridge_step(); + second.translate(50, 0, 0); + mesh.merge(second); + } + Print print; + Model model; + init_print({mesh}, print, model, config, nullptr, false); + print.process(); + + // Orca: Check final extrusion endpoints after polygon cleanup and fill generation. + // A correct bridge angle and correct sparse anchors alone do not guarantee contact. + const PrintObject &object = *print.objects().front(); + size_t checked = 0; + for (size_t i = 1; i < object.layer_count(); ++i) { + Polygons support; + Polylines walls; + for (const LayerRegion *region : object.get_layer(i - 1)->regions()) { + region->perimeters.polygons_covered_by_width(support, 0.f); + region->fills.polygons_covered_by_width(support, 0.f); + region->perimeters.collect_polylines(walls); + } + REQUIRE_FALSE(support.empty()); + const AABBTreeLines::LinesDistancer support_tree(to_lines(union_(support))); + const AABBTreeLines::LinesDistancer wall_tree(to_lines(walls)); + for (const LayerRegion *region : object.get_layer(i)->regions()) + for (const ExtrusionEntity *entity : region->fills.flatten().entities) { + if (entity->role() != erInternalBridgeInfill) + continue; + const auto *path = dynamic_cast(entity); + REQUIRE(path != nullptr); + for (const Line &line : path->polyline.to_polyline().lines()) { + // Orca: Sample span ends, excluding short connectors and wall overlap. + if (line.length() < scale_(std::max(0.7, 3. * path->width))) + continue; + for (const Point &point : {line.a, line.b}) { + if (wall_tree.distance_from_lines(point) <= scale_(0.5)) + continue; + CAPTURE(i, point.x(), point.y()); + const double gap = unscale(support_tree.distance_from_lines(point)) - 0.5 * path->width; + CHECK(gap <= 0.1); + ++checked; + } + } + } + } + REQUIRE(checked > 0); +} + +TEST_CASE("Enabling separated infill recomputes body origins", "[PrintObject][InternalBridge][Regression]") +{ + const std::string pattern = GENERATE("hilbertcurve", "octagramspiral", "archimedeanchords"); + CAPTURE(pattern); + auto footprint = [&](bool reslice) { + auto config = internal_bridge_config(pattern, 2); + config.set_deserialize_strict({{"separated_infills", !reslice}}); + TriangleMesh mesh = internal_bridge_step(); + TriangleMesh second = internal_bridge_step(); + second.translate(50, 0, 0); + mesh.merge(second); + Print print; + Model model; + init_print({mesh}, print, model, config, nullptr, false); + print.process(); + if (reslice) { + // Orca: Enabling centering after a completed slice must rebuild the body + // origins now shared by bridge preparation and printed infill. + config.set_deserialize_strict({{"separated_infills", true}}); + print.apply(model, config); + print.process(); + } + Polygons result; + for (const LayerRegion *region : print.objects().front()->get_layer(4)->regions()) + region->fills.polygons_covered_by_width(result, 0.f); + return union_(result); + }; + const Polygons fresh = footprint(false); + const Polygons resliced = footprint(true); + REQUIRE_FALSE(fresh.empty()); + CHECK(area(diff(fresh, resliced)) < scaled(1.) * scaled(1.) * 1e-6); + CHECK(area(diff(resliced, fresh)) < scaled(1.) * scaled(1.) * 1e-6); +} + +TEST_CASE("Surface centering survives changes to separated infill settings", "[PrintObject][SurfaceInfill][Regression]") +{ + const std::string pattern = GENERATE("archimedeanchords", "octagramspiral"); + const std::string initial_center = GENERATE("each_surface", "each_model", "each_assembly"); + const std::string final_center = GENERATE("each_surface", "each_model", "each_assembly"); + const bool separated = GENERATE(false, true); + const std::string top_order = GENERATE("default", "outward", "inward"); + const std::string bottom_order = top_order == "outward" ? "inward" : top_order == "inward" ? "outward" : "default"; + const std::string density = GENERATE("80%", "100%"); + const bool change_center = initial_center != final_center; + CAPTURE(pattern, initial_center, final_center, separated, top_order, bottom_order, density); + + auto config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({{"top_surface_pattern", pattern}, + {"bottom_surface_pattern", pattern}, + {"top_surface_fill_order", top_order}, + {"bottom_surface_fill_order", bottom_order}, + {"top_surface_density", density}, + {"bottom_surface_density", density}, + {"center_of_surface_pattern", initial_center}, + {"separated_infills", change_center ? separated : !separated}, + {"sparse_infill_pattern", "rectilinear"}, + {"sparse_infill_density", "15%"}, + {"top_shell_layers", 2}, + {"bottom_shell_layers", 2}, + {"top_shell_thickness", 0}, + {"bottom_shell_thickness", 0}, + {"layer_height", 0.2}, + {"initial_layer_print_height", 0.2}}); + + // Orca: Two disconnected bodies exercise per-body centering. The offset tower also + // makes each-surface and each-model centering differ on the top surfaces. + TriangleMesh mesh = make_cube(30, 24, 2); + TriangleMesh tower = make_cube(12, 10, 1); + tower.translate(4, 3, 2); + mesh.merge(tower); + TriangleMesh second = mesh; + second.translate(50, 0, 0); + mesh.merge(second); + + // Orca: Equal footprints can hide reordered or reversed paths. Retain their point + // sequences and ordering protection to cover the directional surface behavior too. + struct SurfaceFillSnapshot { + std::map> paths; + bool protected_order = true; + }; + auto surface_fills = [](const Print &print) { + std::map, SurfaceFillSnapshot> result; + const PrintObject &object = *print.objects().front(); + for (size_t i = 0; i < object.layer_count(); ++i) { + auto collect = [&](const auto &self, const ExtrusionEntity &entity, bool no_sort) -> void { + if (const auto *collection = dynamic_cast(&entity)) { + for (const ExtrusionEntity *child : collection->entities) + self(self, *child, no_sort || collection->no_sort); + } else if (entity.role() == erTopSolidInfill || entity.role() == erBottomSurface) { + const auto *path = dynamic_cast(&entity); + REQUIRE(path != nullptr); + auto &snapshot = result[{i, entity.role()}]; + // Orca: The centered test model has one body on either side of X=0. + // Their traversal order may vary; preserve path order within each body. + Points points = path->polyline.to_polyline().points; + REQUIRE_FALSE(points.empty()); + snapshot.paths[points.front().x() > 0].push_back(std::move(points)); + snapshot.protected_order &= no_sort && !path->can_reverse(); + } + }; + for (const LayerRegion *region : object.get_layer(i)->regions()) + collect(collect, region->fills, false); + } + return result; + }; + + Print print; + Model model; + init_print({mesh}, print, model, config, nullptr, false); + print.process(); + const auto initial = surface_fills(print); + config.set_deserialize_strict({{"center_of_surface_pattern", final_center}, {"separated_infills", separated}}); + print.apply(model, config); + // Orca: Preparation owns the body origins, and its invalidation must also force + // regeneration of top/bottom extrusion paths, even when sparse infill is unchanged. + CHECK_FALSE(print.objects().front()->is_step_done(posPrepareInfill)); + CHECK_FALSE(print.objects().front()->is_step_done(posInfill)); + print.process(); + const auto resliced = surface_fills(print); + + Print fresh_print; + Model fresh_model; + init_print({mesh}, fresh_print, fresh_model, config, nullptr, false); + fresh_print.process(); + const auto fresh = surface_fills(fresh_print); + REQUIRE_FALSE(fresh.empty()); + REQUIRE(resliced.size() == fresh.size()); + std::set roles; + bool changed_paths = false; + for (const auto &entry : fresh) { + CAPTURE(entry.first.first, entry.first.second); + REQUIRE_FALSE(entry.second.paths.empty()); + roles.insert(entry.first.second); + REQUIRE(resliced.count(entry.first) == 1); + REQUIRE(initial.count(entry.first) == 1); + const auto &actual = resliced.at(entry.first); + const auto &expected = entry.second; + const auto &before = initial.at(entry.first); + CHECK((actual.paths == expected.paths)); + if (!change_center) + CHECK((actual.paths == before.paths)); + if (top_order != "default") { + CHECK(expected.protected_order); + CHECK(actual.protected_order); + CHECK(before.protected_order); + } + changed_paths |= expected.paths != before.paths; + } + CHECK(roles.count(erTopSolidInfill) == 1); + CHECK(roles.count(erBottomSurface) == 1); + // Orca: Guard against a vacuous comparison: changing surface centering must change + // the printed pattern, while toggling separated sparse infill must leave it alone. + CHECK(changed_paths == change_center); +} + +TEST_CASE("Separated infill keeps fragmented and nested bodies independent", "[PrintObject][SurfaceInfill][Regression]") +{ + constexpr size_t grid_size = 8; + TriangleMesh mesh; + auto add_box = [&](double x, double y, double width, double depth) { + TriangleMesh box = make_cube(width, depth, 0.6); + box.translate(x, y, 0); + mesh.merge(box); + }; + // Orca: Many small islands exercise spatial pruning and the tree's original + // island indices. A pillar inside a frame also overlaps its bounding box, + // but must remain a separate body because it lies entirely inside the hole. + for (size_t x = 0; x < grid_size; ++ x) + for (size_t y = 0; y < grid_size; ++ y) + add_box(6 * x, 6 * y, 3, 3); + add_box(54, 0, 20, 4); + add_box(54, 16, 20, 4); + add_box(54, 0, 4, 20); + add_box(70, 0, 4, 20); + add_box(62, 8, 4, 4); + + auto config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({{"separated_infills", true}, + {"center_of_surface_pattern", "each_surface"}, + {"layer_height", 0.2}, + {"initial_layer_print_height", 0.2}, + {"elefant_foot_compensation", 0}, + {"wall_loops", 1}}); + Print print; + Model model; + init_print({mesh}, print, model, config, nullptr, false); + // Orca: Prepare body bounds through the public pipeline, then inspect the object read-only. + print.process(); + const PrintObject &object = *print.objects().front(); + REQUIRE(object.layer_count() > 1); + for (const Layer *layer : object.layers()) { + REQUIRE(layer->lslices.size() == grid_size * grid_size + 2); + REQUIRE(layer->lslices_separated_component_bboxes.size() == layer->lslices.size()); + size_t holes = 0; + for (size_t i = 0; i < layer->lslices.size(); ++ i) { + const BoundingBox &body = layer->lslices_separated_component_bboxes[i]; + const BoundingBox &island = layer->lslices_bboxes[i]; + CHECK(body.min == island.min); + CHECK(body.max == island.max); + holes += layer->lslices[i].holes.size(); + } + CHECK(holes == 1); + } +} + +TEST_CASE("Body centering survives islands merging and splitting between layers", "[PrintObject][SurfaceInfill][Regression]") +{ + const bool separated = GENERATE(false, true); + CAPTURE(separated); + // Orca: Four posts join through horizontal then vertical rails, creating a + // cycle of overlaps before splitting into four islands again. This exercises + // redundant connections and indexing either adjacent layer. A fifth post + // stays separate at every height. + TriangleMesh mesh; + for (int x : {0, 8}) + for (int y : {0, 8}) { + TriangleMesh post = make_cube(4, 4, 1); + post.translate(x, y, 0); + mesh.merge(post); + } + for (int y : {0, 8}) { + TriangleMesh rail = make_cube(12, 4, 0.2); + rail.translate(0, y, 0.2); + mesh.merge(rail); + } + for (int x : {0, 8}) { + TriangleMesh rail = make_cube(4, 12, 0.2); + rail.translate(x, 0, 0.4); + mesh.merge(rail); + } + TriangleMesh isolated = make_cube(4, 4, 1); + isolated.translate(20, 0, 0); + mesh.merge(isolated); + + auto config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({{"separated_infills", separated}, + {"center_of_surface_pattern", separated ? "each_surface" : "each_model"}, + {"layer_height", 0.2}, + {"initial_layer_print_height", 0.2}, + {"elefant_foot_compensation", 0}, + {"wall_loops", 1}}); + Print print; + Model model; + init_print({mesh}, print, model, config, nullptr, false); + // Orca: Prepare body bounds through the public pipeline, then inspect the object read-only. + print.process(); + const PrintObject &object = *print.objects().front(); + REQUIRE(object.layer_count() == 5); + REQUIRE(object.get_layer(0)->lslices.size() == 5); + REQUIRE(object.get_layer(1)->lslices.size() == 3); + REQUIRE(object.get_layer(2)->lslices.size() == 3); + REQUIRE(object.get_layer(4)->lslices.size() == 5); + + BoundingBox isolated_bbox = object.get_layer(0)->lslices_bboxes.front(); + for (const BoundingBox &bbox : object.get_layer(0)->lslices_bboxes) + if (bbox.min.x() > isolated_bbox.min.x()) + isolated_bbox = bbox; + BoundingBox connected_bbox; + for (const Layer *layer : object.layers()) + for (const BoundingBox &bbox : layer->lslices_bboxes) + if (bbox.min.x() < isolated_bbox.min.x()) + connected_bbox.merge(bbox); + for (const Layer *layer : object.layers()) { + REQUIRE(layer->lslices_separated_component_bboxes.size() == layer->lslices.size()); + for (size_t i = 0; i < layer->lslices.size(); ++ i) { + const BoundingBox &expected = layer->lslices_bboxes[i].min.x() < isolated_bbox.min.x() ? connected_bbox : isolated_bbox; + const BoundingBox &actual = layer->lslices_separated_component_bboxes[i]; + CHECK(actual.min == expected.min); + CHECK(actual.max == expected.max); + } + } +} diff --git a/tests/fff_print/test_tree_support.cpp b/tests/fff_print/test_tree_support.cpp index 362eb0ca56..fbb40bb8f3 100644 --- a/tests/fff_print/test_tree_support.cpp +++ b/tests/fff_print/test_tree_support.cpp @@ -1,5 +1,7 @@ #include +#include + #include "libslic3r/Layer.hpp" #include "libslic3r/TriangleMesh.hpp" @@ -33,10 +35,13 @@ TriangleMesh scaled(TestMesh id, float scale) return mesh; } +// `extra` is applied last, so a caller can add or override any key. void slice_with_tree_support(const TriangleMesh &mesh, Slic3r::Print &print, const char *style, - int threshold_angle = 30, int build_plate_only = 0, int raft_layers = 0) + int threshold_angle = 30, int build_plate_only = 0, int raft_layers = 0, + std::initializer_list extra = {}) { - Slic3r::Test::init_and_process_print({ mesh }, print, { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_deserialize_strict({ { "enable_support", 1 }, { "support_type", "tree(auto)" }, { "support_style", style }, @@ -45,6 +50,8 @@ void slice_with_tree_support(const TriangleMesh &mesh, Slic3r::Print &print, con { "raft_layers", raft_layers }, { "layer_height", 0.2 }, }); + config.set_deserialize_strict(extra); + Slic3r::Test::init_and_process_print({ mesh }, print, config); } Points support_points(const Slic3r::Print &print) @@ -63,6 +70,32 @@ size_t support_point_count(const TriangleMesh &mesh, const char *style, int thre return support_points(print).size(); } +// Index of the first differing point, or the common length when they match. An index keeps a +// failure readable; comparing the vectors themselves dumps thousands of points. +size_t first_difference(const Points &a, const Points &b) +{ + const size_t common = std::min(a.size(), b.size()); + for (size_t i = 0; i < common; ++i) + if (a[i] != b[i]) + return i; + return common; +} + +// Slice `mesh` twice and require an identical support point sequence. Point counts and total +// length are order insensitive, so the sequence is what a reordering shows up in. +void sliced_twice_matches(const TriangleMesh &mesh, int build_plate_only, const char *style = "tree_slim", + std::initializer_list extra = {}) +{ + Slic3r::Print first_print, second_print; + slice_with_tree_support(mesh, first_print, style, 30, build_plate_only, 0, extra); + slice_with_tree_support(mesh, second_print, style, 30, build_plate_only, 0, extra); + const Points first = support_points(first_print); + const Points second = support_points(second_print); + REQUIRE(first.size() > 1000); // without support the comparison below passes vacuously + REQUIRE(second.size() == first.size()); + REQUIRE(first_difference(first, second) == first.size()); +} + } // namespace TEST_CASE("Tree support is generated for an overhang and not for a plain cube", "[TreeSupport]") @@ -123,3 +156,35 @@ TEST_CASE("A raft is still generated under tree support", "[TreeSupport]") // The raft goes under the object. REQUIRE(rafted_object->layers().front()->print_z > unrafted_object->layers().front()->print_z); } + +// drop_nodes() decides the node merges and spawns the next layer's nodes in parallel. Every one of +// those decisions has to be applied in a fixed order, or the same model gives different branches on +// each slice. +TEST_CASE("Tree support toolpaths do not depend on thread scheduling", "[TreeSupport][Regression]") +{ + // Scaled up so that a layer holds enough nodes for the parallel range to be split. At stock + // size it stays in one chunk and the order never varies. + SECTION("overhang") { sliced_twice_matches(scaled(TestMesh::overhang, 2.f), 0); } + SECTION("bridge with hole") { sliced_twice_matches(scaled(TestMesh::bridge_with_hole, 3.f), 0); } + // Dropping every branch that cannot reach the bed leaves the survivors dense enough that the + // neighbour merge fires in bulk. + SECTION("on the build plate") { sliced_twice_matches(scaled(TestMesh::overhang, 4.f), 1); } + // Branches resting on the model are what put nodes in a part group other than 0, which is the + // only way to reach the prune in the second pass. tree_hybrid additionally builds polygon + // nodes, so it is the only style that exercises the overhang merge. + SECTION("resting on the model") { sliced_twice_matches(two_tier_mesh(), 0); } + SECTION("hybrid on the model") { sliced_twice_matches(two_tier_mesh(), 0, "tree_hybrid"); } +} + +// Prim breaks equal-distance ties by heap address. A 1 mm branch diameter puts neighbours close +// enough to tie, and an explicit line width pins max_move_dist, so the moved tie winner reaches +// the support toolpaths. +TEST_CASE("Tree support toolpaths do not depend on the MST tie order", "[TreeSupport][Regression]") +{ + sliced_twice_matches(two_tier_mesh(), 0, "tree_hybrid", { + { "tree_support_branch_diameter", 1.0 }, + { "tree_support_branch_distance", 5.0 }, + { "tree_support_branch_angle", 40 }, + { "support_line_width", 0.4 }, + }); +} diff --git a/tests/fff_print/test_wipe_tower.cpp b/tests/fff_print/test_wipe_tower.cpp index 2bd7ac4189..24d50d6e66 100644 --- a/tests/fff_print/test_wipe_tower.cpp +++ b/tests/fff_print/test_wipe_tower.cpp @@ -152,6 +152,8 @@ static DynamicPrintConfig wipe_tower_toolchange_config(const std::string &gcode_ { "outer_wall_filament_id", 2 }, { "inner_wall_filament_id", 2 }, { "enable_prime_tower", true }, + { "wipe_tower_x", 50 }, // inside the 200x200 test bed + { "wipe_tower_y", 50 }, // (the default y, 220, is not) { "layer_height", 0.3 }, { "gcode_flavor", gcode_flavor }, }); @@ -182,3 +184,148 @@ TEST_CASE("The wipe tower's toolchange planner flush follows the gcode flavor", CHECK_THAT(tower, !Catch::Matchers::ContainsSubstring(unexpected)); } } + +// What Print feeds the shared estimate. The libslic3r WipeTowerEstimate cases cannot see this: +// they call the estimator directly. The estimate counts the filaments the print really uses, +// so the two-filament shape gives the outer wall the second one. +static DynamicPrintConfig tower_estimate_config(const char *wall_type, unsigned int filaments = 2) +{ + // 100 mm3 per purge on a 50 mm wide tower: one purge is 100/(layer_height * 50) of depth. + return multifilament_config(filaments, { + { "outer_wall_filament_id", filaments == 2 ? "2" : "1" }, + { "enable_prime_tower", "1" }, + { "wipe_tower_wall_type", wall_type }, + { "prime_tower_width", "50" }, + { "prime_volume", "100" }, + { "prime_tower_infill_gap", "100%" }, + { "prime_tower_brim_width", "3" }, + { "purge_in_prime_tower", "0" }, + { "single_extruder_multi_material", "0" }, + { "timelapse_type", "0" }, + { "layer_height", "0.2" }, + { "enable_wrapping_detection", "0" }, + { "raft_layers", "0" } }); +} + +TEST_CASE("The tower is sized for the thinnest layer any object on the plate is sliced at", "[WipeTower]") +{ + // The tower has to survive its thinnest layer, so an override finer than the preset drives + // the estimate even on the second object. Two 20 mm cubes, the second at 0.1 mm. + const DynamicPrintConfig config = tower_estimate_config("rectangle"); + const std::vector> overrides = { + {}, { { "layer_height", "0.1" } } }; + + Print print; + Model model; + init_print({ cube(20), cube(20) }, print, model, config, &overrides); + + // One purge at 0.1 mm: 100 / (0.1 * 50) = 20 mm, above the 20 mm-tall tower's stability + // floor. At the preset's 0.2 mm it would be half that, so the two are easy to tell apart. + const float floor_20mm = WipeTower::get_limit_depth_by_height(20.f); + REQUIRE(floor_20mm < 10.f); + CHECK_THAT(print.wipe_tower_data(2).depth, Catch::Matchers::WithinAbs(20., 1e-4)); +} + +TEST_CASE("Validation is given the tower's effective width, not the configured one", "[WipeTower]") +{ + // A rib wall squares the tower, so its width is its depth. Validation reads this rather + // than re-deriving the rule from the wall type. + Print print; + Model model; + + SECTION("a rectangle wall keeps the configured width") { + const DynamicPrintConfig config = tower_estimate_config("rectangle"); + init_print({ cube(20) }, print, model, config); + const WipeTowerData &data = print.wipe_tower_data(2); + CHECK_THAT(data.width, Catch::Matchers::WithinAbs(50., 1e-4)); + CHECK(data.depth < data.width); + } + + SECTION("a rib wall reports the squared footprint") { + const DynamicPrintConfig config = tower_estimate_config("rib"); + init_print({ cube(20) }, print, model, config); + const WipeTowerData &data = print.wipe_tower_data(2); + CHECK_THAT(data.width, Catch::Matchers::WithinAbs(data.depth, 1e-4)); + CHECK(data.width > 0.f); + } +} + +TEST_CASE("Generating the tower keeps its reported width current", "[WipeTower]") +{ + // width is handed out after the slice, so leaving it at the estimate reports a zero-width + // tower to every post-generation consumer. + const DynamicPrintConfig config = wipe_tower_toolchange_config("marlin"); + Print print; + Model model; + init_print({ cube(10) }, print, model, config); + print.apply(model, config); + REQUIRE(print.wipe_tower_data(2).width > 0.f); + + print.process(); + REQUIRE(print.is_step_done(psWipeTower)); + const WipeTowerData &data = print.wipe_tower_data(); + // A width the generator never wrote reads as zero. A rib wall squares the tower, so the + // generated width is the body square: under the configured 50 mm, and inside the depth. + CHECK(data.width > 0.f); + CHECK(data.width < 50.f); + CHECK(data.width <= data.depth + EPSILON); +} + +TEST_CASE("A single-filament plate reserves a tower only when one is actually printed", "[WipeTower]") +{ + // The estimate has to answer this the way Print::apply does: reporting no tower for one + // that is built collapses the validation hull to a point, and reporting one for a tower + // that is not built takes that bed area away from the arranger and draws a preview box + // over nothing. + Print print; + Model model; + + SECTION("no tool change and nothing else that prints one") { + const DynamicPrintConfig config = tower_estimate_config("rib", 1); + init_print({ cube(20) }, print, model, config); + REQUIRE_FALSE(print.has_wipe_tower()); + CHECK_THAT(print.wipe_tower_data(1).depth, Catch::Matchers::WithinAbs(0., 1e-6)); + } + + // A raft puts the tower on every layer below the object, but only where there is a tower: + // Print::apply runs normalize_fdm_2, which clears enable_prime_tower for a plate that + // purges one filament and has neither smooth timelapse nor wrapping detection on. + SECTION("a raft alone does not print one") { + DynamicPrintConfig config = tower_estimate_config("rib", 1); + config.set_deserialize_strict({ { "raft_layers", "3" } }); + init_print({ cube(20) }, print, model, config); + REQUIRE_FALSE(print.config().enable_prime_tower.value); + REQUIRE_FALSE(print.has_wipe_tower()); + CHECK_THAT(print.wipe_tower_data(1).depth, Catch::Matchers::WithinAbs(0., 1e-6)); + } + + SECTION("smooth timelapse prints one, and keeps enable_prime_tower on") { + DynamicPrintConfig config = tower_estimate_config("rib", 1); + config.set_deserialize_strict({ { "timelapse_type", "1" } }); + init_print({ cube(20) }, print, model, config); + REQUIRE(print.has_wipe_tower()); + CHECK(print.wipe_tower_data(1).depth > 0.f); + } +} + +TEST_CASE("A tower printed without a tool change is still validated against the bed", "[WipeTower]") +{ + // Wrapping detection prints a tower on a plate that purges one filament. Neither the old + // estimate (which read the wall type and smooth timelapse) nor the old containment gate (the + // filament count or smooth timelapse) knew about it, so between them that tower was never + // checked against the bed. + Print print; + Model model; + DynamicPrintConfig config = tower_estimate_config("rectangle", 1); + // Relative E without a per-layer G92 is rejected before the tower is ever looked at, and + // has_wipe_tower() wants a real exclusion polygon before it honours wrapping detection. + config.set_deserialize_strict({ { "enable_wrapping_detection", "1" }, + { "wrapping_exclude_area", "180x180,190x180,190x190,180x190" }, + { "wipe_tower_x", "500" }, { "wipe_tower_y", "500" }, { "use_relative_e_distances", "0" } }); + + init_print({ cube(20) }, print, model, config); + REQUIRE(print.extruders(true).size() == 1); + REQUIRE(print.has_wipe_tower()); + CHECK(print.wipe_tower_data(1).depth > 0.f); + CHECK_THAT(print.validate().string, Catch::Matchers::ContainsSubstring("printable area")); +} diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 5d3e301ea7..5c10ab1496 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -29,6 +29,7 @@ add_executable(${_TEST_NAME}_tests test_polygon.cpp test_mutable_polygon.cpp test_mutable_priority_queue.cpp + test_minimum_spanning_tree.cpp test_nozzle_volume_type.cpp test_step.cpp test_stl.cpp @@ -39,6 +40,8 @@ add_executable(${_TEST_NAME}_tests test_utils.cpp test_timeutils.cpp test_voronoi.cpp + test_wipe_tower_estimate.cpp + test_wipe_tower.cpp test_optimizers.cpp test_ordering_strategies.cpp # test_png_io.cpp diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index 4c0a09cf3f..5c38859a12 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -1,6 +1,5 @@ #include "libslic3r/Model.hpp" -#include "libslic3r/TriangleSelector.hpp" #include "libslic3r/Format/3mf.hpp" #include "libslic3r/Format/bbs_3mf.hpp" #include "libslic3r/Format/STL.hpp" @@ -9,9 +8,12 @@ #include "libslic3r/Preset.hpp" #include "libslic3r/MultiNozzleUtils.hpp" #include "libslic3r/ProjectTask.hpp" +#include "libslic3r/PublishSettings.hpp" #include "test_utils.hpp" +#include + #include #include @@ -499,7 +501,6 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") { } } - // A mixed-color filament occupies an ordinary filament slot, and painting with it stores an // ordinary extruder state: a project saved by BambuStudio encodes filament 5 of a 5-slot setup // as paint state 5, with the mix described by the parallel filament_mixed_* project arrays. @@ -590,3 +591,639 @@ SCENARIO("Mixed-color filament setup and painting round-trip through a .3mf", "[ } } } +// Locks the serialization contract of the "Publish" metadata: the orca_published flag and the +// orca_published_keys JSON array in model.model_info->metadata_items must survive a store_bbs_3mf -> +// load_bbs_3mf round-trip unchanged. (The full preset-preservation behavior is exercised +// headlessly in test_preset_bundle_loading.cpp.) +SCENARIO("Published 3MF round-trips the published flag and published_keys metadata", "[3mf]") { + GIVEN("a model carrying published metadata") { + Model model; + std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src_file.c_str(), &model)); + model.add_default_instances(); + + model.model_info = std::make_shared(); + model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = "1"; + model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] = R"(["layer_height","wall_thickness"])"; + + // store_bbs_3mf stages project_settings.config through the model's backup path; point + // it at a writable temp dir (the default lives under a read-only root in CI). + ScopedTemporaryDir backup_dir("orca_pub"); + model.set_backup_path(backup_dir.string()); + + WHEN("stored to and reloaded from a .3mf") { + ScopedTemporaryFile temp(".3mf"); + const std::string test_file = temp.string(); + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + StoreParams store_params; + store_params.path = test_file.c_str(); + store_params.model = &model; + store_params.config = &config; + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + REQUIRE(store_bbs_3mf(store_params)); + + Model dst_model; + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector project_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig); + THEN("the published metadata round-trips unchanged") { + REQUIRE(loaded); + REQUIRE(dst_model.model_info != nullptr); + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_TAG] == "1"); + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] == R"(["layer_height","wall_thickness"])"); + + // The orca_published_keys value is a JSON array of setting keys; it must parse back to + // the same keys that were selected. + nlohmann::json keys = nlohmann::json::parse(dst_model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG]); + REQUIRE(keys.is_array()); + REQUIRE(keys.size() == 2); + REQUIRE(keys[0] == "layer_height"); + REQUIRE(keys[1] == "wall_thickness"); + } + release_PlateData_list(dst_plates); + } + } +} + +// A normal 3MF (no Publish metadata) must load identically: the loader must not fabricate a +// "orca_published" flag or orca_published_keys for files that never carried them. +SCENARIO("Legacy 3MF without published metadata loads unchanged", "[3mf]") { + GIVEN("a model without any published metadata") { + Model model; + std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src_file.c_str(), &model)); + model.add_default_instances(); + + ScopedTemporaryDir backup_dir("orca_legacy"); + model.set_backup_path(backup_dir.string()); + + WHEN("stored to and reloaded from a .3mf") { + ScopedTemporaryFile temp(".3mf"); + const std::string test_file = temp.string(); + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + StoreParams store_params; + store_params.path = test_file.c_str(); + store_params.model = &model; + store_params.config = &config; + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + REQUIRE(store_bbs_3mf(store_params)); + + Model dst_model; + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector project_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig); + THEN("no published key is fabricated") { + REQUIRE(loaded); + REQUIRE(dst_model.model_info != nullptr); + REQUIRE(dst_model.model_info->metadata_items.count(ORCA_PUBLISHED_TAG) == 0); + REQUIRE(dst_model.model_info->metadata_items.count(ORCA_PUBLISHED_KEYS_TAG) == 0); + } + release_PlateData_list(dst_plates); + } + } +} + +// Locks the serialization contract of the orca_published_material_keys metadata: the per-entry JSON +// must survive a store_bbs_3mf -> load_bbs_3mf round-trip verbatim, exactly like orca_published_keys. +SCENARIO("Published 3MF round-trips the published_material_keys metadata", "[3mf]") { + GIVEN("a model carrying published material keys metadata") { + Model model; + std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src_file.c_str(), &model)); + model.add_default_instances(); + + const std::string material_keys_json = + R"([{"material":{"filament_type":"PLA","filament_vendor":"Generic","filament_id":"GFL99"},"slot":0,"keys":["filament_retraction_length","filament_z_hop"]}])"; + + model.model_info = std::make_shared(); + model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] = material_keys_json; + + ScopedTemporaryDir backup_dir("orca_pub_mat"); + model.set_backup_path(backup_dir.string()); + + WHEN("stored to and reloaded from a .3mf") { + ScopedTemporaryFile temp(".3mf"); + const std::string test_file = temp.string(); + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + StoreParams store_params; + store_params.path = test_file.c_str(); + store_params.model = &model; + store_params.config = &config; + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + REQUIRE(store_bbs_3mf(store_params)); + + Model dst_model; + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector project_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig); + THEN("the published material keys metadata round-trips unchanged") { + REQUIRE(loaded); + REQUIRE(dst_model.model_info != nullptr); + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json); + + // The value must parse back to one material entry carrying the nested identity + // object, the author slot ordinal and the key list. + nlohmann::json entries = nlohmann::json::parse(material_keys_json); + REQUIRE(entries.is_array()); + REQUIRE(entries.size() == 1); + REQUIRE(entries[0]["material"]["filament_type"] == "PLA"); + REQUIRE(entries[0]["material"]["filament_vendor"] == "Generic"); + REQUIRE(entries[0]["material"]["filament_id"] == "GFL99"); + REQUIRE(entries[0]["slot"] == 0); + REQUIRE(entries[0]["keys"].is_array()); + REQUIRE(entries[0]["keys"].size() == 2); + REQUIRE(entries[0]["keys"][0] == "filament_retraction_length"); + } + release_PlateData_list(dst_plates); + } + } +} + +SCENARIO("Minimal published 3MF omits project config, preset dumps and slicer tags", "[3mf]") { + GIVEN("a multi-instance model carrying published metadata and a published_config payload") { + Model model; + std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src_file.c_str(), &model)); + model.add_default_instances(); + // A second instance: tag-less third-party files get their multi-instance objects split, + // published files must not (the loader recognizes them by their metadata). + model.objects.front()->add_instance(); + + DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config(); + full_cfg.set_key_value("layer_height", new ConfigOptionFloat(0.24)); + full_cfg.set_key_value("retraction_length", new ConfigOptionFloats({ 1.2 })); + + const std::vector published_keys = { "layer_height", "retraction_length" }; + const std::vector material_keys = { + { "PLA", "Generic", "GFL99", "", "Generic PLA", 0, { "filament_retraction_length" } } + }; + + // The payload builder keeps the published and identity keys and drops everything else. + DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, published_keys, material_keys); + REQUIRE(filtered_cfg.option("layer_height") != nullptr); + REQUIRE(filtered_cfg.option("retraction_length") != nullptr); + REQUIRE(filtered_cfg.option("filament_colour") != nullptr); + REQUIRE(filtered_cfg.option("filament_type") != nullptr); + REQUIRE(filtered_cfg.option("wipe_tower_x") != nullptr); + REQUIRE(filtered_cfg.option("sparse_infill_density") == nullptr); + REQUIRE(filtered_cfg.option("machine_start_gcode") == nullptr); + + // Serialize the payload exactly like export_published_3mf does. + std::string payload; + for (const std::string &key : filtered_cfg.keys()) + payload += key + " = " + filtered_cfg.opt_serialize(key) + "\n"; + + model.model_info = std::make_shared(); + model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = "1"; + model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] = R"(["layer_height","retraction_length"])"; + model.model_info->metadata_items[ORCA_PUBLISHED_CONFIG_TAG] = payload; + + ScopedTemporaryDir backup_dir("orca_min_pub"); + model.set_backup_path(backup_dir.string()); + + WHEN("stored using SaveStrategy::MinimalPublished and reloaded") { + ScopedTemporaryFile temp(".3mf"); + const std::string test_file = temp.string(); + + // Create a fake project preset to verify MinimalPublished omits it. + Preset preset(Preset::TYPE_PRINT, "TestPrintPreset"); + preset.config = full_cfg; + std::vector project_presets = { &preset }; + + StoreParams store_params; + store_params.path = test_file.c_str(); + store_params.model = &model; + store_params.config = &filtered_cfg; + store_params.project_presets = project_presets; + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence | SaveStrategy::MinimalPublished; + REQUIRE(store_bbs_3mf(store_params)); + + Model dst_model; + ScopedTemporaryDir loaded_backup_dir("orca_min_pub_loaded"); + dst_model.set_backup_path(loaded_backup_dir.string()); + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector loaded_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &loaded_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig); + THEN("the 3MF loads without project config or embedded presets") { + REQUIRE(loaded); + REQUIRE(dst_config.empty()); + REQUIRE(loaded_presets.empty()); + } + THEN("the file carries no slicer tags and classifies as a generic 3MF") { + REQUIRE_FALSE(is_bbl_3mf); + REQUIRE_FALSE(is_orca_3mf); + // No Application / OrcaSlicer tag: old receivers import the geometry silently + // instead of showing a baked-in, wrong "old version" popup. + REQUIRE_FALSE(file_version.valid()); + } + THEN("the geometry keeps BBS-grade handling: instances are not split") { + REQUIRE(dst_model.objects.size() == 1); + REQUIRE(dst_model.objects.front()->instances.size() == 2); + } + THEN("the published metadata and payload round-trip unchanged") { + REQUIRE(dst_model.model_info != nullptr); + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_TAG] == "1"); + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] == R"(["layer_height","retraction_length"])"); + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_CONFIG_TAG] == payload); + } + THEN("the payload parses back to the published values") { + DynamicPrintConfig parsed_payload; + parsed_payload.load_from_ini_string(dst_model.model_info->metadata_items[ORCA_PUBLISHED_CONFIG_TAG], ForwardCompatibilitySubstitutionRule::Enable); + REQUIRE(parsed_payload.option("layer_height") != nullptr); + REQUIRE_THAT(parsed_payload.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.24, 1e-6)); + REQUIRE(parsed_payload.option("retraction_length") != nullptr); + REQUIRE_THAT(parsed_payload.opt("retraction_length")->get_at(0), Catch::Matchers::WithinAbs(1.2, 1e-6)); + } + release_PlateData_list(dst_plates); + } + } +} + +// A minimal published 3MF must not leak the slicer tags of the source project. The exporter seeds +// metadata_item_map from the input file's metadata_items, so re-publishing a project opened from a +// regular Orca/BBS 3MF (the typical remix flow) must strip the Application / OrcaSlicer tags it +// came with, otherwise old receivers route onto the baked-in "old version" popup. +SCENARIO("MinimalPublished strips slicer tags carried by the source project", "[3mf]") { + GIVEN("a model loaded from a regular Orca/BBS 3MF whose metadata carries the slicer tags") { + Model model; + std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src_file.c_str(), &model)); + model.add_default_instances(); + + model.model_info = std::make_shared(); + model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = "1"; + model.model_info->metadata_items["Application"] = "BambuStudio-2.0.0"; + model.model_info->metadata_items["OrcaSlicer"] = "2.1.0"; + + ScopedTemporaryDir backup_dir("orca_strip_tags"); + model.set_backup_path(backup_dir.string()); + + WHEN("stored using SaveStrategy::MinimalPublished and reloaded") { + ScopedTemporaryFile temp(".3mf"); + const std::string test_file = temp.string(); + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + StoreParams store_params; + store_params.path = test_file.c_str(); + store_params.model = &model; + store_params.config = &config; + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence | SaveStrategy::MinimalPublished; + REQUIRE(store_bbs_3mf(store_params)); + + Model dst_model; + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector loaded_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &loaded_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig); + THEN("the source slicer tags are stripped, not carried through") { + REQUIRE(loaded); + REQUIRE(dst_model.model_info != nullptr); + REQUIRE(dst_model.model_info->metadata_items.count("Application") == 0); + REQUIRE(dst_model.model_info->metadata_items.count("OrcaSlicer") == 0); + // The published marker itself must survive. + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_TAG] == "1"); + } + THEN("the file classifies as a generic 3MF without a version popup") { + REQUIRE_FALSE(is_bbl_3mf); + REQUIRE_FALSE(is_orca_3mf); + REQUIRE_FALSE(file_version.valid()); + } + release_PlateData_list(dst_plates); + } + } +} + +// An entry masks the non-published slots to their defaults so publishing slot 1 never leaks slot +// 0's value into the file. Both a full entry (the whole-slot key list) and a partial entry (a +// per-slot key) go through the same masking path in filter_published_config (keys and full_keys +// are filtered identically), so the two forms are exercised together. +SCENARIO("Published entries mask the other slots to their defaults", "[3mf]") { + const bool full = GENERATE(true, false); + GIVEN("a full print configuration with two filament slots") { + DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config(); + full_cfg.opt("filament_diameter")->values = { 1.75, 1.75 }; + full_cfg.opt("filament_colour")->values = { "#111111", "#222222" }; + // filament_flow_ratio carries a non-empty option default (1.0) of the same type, so the + // mask can restore it on the non-published slot. + full_cfg.opt("filament_flow_ratio", true)->values = { 1.02, 0.98 }; + + WHEN("filtering with a published entry for slot 1") { + PublishedMaterialEntry entry; + entry.slot = 1; + if (full) { + entry.full = true; + entry.full_keys = { "filament_flow_ratio" }; + } else { + entry.keys = { "filament_flow_ratio" }; + } + DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { entry }); + + THEN("the selected key is present with the author's slot value") { + REQUIRE(filtered_cfg.option("filament_flow_ratio") != nullptr); + REQUIRE_THAT(filtered_cfg.opt("filament_flow_ratio")->values[1], Catch::Matchers::WithinAbs(0.98, 1e-6)); + } + THEN("the non-published slot is masked to its default") { + REQUIRE_THAT(filtered_cfg.opt("filament_flow_ratio")->values[0], Catch::Matchers::WithinAbs(1.0, 1e-6)); + } + THEN("the identity keys stay present") { + REQUIRE(filtered_cfg.option("filament_colour") != nullptr); + } + } + } +} + +// A key needing slot masking that cannot be masked (no registered option default of the same +// type) is dropped from the payload entirely instead of shipping the author's whole vector. +SCENARIO("Unmaskable keys are dropped from the published payload instead of leaking", "[3mf]") { + GIVEN("a config carrying a synthetic def-less vector key and a maskable one") { + DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config(); + full_cfg.opt("filament_diameter")->values = { 1.75, 1.75 }; + full_cfg.opt("filament_colour")->values = { "#111111", "#222222" }; + // Not a PrintConfig key: print_config_def has no default to mask with. + full_cfg.set_key_value("orca_synthetic_setting", new ConfigOptionFloats({ 9.9, 8.8 })); + full_cfg.opt("filament_flow_ratio", true)->values = { 1.02, 0.98 }; + + PublishedMaterialEntry partial_entry; + partial_entry.slot = 1; + partial_entry.keys = { "orca_synthetic_setting", "filament_flow_ratio" }; + + WHEN("filtering with a partial entry for slot 1") { + DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { partial_entry }); + + THEN("the unmaskable synthetic key is not published") { + REQUIRE(filtered_cfg.option("orca_synthetic_setting") == nullptr); + } + THEN("the maskable key is present, author slot kept, other slot masked") { + REQUIRE(filtered_cfg.opt("filament_flow_ratio") != nullptr); + REQUIRE_THAT(filtered_cfg.opt("filament_flow_ratio")->values[1], Catch::Matchers::WithinAbs(0.98, 1e-6)); + REQUIRE_THAT(filtered_cfg.opt("filament_flow_ratio")->values[0], Catch::Matchers::WithinAbs(1.0, 1e-6)); + } + THEN("the identity keys stay present") { + REQUIRE(filtered_cfg.option("filament_colour") != nullptr); + } + } + } +} + +// A per-extruder printer key carrying a "#N" variant (e.g. retraction_length#1) must not serialize +// every extruder's value: the base is masked to the author's extruder and the other slots are +// restored to their option default, matching the material-side slot-masking invariant. A bare +// printer base key (no variant) keeps whole-vector serialization. +SCENARIO("Published per-extruder printer keys mask the other extruders to their defaults", "[3mf]") { + GIVEN("a full print configuration with three extruders carrying per-extruder retraction values") { + DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config(); + // Non-default values on the un-selected slots, so a leak is distinguishable from the mask + // restoring the option default (retraction_length defaults to {0.8}). + full_cfg.opt("retraction_length")->values = { 3.0, 1.2, 4.0 }; + + WHEN("filtering with only extruder 1's retraction_length checked") { + DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, { "retraction_length#1" }, {}); + + THEN("the author's extruder value survives") { + REQUIRE_THAT(filtered_cfg.opt("retraction_length")->values[1], Catch::Matchers::WithinAbs(1.2, 1e-6)); + } + THEN("the other extruders are masked to their default") { + REQUIRE_THAT(filtered_cfg.opt("retraction_length")->values[0], Catch::Matchers::WithinAbs(0.8, 1e-6)); + REQUIRE_THAT(filtered_cfg.opt("retraction_length")->values[2], Catch::Matchers::WithinAbs(0.8, 1e-6)); + } + } + WHEN("filtering the bare base key without a '#N' variant") { + DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, { "retraction_length" }, {}); + + THEN("the whole vector is serialized unmasked") { + REQUIRE_THAT(filtered_cfg.opt("retraction_length")->values[0], Catch::Matchers::WithinAbs(3.0, 1e-6)); + REQUIRE_THAT(filtered_cfg.opt("retraction_length")->values[1], Catch::Matchers::WithinAbs(1.2, 1e-6)); + REQUIRE_THAT(filtered_cfg.opt("retraction_length")->values[2], Catch::Matchers::WithinAbs(4.0, 1e-6)); + } + } + } +} + +// The extended per-entry fields (full dump list, published type and colour) travel inside the +// published_material_keys metadata and round-trip unchanged. +SCENARIO("Published 3MF round-trips the extended material metadata", "[3mf]") { + GIVEN("a model carrying extended published material keys metadata") { + Model model; + std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src_file.c_str(), &model)); + model.add_default_instances(); + + const std::string material_keys_json = + R"([{"material":{"filament_type":"PLA","filament_vendor":"Generic","filament_id":"GFL99","setting_id":"RFs9eCKYOMUSmvZf","name":"Generic PLA Matte @System"},"slot":1,"keys":[],"full":true,"full_keys":["filament_retraction_length","filament_colour"],"publish_type":true,"type":"PLA","publish_color":false,"color":""}])"; + + model.model_info = std::make_shared(); + model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] = material_keys_json; + + ScopedTemporaryDir backup_dir("orca_pub_mat2"); + model.set_backup_path(backup_dir.string()); + + WHEN("stored to and reloaded from a .3mf") { + ScopedTemporaryFile temp(".3mf"); + const std::string test_file = temp.string(); + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + StoreParams store_params; + store_params.path = test_file.c_str(); + store_params.model = &model; + store_params.config = &config; + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + REQUIRE(store_bbs_3mf(store_params)); + + Model dst_model; + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector project_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + bool loaded = load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig); + THEN("the extended material metadata round-trips unchanged") { + REQUIRE(loaded); + REQUIRE(dst_model.model_info != nullptr); + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json); + + // The value must parse back with every extended field intact. + nlohmann::json entries = nlohmann::json::parse(material_keys_json); + REQUIRE(entries.is_array()); + REQUIRE(entries.size() == 1); + REQUIRE(entries[0]["full"].get() == true); + REQUIRE(entries[0]["full_keys"].is_array()); + REQUIRE(entries[0]["full_keys"].size() == 2); + REQUIRE(entries[0]["publish_type"].get() == true); + REQUIRE(entries[0]["type"] == "PLA"); + REQUIRE(entries[0]["publish_color"].get() == false); + } + release_PlateData_list(dst_plates); + } + } +} + +// A published mixed filament serializes its whole definition (components, ratios, gradient) +// masked to the author's slot: the mix slot's values survive, the non-published slots reset to +// their defaults, so a partial publish never leaks another slot's mix data. +SCENARIO("Published mixed-filament keys are masked to the author's slot", "[3mf]") { + GIVEN("a full print configuration with three slots, one of them mixed") { + DynamicPrintConfig full_cfg = DynamicPrintConfig::full_print_config(); + full_cfg.opt("filament_diameter")->values = { 1.75, 1.75, 1.75 }; + full_cfg.opt("filament_colour")->values = { "#111111", "#222222", "#333333" }; + full_cfg.opt("filament_is_mixed")->values = { 0, 0, 1 }; + full_cfg.opt("filament_mixed_components")->values = { "", "", "1,2" }; + full_cfg.opt("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" }; + full_cfg.opt("filament_mixed_gradient")->values = { 0, 0, 1 }; + full_cfg.opt("filament_mixed_gradient_range")->values = { "", "", "0.9,0.1" }; + full_cfg.opt("filament_mixed_gradient_curve")->values = { "", "", "0,0.1|1,0.9" }; + full_cfg.opt("filament_mixed_gradient_per_part")->values = { 0, 0, 1 }; + + PublishedMaterialEntry mix_entry; + mix_entry.slot = 2; + mix_entry.keys = { + "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios", + "filament_mixed_gradient", "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" + }; + + WHEN("filtering with a mixed entry for slot 2") { + DynamicPrintConfig filtered_cfg = filter_published_config(full_cfg, {}, { mix_entry }); + + THEN("the author's mixed slot keeps its definition") { + REQUIRE(filtered_cfg.option("filament_is_mixed") != nullptr); + REQUIRE(filtered_cfg.opt("filament_is_mixed")->values == std::vector{ 0, 0, 1 }); + const auto& components = filtered_cfg.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 3); + CHECK(components[2] == "1,2"); + CHECK(filtered_cfg.opt("filament_mixed_sublayer_ratios")->values[2] == "0.6,0.4"); + CHECK(filtered_cfg.opt("filament_mixed_gradient_curve")->values[2] == "0,0.1|1,0.9"); + CHECK(filtered_cfg.opt("filament_mixed_gradient")->values[2]); + CHECK(filtered_cfg.opt("filament_mixed_gradient_per_part")->values[2]); + } + THEN("the non-published slots are masked to their defaults") { + CHECK(filtered_cfg.opt("filament_mixed_components")->values[0] == ""); + CHECK(filtered_cfg.opt("filament_mixed_components")->values[1] == ""); + CHECK(filtered_cfg.opt("filament_is_mixed")->values[0] == 0); + CHECK(filtered_cfg.opt("filament_is_mixed")->values[1] == 0); + } + THEN("the identity keys stay present") { + REQUIRE(filtered_cfg.option("filament_colour") != nullptr); + } + } + } +} + +// The published flag is gated on the exact string "1": any other serialized value means "not +// published", so a receiver never treats a file as published on a loose truthiness check. +TEST_CASE("is_published_3mf_flag accepts only the literal \"1\"", "[3mf]") { + CHECK(is_published_3mf_flag("1")); + CHECK_FALSE(is_published_3mf_flag("0")); + CHECK_FALSE(is_published_3mf_flag("false")); + CHECK_FALSE(is_published_3mf_flag("true")); + CHECK_FALSE(is_published_3mf_flag("")); + CHECK_FALSE(is_published_3mf_flag("YES")); +} + +// bbs_3mf_is_published is the lightweight metadata probe used to decide whether a file was +// produced by the publish feature (GUI "recently published" tracking). It must return true only +// for a file whose metadata carries the flag set to "1", and false for legacy files and for a +// file whose flag is present but not "1" (which loads as a normal, non-published 3MF). +SCENARIO("bbs_3mf_is_published detects only genuinely published 3MFs", "[3mf]") { + auto store_model = [](const std::string &path, const std::string &flag_value, const std::string &keys_value) { + Model model; + std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src_file.c_str(), &model)); + model.add_default_instances(); + model.model_info = std::make_shared(); + // An empty flag_value means "don't write the flag at all" (a legacy file). + if (!flag_value.empty()) + model.model_info->metadata_items[ORCA_PUBLISHED_TAG] = flag_value; + model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] = keys_value; + ScopedTemporaryDir backup_dir("orca_is_pub"); + model.set_backup_path(backup_dir.string()); + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + StoreParams store_params; + store_params.path = path.c_str(); + store_params.model = &model; + store_params.config = &config; + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + REQUIRE(store_bbs_3mf(store_params)); + }; + + GIVEN("a minimal published 3MF whose flag is \"1\"") { + ScopedTemporaryFile temp(".3mf"); + store_model(temp.string(), "1", R"(["layer_height"])"); + WHEN("probed by bbs_3mf_is_published") { + THEN("it is recognized as published") { + CHECK(bbs_3mf_is_published(temp.string())); + } + } + } + GIVEN("a legacy 3MF without any published flag") { + ScopedTemporaryFile temp(".3mf"); + store_model(temp.string(), "", R"(["layer_height"])"); + WHEN("probed by bbs_3mf_is_published") { + THEN("it is not recognized as published") { + CHECK_FALSE(bbs_3mf_is_published(temp.string())); + } + } + } + GIVEN("a 3MF carrying the flag set to \"0\"") { + ScopedTemporaryFile temp(".3mf"); + store_model(temp.string(), "0", R"(["layer_height"])"); + WHEN("probed and loaded") { + THEN("it is not recognized as published") { + CHECK_FALSE(bbs_3mf_is_published(temp.string())); + } + THEN("it loads as a normal, non-published 3MF") { + Model dst_model; + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector project_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + REQUIRE(load_bbs_3mf(temp.string().c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig)); + REQUIRE(dst_model.model_info != nullptr); + // The key is present but not "1", so nothing treats the file as published; the + // stored keys still round-trip verbatim. + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_TAG] == "0"); + REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_KEYS_TAG] == R"(["layer_height"])"); + release_PlateData_list(dst_plates); + } + } + } +} + diff --git a/tests/libslic3r/test_arachne_walls.cpp b/tests/libslic3r/test_arachne_walls.cpp index 4ca18400af..2b6580ed11 100644 --- a/tests/libslic3r/test_arachne_walls.cpp +++ b/tests/libslic3r/test_arachne_walls.cpp @@ -22,6 +22,8 @@ #include "libslic3r/Arachne/utils/ExtrusionLine.hpp" #include "libslic3r/Arachne/BeadingStrategy/BeadingStrategyFactory.hpp" #include "libslic3r/Arachne/BeadingStrategy/BeadingStrategy.hpp" +#include "libslic3r/Feature/FuzzySkin/FuzzySkin.hpp" +#include "libslic3r/Flow.hpp" #include "libslic3r/Polygon.hpp" #include "libslic3r/ExPolygon.hpp" #include "libslic3r/ClipperUtils.hpp" @@ -309,3 +311,71 @@ TEST_CASE("Beading interpolation tolerates a thicker side with fewer insets", "[ CHECK(result.bead_widths[i] == expected.bead_widths[i]); } } + +namespace { + +// Closed 20 mm square loop at a uniform width. +Arachne::ExtrusionJunctions square_loop(coord_t width) +{ + const coord_t s = scaled(20.); + return {{Point(0, 0), width, 0}, {Point(s, 0), width, 0}, {Point(s, s), width, 0}, {Point(0, s), width, 0}, {Point(0, 0), width, 0}}; +} + +FuzzySkinConfig thick_fuzzy_config(FuzzySkinMode mode, NoiseType noise_type, double thickness_mm) +{ + FuzzySkinConfig cfg{}; + cfg.type = FuzzySkinType::All; + cfg.thickness = scaled(thickness_mm); + cfg.point_distance = scaled(0.3); + cfg.fuzzy_first_layer = true; + cfg.noise_type = noise_type; + cfg.noise_scale = 1.0; + cfg.noise_octaves = 4; + cfg.noise_persistence = 0.5; + cfg.mode = mode; + cfg.layer_id = 5; + return cfg; +} + +} // namespace + +// Extrusion and Combined mode add noise to each junction's width. A junction narrower than +// height * (1 - PI/4) makes Flow::rounded_rectangle_extrusion_spacing() throw and fails the slice. +// The fuzz thickness is 3x the line width so the clamp is hit on every run regardless of RNG seed. +// Ridged multifractal is covered because its output is not bounded to [-1, 1], so it scales past +// the configured thickness; the floor has to hold for any noise value, not just an in-range one. +TEST_CASE("Fuzzy skin extrusion width is floored at the minimum the flow accepts", "[Arachne][FuzzySkin]") { + using namespace Slic3r::Feature::FuzzySkin; + + const double layer_height = GENERATE(0.08, 0.2, 0.28); + const auto mode = GENERATE(FuzzySkinMode::Extrusion, FuzzySkinMode::Combined); + const auto noise_type = GENERATE(NoiseType::Classic, NoiseType::Perlin, NoiseType::Billow, NoiseType::RidgedMulti, NoiseType::Voronoi); + CAPTURE(layer_height, int(mode), int(noise_type)); + + const double line_width_mm = 0.42; + auto loop = square_loop(scaled(line_width_mm)); + fuzzy_extrusion_line(loop, /*slice_z*/ 1.0, layer_height, thick_fuzzy_config(mode, noise_type, 3 * line_width_mm)); + + REQUIRE(loop.size() > 100); + + const auto narrowest = std::min_element(loop.begin(), loop.end(), [](const auto& a, const auto& b) { return a.w < b.w; }); + const double narrowest_mm = unscaled(narrowest->w); + const double floor_mm = layer_height * (1. - 0.25 * PI); + CAPTURE(narrowest_mm, floor_mm); + + CHECK(narrowest_mm < line_width_mm); // the clamp was exercised + CHECK(narrowest_mm > floor_mm); + CHECK_NOTHROW(Flow::rounded_rectangle_extrusion_spacing(float(narrowest_mm), float(layer_height))); +} + +// Displacement mode only moves points; widths must pass through unchanged. +TEST_CASE("Fuzzy skin displacement mode leaves widths untouched", "[Arachne][FuzzySkin]") { + using namespace Slic3r::Feature::FuzzySkin; + + const coord_t width = scaled(0.42); + auto loop = square_loop(width); + fuzzy_extrusion_line(loop, /*slice_z*/ 1.0, /*layer_height*/ 0.2, thick_fuzzy_config(FuzzySkinMode::Displacement, NoiseType::Classic, 1.26)); + + REQUIRE(loop.size() > 100); + CHECK(std::all_of(loop.begin(), loop.end(), [width](const auto& j) { return j.w == width; })); +} diff --git a/tests/libslic3r/test_arrange.cpp b/tests/libslic3r/test_arrange.cpp index a9fb51e352..3906cba8ba 100644 --- a/tests/libslic3r/test_arrange.cpp +++ b/tests/libslic3r/test_arrange.cpp @@ -4,6 +4,8 @@ #include "libslic3r/BoundingBox.hpp" #include "libslic3r/ClipperUtils.hpp" #include "libslic3r/ExPolygon.hpp" +#include "libslic3r/Print.hpp" +#include "libslic3r/PrintConfig.hpp" using namespace Slic3r; using namespace Slic3r::arrangement; @@ -24,11 +26,13 @@ ArrangePolygon make_square(coord_t side) return ap; } -ArrangePolygons squares(int n, double side_mm) +ArrangePolygons squares(int n, double side_mm, double height_mm = 0.) { ArrangePolygons items; - for (int i = 0; i < n; ++i) + for (int i = 0; i < n; ++i) { items.emplace_back(make_square(scaled(side_mm))); + items.back().height = height_mm; + } return items; } @@ -82,6 +86,38 @@ void require_no_overlap(const ArrangePolygons &items) REQUIRE(disjoint(placed_shapes(items))); } +// The sequential-print floor is chosen by comparing object height against the nozzle, +// so the two are defined together and every expectation is derived from them. +constexpr double NOZZLE_HEIGHT_MM = 2.5; +constexpr double CLEARANCE_MM = 30.; +constexpr double NOZZLE_FLOOR_MM = MAX_OUTER_NOZZLE_DIAMETER / 2.; + +ArrangeParams seq_print_params(coord_t min_dist) +{ + ArrangeParams p = quiet_params(min_dist); + p.is_seq_print = true; + p.clearance_radius = float(CLEARANCE_MM); + p.nozzle_height = float(NOZZLE_HEIGHT_MM); + p.object_skirt_offset = 0.f; + return p; +} + +// update_selected_items_inflation reads the bed out of the config to cap inflation. +DynamicPrintConfig bed_config() +{ + DynamicPrintConfig c; + c.set_key_value("printable_area", new ConfigOptionPoints{{0, 0}, {200, 0}, {200, 200}, {0, 200}}); + return c; +} + +ArrangePolygons squares_of_heights(const std::vector &heights_mm) +{ + ArrangePolygons items; + for (double height_mm : heights_mm) + items.push_back(squares(1, 20., height_mm).front()); + return items; +} + } // namespace // Prove the overlap check the other tests rely on actually detects overlap. @@ -222,3 +258,61 @@ TEST_CASE("Arrange aligns the pile to a custom center", "[Arrange]") REQUIRE(ap.bed_idx == 0); require_no_overlap(items); } + +TEST_CASE("Sequential print floors the object distance by object height", "[Arrange]") +{ + // The only place sequential-print clearance is enforced. The arrange menu offers + // no floor of its own, so a stored 0 has to be raised here or not at all. + struct Case + { + std::string description; + std::vector heights; + double skirt_offset_mm; + double expected_floor_mm; + }; + + auto c = GENERATE(values({ + {"objects taller than the nozzle need the full clearance", {NOZZLE_HEIGHT_MM * 2, NOZZLE_HEIGHT_MM * 2}, 0., CLEARANCE_MM}, + {"an object exactly at the nozzle height counts as tall", {NOZZLE_HEIGHT_MM, NOZZLE_HEIGHT_MM}, 0., CLEARANCE_MM}, + {"one tall object among short ones is enough", {NOZZLE_HEIGHT_MM / 2, NOZZLE_HEIGHT_MM * 2}, 0., CLEARANCE_MM}, + {"objects the nozzle clears keep only the nozzle-width floor", {NOZZLE_HEIGHT_MM / 2, NOZZLE_HEIGHT_MM / 2}, 0., NOZZLE_FLOOR_MM}, + {"a wide skirt raises the floor for short objects", {NOZZLE_HEIGHT_MM / 2, NOZZLE_HEIGHT_MM / 2}, 3., 6.}, + })); + + DYNAMIC_SECTION(c.description) + { + ArrangePolygons items = squares_of_heights(c.heights); + DynamicPrintConfig cfg = bed_config(); + ArrangeParams p = seq_print_params(0); + p.object_skirt_offset = float(c.skirt_offset_mm); + + update_selected_items_inflation(items, &cfg, p); + + CHECK(p.min_obj_distance >= scaled(c.expected_floor_mm)); + CHECK(p.min_obj_distance <= scaled(c.expected_floor_mm + 0.01)); + // Half each, so a pair ends up a full min_obj_distance apart. + CHECK(items.front().inflation == p.min_obj_distance / 2); + } +} + +TEST_CASE("Sequential print keeps an object distance already above the floor", "[Arrange]") +{ + const coord_t stored = scaled(CLEARANCE_MM * 2); + ArrangePolygons items = squares_of_heights({NOZZLE_HEIGHT_MM * 2, NOZZLE_HEIGHT_MM * 2}); + DynamicPrintConfig cfg = bed_config(); + ArrangeParams p = seq_print_params(stored); + + update_selected_items_inflation(items, &cfg, p); + CHECK(p.min_obj_distance == stored); +} + +TEST_CASE("Layered printing does not floor the object distance", "[Arrange]") +{ + ArrangePolygons items = squares_of_heights({NOZZLE_HEIGHT_MM * 2, NOZZLE_HEIGHT_MM * 2}); + DynamicPrintConfig cfg = bed_config(); + ArrangeParams p = seq_print_params(0); + p.is_seq_print = false; + + update_selected_items_inflation(items, &cfg, p); + CHECK(p.min_obj_distance == 0); +} diff --git a/tests/libslic3r/test_config.cpp b/tests/libslic3r/test_config.cpp index bd147b5881..9a70ecbaeb 100644 --- a/tests/libslic3r/test_config.cpp +++ b/tests/libslic3r/test_config.cpp @@ -1091,3 +1091,73 @@ TEST_CASE("get_filament_type treats empty vector options as absent", "[Config][F REQUIRE(displayed == "Sup.PLA"); } } + +namespace { + +// min_object_distance reads exactly these three options. +DynamicPrintConfig spacing_config(PrinterTechnology tech, PrintSequence seq, double clearance_radius) +{ + DynamicPrintConfig c; + c.set_key_value("printer_technology", new ConfigOptionEnum(tech)); + c.set_key_value("print_sequence", new ConfigOptionEnum(seq)); + c.set_key_value("extruder_clearance_radius", new ConfigOptionFloat(clearance_radius)); + return c; +} + +} // namespace + +TEST_CASE("min_object_distance floors object spacing per print sequence", "[Config]") +{ + struct Case + { + std::string description; + PrinterTechnology tech; + PrintSequence sequence; + double clearance_radius; + double expected; + }; + + auto c = GENERATE(values({ + {"sequential FFF takes a clearance radius above the floor", ptFFF, PrintSequence::ByObject, 12., 12.}, + {"sequential FFF holds the floor at the radius", ptFFF, PrintSequence::ByObject, 6., 6.}, + {"sequential FFF holds the floor below the radius", ptFFF, PrintSequence::ByObject, 4., 6.}, + {"layered FFF ignores the clearance radius", ptFFF, PrintSequence::ByLayer, 12., 6.}, + {"SLA is a flat 6mm", ptSLA, PrintSequence::ByObject, 12., 6.}, + {"SLA ignores the print sequence too", ptSLA, PrintSequence::ByLayer, 12., 6.}, + })); + + DYNAMIC_SECTION(c.description) + { + CHECK_THAT(min_object_distance(spacing_config(c.tech, c.sequence, c.clearance_radius)), + Catch::Matchers::WithinAbs(c.expected, 1e-9)); + } +} + +TEST_CASE("min_object_distance yields no floor when an FFF config lacks the options", "[Config]") +{ + // Missing options yield 0 rather than an error, so a caller gets no floor at all. + SECTION("no clearance radius") { + DynamicPrintConfig c; + c.set_key_value("printer_technology", new ConfigOptionEnum(ptFFF)); + c.set_key_value("print_sequence", new ConfigOptionEnum(PrintSequence::ByObject)); + CHECK_THAT(min_object_distance(c), Catch::Matchers::WithinAbs(0., 1e-9)); + } + + SECTION("no print sequence") { + DynamicPrintConfig c; + c.set_key_value("printer_technology", new ConfigOptionEnum(ptFFF)); + c.set_key_value("extruder_clearance_radius", new ConfigOptionFloat(12.)); + CHECK_THAT(min_object_distance(c), Catch::Matchers::WithinAbs(0., 1e-9)); + } + + SECTION("nothing at all") { + CHECK_THAT(min_object_distance(DynamicPrintConfig{}), Catch::Matchers::WithinAbs(0., 1e-9)); + } + + SECTION("an unset printer technology is treated as FFF") { + DynamicPrintConfig c; + c.set_key_value("print_sequence", new ConfigOptionEnum(PrintSequence::ByObject)); + c.set_key_value("extruder_clearance_radius", new ConfigOptionFloat(12.)); + CHECK_THAT(min_object_distance(c), Catch::Matchers::WithinAbs(12., 1e-9)); + } +} diff --git a/tests/libslic3r/test_marchingsquares.cpp b/tests/libslic3r/test_marchingsquares.cpp index 6844ecb6ac..9a11f49faa 100644 --- a/tests/libslic3r/test_marchingsquares.cpp +++ b/tests/libslic3r/test_marchingsquares.cpp @@ -1,4 +1,6 @@ +#ifndef NOMINMAX #define NOMINMAX +#endif #include #include "test_utils.hpp" diff --git a/tests/libslic3r/test_minimum_spanning_tree.cpp b/tests/libslic3r/test_minimum_spanning_tree.cpp new file mode 100644 index 0000000000..5f9998171c --- /dev/null +++ b/tests/libslic3r/test_minimum_spanning_tree.cpp @@ -0,0 +1,66 @@ +#include + +#include + +#include "libslic3r/MinimumSpanningTree.hpp" +#include "libslic3r/Point.hpp" + +using namespace Slic3r; + +// A 5x5 lattice: at every step of Prim's algorithm several candidates sit at the same +// distance from the tree, so the tie-break decides the tree's shape. +static std::vector lattice() +{ + std::vector vertices; + for (int y = 0; y < 5; ++y) + for (int x = 0; x < 5; ++x) + vertices.emplace_back(Point::new_scale(x, y)); + return vertices; +} + +static std::vector sorted_neighbours(const MinimumSpanningTree &mst, const Point &vertex) +{ + std::vector neighbours = mst.adjacent_nodes(vertex); + std::sort(neighbours.begin(), neighbours.end()); + return neighbours; +} + +TEST_CASE("Minimum spanning tree connects every vertex", "[MinimumSpanningTree]") +{ + const std::vector vertices = lattice(); + const MinimumSpanningTree mst(vertices); + + REQUIRE(mst.vertices().size() == vertices.size()); + size_t adjacency_entries = 0; + for (const Point &vertex : vertices) { + const std::vector neighbours = mst.adjacent_nodes(vertex); + REQUIRE(! neighbours.empty()); + adjacency_entries += neighbours.size(); + } + // A tree on n vertices has n - 1 edges, each listed from both ends. + REQUIRE(adjacency_entries == 2 * (vertices.size() - 1)); +} + +TEST_CASE("Minimum spanning tree does not depend on the order of the non-root vertices", "[MinimumSpanningTree][Regression]") +{ + const std::vector vertices = lattice(); + const MinimumSpanningTree reference(vertices); + + // The root stays first: Prim's tree legitimately depends on where it starts. + // Every other order of the remaining vertices must give the same tree. + std::vector> orders; + orders.emplace_back(vertices); + std::reverse(orders.back().begin() + 1, orders.back().end()); + for (size_t shift = 1; shift + 1 < vertices.size(); ++shift) { + orders.emplace_back(vertices); + std::rotate(orders.back().begin() + 1, orders.back().begin() + 1 + shift, orders.back().end()); + } + + for (const std::vector &order : orders) { + const MinimumSpanningTree mst(order); + for (const Point &vertex : vertices) { + INFO("vertex " << vertex.x() << "," << vertex.y()); + REQUIRE(sorted_neighbours(mst, vertex) == sorted_neighbours(reference, vertex)); + } + } +} diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 59aae221b5..ecdede7053 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -6,15 +6,37 @@ #include "libslic3r/PresetBundle.hpp" #include "libslic3r/AppConfig.hpp" +#include "libslic3r/Model.hpp" +#include "libslic3r/TriangleMesh.hpp" +#include "libslic3r/Utils.hpp" +#include "libslic3r/miniz_extension.hpp" #include "test_utils.hpp" +#include +#include +#include + using namespace Slic3r; namespace { namespace fs = boost::filesystem; +// Whether a key is listed in a vector of keys (published_keys / skipped_keys). +bool contains_key(const std::vector &keys, const std::string &key) +{ + return std::find(keys.begin(), keys.end(), key) != keys.end(); +} + +void check_double_vector(const std::vector &actual, std::initializer_list expected) +{ + REQUIRE(actual.size() == expected.size()); + size_t index = 0; + for (double value : expected) + REQUIRE_THAT(actual[index++], Catch::Matchers::WithinAbs(value, 1e-6)); +} + void write_print_preset(const DynamicPrintConfig &default_config, const fs::path &file, const std::string &name, const std::string &inherits = {}) { DynamicPrintConfig config(default_config); @@ -53,6 +75,22 @@ void set_renamed_from(PresetCollection &coll, const std::string &preset_name, st it->renamed_from = std::move(old_names); } +// A single-slot PLA file config for the published-material load tests (mirrors what the GUI +// builds from a 3mf's project settings before load_config_model). +DynamicPrintConfig published_pla_file_config() +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75 }; + config.opt("filament_self_index")->values = { 1 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt("filament_type")->values = { "PLA" }; + config.opt("filament_vendor")->values = { "Generic" }; + config.opt("filament_ids")->values = { "GFL99" }; + config.option("filament_retraction_length", true)->values = { 0.9 }; + return config; +} + // A standalone print preset collection that exposes the protected rename-map builder, so a // renamed_from scenario can be set up without the full system-profile load pipeline. // (PresetCollection is non-copyable - it holds a mutex - so it is constructed directly with @@ -554,6 +592,401 @@ struct LibraryFilamentTestCollection : public PresetCollection } // namespace +TEST_CASE("Missing app config is accepted as default CLI state", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + AppConfig app_config; + app_config.set_loading_path((dir.path() / "missing.conf").string()); + CHECK(app_config.load_if_exists().empty()); +} + +TEST_CASE("Read-only user preset loading does not create or delete files", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + PresetBundle bundle; + PresetsConfigSubstitutions substitutions; + + const fs::path missing_root = dir.path() / "missing-user"; + bundle.prints.load_presets(missing_root.string(), PRESET_PRINT_NAME, substitutions, + ForwardCompatibilitySubstitutionRule::EnableSilent, nullptr, + PresetOrigin(), true); + CHECK_FALSE(fs::exists(missing_root / PRESET_PRINT_NAME)); + + const fs::path malformed = dir.path() / "existing-user" / PRESET_PRINT_NAME / "malformed.json"; + fs::create_directories(malformed.parent_path()); + std::ofstream(malformed.string()) << "{not-json"; + bundle.prints.load_presets((dir.path() / "existing-user").string(), PRESET_PRINT_NAME, substitutions, + ForwardCompatibilitySubstitutionRule::EnableSilent, nullptr, + PresetOrigin(), true); + CHECK(fs::exists(malformed)); +} + +TEST_CASE("Typeless preset resolution probes loaded FFF collections", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path source_file = dir.path() / "typeless-process.json"; + std::ofstream(source_file.string()) << R"({"name":"Typeless Process","from":"User"})"; + + PresetBundle bundle; + Preset &process = add_inmemory_preset(bundle.prints, "Typeless Process"); + process.file = source_file.string(); + process.config.option("travel_speed", true)->values = {321.0}; + + DynamicPrintConfig raw; + Preset::Type resolved_type = Preset::TYPE_INVALID; + std::string error; + REQUIRE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error, false)); + CHECK(error.empty()); + CHECK(resolved_type == Preset::TYPE_PRINT); + REQUIRE(raw.option("travel_speed")->values.size() == 1); + CHECK_THAT(raw.option("travel_speed")->values.front(), Catch::Matchers::WithinAbs(321.0, 1e-6)); +} + +TEST_CASE("Typeless preset resolution preserves duplicate identity ambiguity", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path source_file = dir.path() / "duplicate-process.json"; + std::ofstream(source_file.string()) << "{}"; + + PresetBundle bundle; + add_inmemory_preset(bundle.prints, "First Process Identity").file = source_file.string(); + add_inmemory_preset(bundle.prints, "Second Process Identity").file = source_file.string(); + + DynamicPrintConfig raw; + Preset::Type resolved_type = Preset::TYPE_INVALID; + std::string error; + CHECK_FALSE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error, false)); + CHECK(error == "Preset identity is ambiguous"); + CHECK(resolved_type == Preset::TYPE_INVALID); +} + +TEST_CASE("Typeless preset resolution rejects cross-type ambiguity", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path source_file = dir.path() / "ambiguous.json"; + std::ofstream(source_file.string()) << "{}"; + + PresetBundle bundle; + add_inmemory_preset(bundle.prints, "Process Identity").file = source_file.string(); + add_inmemory_preset(bundle.filaments, "Filament Identity").file = source_file.string(); + + DynamicPrintConfig raw; + Preset::Type resolved_type = Preset::TYPE_INVALID; + std::string error; + CHECK_FALSE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error, false)); + CHECK(error == "Preset type is ambiguous"); + CHECK(resolved_type == Preset::TYPE_INVALID); +} + +TEST_CASE("Typeless preset resolution rejects a missing type candidate", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path source_file = dir.path() / "unknown.json"; + std::ofstream(source_file.string()) << "{}"; + + PresetBundle bundle; + DynamicPrintConfig raw; + Preset::Type resolved_type = Preset::TYPE_INVALID; + std::string error; + CHECK_FALSE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error, false)); + CHECK(error == "Preset type could not be resolved"); + CHECK(resolved_type == Preset::TYPE_INVALID); +} + +TEST_CASE("Exact file resolution rejects multiple preset identities", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path source_file = dir.path() / "duplicate.json"; + std::ofstream(source_file.string()) << "{}"; + + PresetBundle bundle; + Preset &first = add_inmemory_preset(bundle.prints, "First Identity"); + first.file = source_file.string(); + Preset &second = add_inmemory_preset(bundle.prints, "Second Identity"); + second.file = source_file.string(); + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "Parent"; + + std::string error; + CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, source_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error, false)); + CHECK(error == "Preset identity is ambiguous"); +} + +TEST_CASE("System preset resolution returns the canonical vendor configuration", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir source_dir; + PresetBundle bundle; + + VendorProfile vendor("VendorB"); + vendor.name = "Vendor B"; + auto [vendor_it, inserted] = bundle.vendors.emplace(vendor.id, std::move(vendor)); + REQUIRE(inserted); + + Preset &resolved = add_inmemory_preset(bundle.prints, "Vendor B Process", "fdm_process_common"); + resolved.is_system = true; + resolved.vendor = &vendor_it->second; + resolved.file = (source_dir.path() / "vendor-b-process.json").string(); + std::ofstream(resolved.file) << "{}"; + resolved.config.option("travel_speed", true)->values = {321.0}; + resolved.config.option("wall_loops", true)->value = 2; + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common"; + raw.option("wall_loops", true)->value = 5; + + std::string error; + REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, resolved.file, + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + CHECK(error.empty()); + REQUIRE(raw.option("travel_speed")->values.size() == 1); + CHECK_THAT(raw.option("travel_speed")->values.front(), Catch::Matchers::WithinAbs(321.0, 1e-6)); + CHECK(raw.option("wall_loops")->value == 2); +} + +TEST_CASE("Manifest-backed preset resolution loads the source vendor tree", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path vendor_dir = dir.path() / "Acme"; + const fs::path child_file = vendor_dir / "process" / "nested" / "child.json"; + + 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 Process","sub_path":"process/nested/child.json"}]})"; + fs::create_directories(child_file.parent_path()); + std::ofstream((vendor_dir / "process" / "base.json").string()) + << R"({"type":"process","name":"fdm_process_common","from":"system",)" + << R"("instantiation":"false","travel_speed":["321"]})"; + std::ofstream(child_file.string()) + << R"({"type":"process","name":"Acme Process","from":"system",)" + << R"("instantiation":"true","inherits":"fdm_process_common","wall_loops":"5"})"; + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common"; + raw.option("wall_loops", true)->value = 5; + + PresetBundle bundle; + std::string error; + REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, child_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + CHECK(error.empty()); + REQUIRE(raw.option("travel_speed")->values.size() == 1); + CHECK_THAT(raw.option("travel_speed")->values.front(), Catch::Matchers::WithinAbs(321.0, 1e-6)); + CHECK(raw.option("wall_loops")->value == 5); +} + +TEST_CASE("Manifest-backed resolution is scoped to the explicit source root", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + auto write_vendor = [&](const std::string &root_name, double travel_speed) { + const fs::path root = dir.path() / root_name; + const fs::path child_file = root / "Acme" / "process" / "child.json"; + fs::create_directories(child_file.parent_path()); + std::ofstream((root / "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 Process","sub_path":"process/child.json"}]})"; + std::ofstream((root / "Acme" / "process" / "base.json").string()) + << R"({"type":"process","name":"fdm_process_common","from":"system",)" + << R"("instantiation":"false","travel_speed":[")" << travel_speed << R"("]})"; + std::ofstream(child_file.string()) + << R"({"type":"process","name":"Acme Process","from":"system",)" + << R"("instantiation":"true","inherits":"fdm_process_common"})"; + return child_file; + }; + + const fs::path source_a = write_vendor("root-a", 111.0); + const fs::path source_b = write_vendor("root-b", 222.0); + REQUIRE(fs::exists(source_a)); + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "synthetic-parent-marker"; + + PresetBundle bundle; + std::string error; + REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, source_b.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + REQUIRE(raw.option("travel_speed")->values.size() == 1); + CHECK_THAT(raw.option("travel_speed")->values.front(), Catch::Matchers::WithinAbs(222.0, 1e-6)); +} + +TEST_CASE("Exact-only resolution rejects an unconfigured manifest-backed file", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path source_file = dir.path() / "Acme" / "process" / "child.json"; + fs::create_directories(source_file.parent_path()); + std::ofstream((dir.path() / "Acme.json").string()) + << R"({"version":"1.0.0","name":"Acme","process_list":[)" + << R"({"name":"Acme Process","sub_path":"process/child.json"}]})"; + std::ofstream(source_file.string()) + << R"({"type":"process","name":"Acme Process","from":"system",)" + << R"("instantiation":"true","layer_height":"0.2"})"; + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "Some Parent"; + + PresetBundle bundle; + std::string error; + CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, source_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error, false)); + CHECK(error == "Preset was not found in the loaded bundle"); +} + +TEST_CASE("Vendor filament resolution uses the shared Orca library base", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path library_dir = dir.path() / PresetBundle::ORCA_FILAMENT_LIBRARY; + const fs::path vendor_dir = dir.path() / "Acme"; + const fs::path child_file = vendor_dir / "filament" / "nested" / "petg.json"; + + 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_dir / "filament"); + std::ofstream((library_dir / "filament" / "pet.json").string()) + << R"({"type":"filament","name":"fdm_filament_pet","from":"system",)" + << R"("filament_id":"GFL99","instantiation":"false",)" + << R"("filament_type":["PETG"],"filament_density":["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/nested/petg.json","filament_id":"GFA00"}]})"; + fs::create_directories(child_file.parent_path()); + std::ofstream(child_file.string()) + << R"({"type":"filament","name":"Acme PETG","from":"system",)" + << R"("filament_id":"GFA00","instantiation":"true","inherits":"fdm_filament_pet"})"; + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_filament_pet"; + + PresetBundle bundle; + std::string error; + REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_FILAMENT, child_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + CHECK(error.empty()); + CHECK(raw.opt_string("filament_type", 0u) == "PETG"); + REQUIRE(raw.option("filament_density")->values.size() == 1); + CHECK_THAT(raw.option("filament_density")->values.front(), Catch::Matchers::WithinAbs(1.27, 1e-6)); +} + +TEST_CASE("Manifest-backed resolution rejects a missing parent", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path child_file = dir.path() / "Acme" / "process" / "child.json"; + + std::ofstream((dir.path() / "Acme.json").string()) + << R"({"version":"1.0.0","name":"Acme","process_list":[)" + << R"({"name":"Acme Process","sub_path":"process/child.json"}]})"; + fs::create_directories(child_file.parent_path()); + std::ofstream(child_file.string()) + << R"({"type":"process","name":"Acme Process","from":"system",)" + << R"("instantiation":"true","inherits":"Missing Parent","layer_height":"0.2"})"; + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "Missing Parent"; + + PresetBundle bundle; + std::string error; + CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, child_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + CHECK_FALSE(error.empty()); +} + +TEST_CASE("Manifest-backed resolution rejects a vendor load with malformed entries", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path child_file = dir.path() / "Acme" / "process" / "child.json"; + + std::ofstream((dir.path() / "Acme.json").string()) + << R"({"version":"1.0.0","name":"Acme","process_list":[123,)" + << R"({"name":"Acme Process","sub_path":"process/child.json"}]})"; + 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"})"; + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common"; + + PresetBundle bundle; + std::string error; + CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, child_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + CHECK_FALSE(error.empty()); +} + +TEST_CASE("Manifest-backed resolution rejects files absent from the vendor manifest", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path listed_file = dir.path() / "Acme" / "process" / "listed.json"; + const fs::path unlisted_file = dir.path() / "Acme" / "process" / "unlisted.json"; + + std::ofstream((dir.path() / "Acme.json").string()) + << R"({"version":"1.0.0","name":"Acme","process_list":[)" + << R"({"name":"Listed Process","sub_path":"process/listed.json"}]})"; + fs::create_directories(listed_file.parent_path()); + std::ofstream(listed_file.string()) + << R"({"type":"process","name":"Listed Process","from":"system",)" + << R"("instantiation":"true","layer_height":"0.2"})"; + std::ofstream(unlisted_file.string()) + << R"({"type":"process","name":"Unlisted Process","from":"system",)" + << R"("instantiation":"true","inherits":"fdm_process_common"})"; + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common"; + + PresetBundle bundle; + std::string error; + CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, unlisted_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + CHECK(error == "Source file is not an instantiated preset in its vendor manifest"); +} + +TEST_CASE("Manifest-backed resolution rejects a mismatched preset type", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path process_file = dir.path() / "Acme" / "process" / "child.json"; + + std::ofstream((dir.path() / "Acme.json").string()) + << R"({"version":"1.0.0","name":"Acme","process_list":[)" + << R"({"name":"Acme Process","sub_path":"process/child.json"}]})"; + fs::create_directories(process_file.parent_path()); + std::ofstream(process_file.string()) + << R"({"type":"process","name":"Acme Process","from":"system",)" + << R"("instantiation":"true","layer_height":"0.2"})"; + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "fdm_filament_common"; + + PresetBundle bundle; + std::string error; + CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_FILAMENT, process_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + CHECK(error == "Source file is not an instantiated preset in its vendor manifest"); +} + +TEST_CASE("Resolution terminates when no vendor manifest exists", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir dir; + const fs::path detached_file = dir.path() / "detached.json"; + std::ofstream(detached_file.string()) << "{}"; + + DynamicPrintConfig raw; + raw.option(BBL_JSON_KEY_INHERITS, true)->value = "Missing Parent"; + + PresetBundle bundle; + std::string error; + CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, detached_file.string(), + ForwardCompatibilitySubstitutionRule::EnableSilent, error)); + CHECK(error == "Preset was not found in the loaded bundle"); +} + // 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. @@ -605,7 +1038,6 @@ TEST_CASE("A printer specific filament supersedes the generic library filament w CHECK(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_c, nullptr))); } - namespace { // One system printer plus the filament presets a machine facing dialog has to choose between: @@ -826,6 +1258,2807 @@ TEST_CASE("Sizing the filament list to a multi-tool nozzle count keeps mixed slo } } +// A "published" 3MF keeps the user's currently-selected presets and overlays only the +// author-selected process keys onto the edited preset (mirrors the GUI load path: normalize +// before load_config_model, then the overlay in load_config_file_config). +TEST_CASE("Published 3MF overlays only the author-selected process keys onto the edited preset", "[Preset][Bundle][Published]") +{ + // The file config the GUI builds from a .3mf's project settings. + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + // The loader derives the filament count from filament_colour and throws when it is + // empty; a 3mf always carries it. + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt_float("layer_height") = 0.28; // process scalar + config.opt("wiping_volumes_extruders")->values = { 140., 150. }; // matching-size vector + config.opt("post_process")->values = { "script-a", "script-b" }; // mismatched vector + config.opt("nozzle_temperature")->values = { 220 }; // filament key (not applied anywhere) + // Structural (denylisted) key: must be silently ignored. full_print_config() omits the + // *_settings_id keys, so create one explicitly. + config.opt_string("print_settings_id", true) = "file process"; + config.opt("flush_multiplier")->values = { 2., 2. }; // must NOT cross over + config.opt("wipe_tower_x")->values = { 100. }; // plate geometry, does cross over + config.opt("wipe_tower_rotation_angle")->value = 45.; // published-only plate geometry, crosses over + config.option("curr_bed_type")->setInt(BedType::btPC); // must NOT cross over + return config; + }; + + const std::vector published_keys = { + "layer_height", "wiping_volumes_extruders", "post_process", "nozzle_temperature", "print_settings_id" + }; + + PresetBundle bundle; + const std::string pre_load_name = bundle.prints.get_edited_preset().name; + const size_t pre_load_size = bundle.prints.size(); + + // The edited presets are the overlay targets: recognizable pre-load values. + bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1; + bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values = { 10., 20. }; + bundle.prints.get_edited_preset().config.opt("post_process")->values = { "existing-script" }; + bundle.prints.get_edited_preset().config.opt_string("print_settings_id") = "user process"; + // Capture the ctor-seeded project_config values so the assertions below check the load + // leaves them untouched rather than hardcoding the defaults. + const std::vector seed_filament_colour = bundle.project_config.opt("filament_colour")->values; + const std::vector seed_flush_multiplier = bundle.project_config.opt("flush_multiplier")->values; + const int seed_bed_type = bundle.project_config.option("curr_bed_type")->getInt(); + + DynamicPrintConfig config = make_file_config(); + // The GUI normalizes the config before load; mirror that so only the production path runs. + Preset::normalize(config); + + PublishedConfig pub; + pub.published = true; + pub.published_keys = published_keys; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // a) Process scalar overlaid; matching-size vector applied, mismatched one lands in + // skipped_keys; applied keys are not reported. + CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 0.000001)); + check_double_vector(bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values, { 140., 150. }); + CHECK(bundle.prints.get_edited_preset().config.opt("post_process")->values == std::vector{ "existing-script" }); + CHECK(contains_key(pub.skipped_keys, "post_process")); + CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height")); + CHECK_FALSE(contains_key(pub.skipped_keys, "wiping_volumes_extruders")); + + // b) A filament key is never applied anywhere and is reported as skipped. + CHECK(bundle.prints.get_edited_preset().config.option("nozzle_temperature") == nullptr); + CHECK(contains_key(pub.skipped_keys, "nozzle_temperature")); + + // c) A structural key is silently ignored: neither applied nor reported as skipped. + CHECK(bundle.prints.get_edited_preset().config.opt_string("print_settings_id") == "user process"); + CHECK_FALSE(contains_key(pub.skipped_keys, "print_settings_id")); + + // d) Only plate/bed geometry crosses in published mode: filament/purge data and bed type + // stay at the ctor seeds. + CHECK(bundle.project_config.opt("filament_colour")->values == seed_filament_colour); + CHECK(bundle.project_config.opt("flush_multiplier")->values == seed_flush_multiplier); + CHECK(bundle.project_config.option("curr_bed_type")->getInt() == seed_bed_type); + check_double_vector(bundle.project_config.opt("wipe_tower_x")->values, { 100. }); + CHECK_THAT(bundle.project_config.opt("wipe_tower_rotation_angle")->value, Catch::Matchers::WithinAbs(45., 0.000001)); + + // e) The published path keeps the user's currently-selected presets: same preset, same size. + CHECK(bundle.prints.get_edited_preset().name == pre_load_name); + CHECK(bundle.prints.size() == pre_load_size); + + // f) Non-published control: the overlay is disabled, the file's presets are imported + // instead, and no skipped_keys are produced. + PresetBundle control_bundle; + const size_t control_pre_size = control_bundle.prints.size(); + PublishedConfig control_pub; + control_pub.published = false; + control_pub.published_keys = published_keys; + DynamicPrintConfig control_config = make_file_config(); + Preset::normalize(control_config); + control_bundle.load_config_model("test.3mf", std::move(control_config), Semver(), &control_pub); + + CHECK(control_pub.skipped_keys.empty()); + CHECK(control_bundle.prints.size() > control_pre_size); + // The file's layer_height reached the edited preset via the normal import, not the overlay. + CHECK_THAT(control_bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 0.000001)); +} + +// The published printer overlay is restricted to the publishable retraction/z-hop allowlist: +// matching-size vectors apply, mismatched vectors are reported as skipped, and any other +// printer-class key (e.g. machine_start_gcode) is contract-excluded (never applied, never +// reported). +TEST_CASE("Published 3MF overlays only the allowlisted retraction and z-hop keys onto the edited printer preset", "[Preset][Bundle][Published]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + Preset::normalize(config); + config.opt("retraction_length")->values = { 1.4 }; // matching size (1 extruder) + config.opt("retraction_speed")->values = { 45., 55. }; // size 2: mismatched + config.opt_string("machine_start_gcode") = "G28 ; from file"; // outside the allowlist + + PresetBundle bundle; + bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8 }; + // A recognizable non-default value: the skipped mismatch below must leave it untouched + // (asserting the default instead would silently test PrintConfig's retraction_speed). + bundle.printers.get_edited_preset().config.opt("retraction_speed")->values = { 33. }; + bundle.printers.get_edited_preset().config.opt_string("machine_start_gcode") = "G28 ; user"; + + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "retraction_length", "retraction_speed", "machine_start_gcode" }; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // Matching-size retraction vector applied; mismatched vector reported as skipped and the + // receiver's own value survives. + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 1.4 }); + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_speed")->values, { 33. }); + CHECK(contains_key(pub.skipped_keys, "retraction_speed")); + // Contract-excluded printer key: silently ignored, absent from skipped_keys. + CHECK(bundle.printers.get_edited_preset().config.opt_string("machine_start_gcode") == "G28 ; user"); + CHECK_FALSE(contains_key(pub.skipped_keys, "machine_start_gcode")); +} + +// A published 3MF carries per-slot material keys; on load they are applied positionally to the +// receiver's slot N (a key-only entry has no type gate), written onto the slot's stored preset +// in place. +TEST_CASE("Published 3MF applies positional material keys onto the receiver's material presets", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + // Two filament slots; filament_diameter drives the normalized per-slot vector sizes. + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + // Keep the multi-extruder consistency validation happy for a 2-slot config. + config.opt("filament_self_index")->values = { 1, 2 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + config.opt("filament_type")->values = { "PLA", "PETG" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFT99" }; + // Author per-slot retraction values. These are per-filament override keys that are not + // members of the static PrintRegionConfig, so full_print_config() omits them and they + // must be created explicitly (as nullable, matching the real 3MF project config). + config.option("filament_retraction_length", true)->values = { 0.9, 1.2 }; + config.option("filament_z_hop", true)->values = { 0.2, 0.3 }; + return config; + }; + + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.filament_id = "GFL99"; + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt_string("filament_vendor", 0u) = "Generic"; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + pla.config.opt("filament_settings_id")->values = { "receiver-pla" }; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.filament_id = "GFT99"; + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt_string("filament_vendor", 0u) = "Generic"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + petg.config.opt("filament_z_hop", true)->values = { 0.1 }; + bundle.filament_presets = { "My PLA", "My PETG" }; + + PublishedMaterialEntry pla_entry; + pla_entry.filament_id = "GFL99"; + pla_entry.slot = 0; // the author's PLA slot + pla_entry.keys = { "filament_retraction_length", "filament_settings_id" }; + PublishedMaterialEntry petg_entry; + petg_entry.filament_id = "GFT99"; + petg_entry.slot = 1; // the author's PETG slot + petg_entry.keys = { "filament_retraction_length", "filament_z_hop" }; + // A slot-less entry (no slot field, only possible in hand-crafted files): silently skipped. + PublishedMaterialEntry noslot_entry; + noslot_entry.filament_type = "ABS"; + noslot_entry.keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { pla_entry, petg_entry, noslot_entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The author's slot values are written onto the receiver's stored presets in place. + check_double_vector(bundle.filaments.find_preset("My PLA")->config.opt("filament_retraction_length")->values, { 0.9 }); + check_double_vector(bundle.filaments.find_preset("My PETG")->config.opt("filament_retraction_length")->values, { 1.2 }); + check_double_vector(bundle.filaments.find_preset("My PETG")->config.opt("filament_z_hop")->values, { 0.3 }); + // Structural keys inside a material entry are silently ignored: the receiver's own + // filament_settings_id is untouched and nothing is reported for it. + CHECK(bundle.filaments.find_preset("My PLA")->config.opt("filament_settings_id")->values == std::vector{ "receiver-pla" }); + CHECK_FALSE(contains_key(pub.skipped_keys, "material:GFL99 (filament_settings_id)")); + // Everything applied; the slot-less entry produced no skipped entry. + CHECK(pub.skipped_keys.empty()); +} + +// A "full publish" slot serializes the whole filament. On load the slot always receives a +// standalone detached copy of the author's material - created even when the receiver's own +// material matches the published type - and no receiver library preset is ever mutated. +TEST_CASE("Published 3MF full-published slots are imported as standalone detached copies", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + // Two author slots; filament_diameter drives the normalized per-slot vector sizes. + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + config.opt("filament_type")->values = { "PLA", "PETG" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFT99" }; + config.option("filament_retraction_length", true)->values = { 0.9, 1.2 }; + return config; + }; + // The full dump of slot 0, publishing the whole filament as type "ABS". + auto make_full_abs_entry = [] { + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "ABS"; + entry.full_keys = { "filament_retraction_length" }; + return entry; + }; + + SECTION("type match still creates a detached copy instead of mutating the receiver material") { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + bundle.filament_presets = { "My PLA", "My PLA" }; + + PublishedMaterialEntry full = make_full_abs_entry(); + full.publish_type_value = "PLA"; // author requires PLA, receiver slot is PLA + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { full }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The type matches, but the import still detaches: the slot lands on a fresh copy + // named from the published type (no identity fields in this entry), carrying the + // author's values; the receiver's own material is untouched. + CHECK(bundle.filament_presets[0] == "PLA"); + Preset *copy = bundle.filaments.find_preset("PLA", false, true); + REQUIRE(copy != nullptr); + check_double_vector(copy->config.opt("filament_retraction_length")->values, { 0.9 }); + check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + CHECK(pub.skipped_keys.empty()); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PLA -> PLA"); + } + + SECTION("type mismatch also detaches: a fresh same-type copy replaces the slot") { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + Preset &abs = add_inmemory_preset(bundle.filaments, "My ABS"); + abs.config.opt_string("filament_type", 0u) = "ABS"; + abs.config.opt("filament_retraction_length", true)->values = { 0.3 }; + bundle.filament_presets = { "My PLA" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_full_abs_entry() }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 1); + // No substitution search runs: a brand-new copy named after the published type is + // created and both pre-existing presets stay exactly as they were. + CHECK(bundle.filament_presets[0] == "ABS"); + Preset *copy = bundle.filaments.find_preset("ABS", false, true); + REQUIRE(copy != nullptr); + check_double_vector(copy->config.opt("filament_retraction_length")->values, { 0.9 }); + check_double_vector(bundle.filaments.find_preset("My ABS", false, true)->config.opt("filament_retraction_length")->values, { 0.3 }); + check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + CHECK(pub.skipped_keys.empty()); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PLA -> ABS"); + } + + SECTION("the author's identity rides on the copy when the type has no library match") { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + Preset &other = add_inmemory_preset(bundle.filaments, "Other PLA"); + other.config.opt_string("filament_type", 0u) = "PLA"; + other.config.opt("filament_retraction_length", true)->values = { 0.7 }; + bundle.filament_presets = { "My PLA" }; + + PublishedMaterialEntry full = make_full_abs_entry(); + // The dump carries the identity too, so the created copy takes the author's type + // and vendor instead of the baseline clone's. + full.full_keys = { "filament_retraction_length", "filament_type", "filament_vendor" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { full }; + DynamicPrintConfig config = make_file_config(); + // The author's slot 0 really is ABS. + config.opt("filament_type")->values = { "ABS", "PETG" }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // No ABS preset needs to exist in the library: the copy carries the author's values, + // type and vendor included. Neither receiver preset was touched. + CHECK(bundle.filament_presets[0] == "ABS"); + Preset *copy = bundle.filaments.find_preset("ABS", false, true); + REQUIRE(copy != nullptr); + check_double_vector(copy->config.opt("filament_retraction_length")->values, { 0.9 }); + CHECK(copy->config.opt_string("filament_type", 0u) == "ABS"); + CHECK(copy->config.opt_string("filament_vendor", 0u) == "Generic"); + check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt_string("filament_type", 0u) == "PLA"); + check_double_vector(bundle.filaments.find_preset("Other PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.7 }); + CHECK(pub.skipped_keys.empty()); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PLA -> ABS"); + } +} + +// The author's preset name travels in the file and names the created standalone copy (variant +// tail stripped), regardless of what the receiver's library holds: an exact-name library preset +// is never reused nor mutated. +TEST_CASE("Published 3MF imports a full material under the author's stripped name", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75 }; + config.opt("filament_self_index")->values = { 1 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt("filament_type")->values = { "PLA" }; + config.opt("filament_vendor")->values = { "Generic" }; + config.opt("filament_ids")->values = { "OGFL99" }; + config.option("filament_retraction_length", true)->values = { 0.9 }; + return config; + }; + + auto add_pla = [](PresetBundle &bundle, const char *name, const char *id, const char *vendor, const char *setting_id) { + Preset &preset = add_inmemory_preset(bundle.filaments, name); + preset.filament_id = id; + preset.setting_id = setting_id; + preset.config.opt_string("filament_type", 0u) = "PLA"; + preset.config.opt_string("filament_vendor", 0u) = vendor; + preset.config.opt("filament_retraction_length", true)->values = { 0.5 }; + return &preset; + }; + + SECTION("an exact-name library preset exists: a detached copy is created beside it") { + PresetBundle bundle; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + add_pla(bundle, "Generic PLA @System", "OGFL99", "Generic", "RcBNzytWgwRrwXXz"); + add_pla(bundle, "Bambu PLA Basic @System", "OGFA00", "Bambu Lab", "zkc85XTKi4cb6cOw"); + bundle.filament_presets = { "My PETG" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.filament_id = "OGFL99"; + entry.filament_vendor = "Generic"; + entry.setting_id = "RcBNzytWgwRrwXXz"; + entry.preset_name = "Generic PLA @System"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The copy is named after the stripped author name; the receiver's exact-name preset + // keeps its own values. + CHECK(bundle.filament_presets[0] == "Generic PLA"); + check_double_vector(bundle.filaments.find_preset("Generic PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.9 }); + check_double_vector(bundle.filaments.find_preset("Generic PLA @System", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt("filament_retraction_length")->values, { 0.6 }); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Generic PLA"); + CHECK(pub.skipped_keys.empty()); + } + + SECTION("only the name is present (broken/stale ids): the copy is still created") { + PresetBundle bundle; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + add_pla(bundle, "Generic PLA @System", "OGFL99", "Generic", "RcBNzytWgwRrwXXz"); + add_pla(bundle, "Bambu PLA Basic @System", "OGFA00", "Bambu Lab", "zkc85XTKi4cb6cOw"); + bundle.filament_presets = { "My PETG" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.preset_name = "Generic PLA @System"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + CHECK(bundle.filament_presets[0] == "Generic PLA"); + check_double_vector(bundle.filaments.find_preset("Bambu PLA Basic @System", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Generic PLA"); + CHECK(pub.skipped_keys.empty()); + } + + SECTION("grown slot (author slot 1) receives its own detached copy") { + auto two_slot_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#FFFF00" }; + config.opt("filament_type")->values = { "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + config.opt("filament_ids")->values = { "OGFL99", "OGFL99" }; + config.option("filament_retraction_length", true)->values = { 0.9, 0.8 }; + return config; + }; + + PresetBundle bundle; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + add_pla(bundle, "Generic PLA @System", "OGFL99", "Generic", "RcBNzytWgwRrwXXz"); + add_pla(bundle, "Bambu PLA Basic @System", "OGFA00", "Bambu Lab", "zkc85XTKi4cb6cOw"); + bundle.filament_presets = { "My PETG" }; + + PublishedMaterialEntry entry; + entry.slot = 1; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.preset_name = "Generic PLA @System"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = two_slot_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 2); + CHECK(bundle.filament_presets[0] == "My PETG"); + // The grown slot seeds the receiver's last preset ("My PETG"), then the full material + // detaches onto a copy carrying the author's slot-1 value; "Generic PLA @System" is + // left untouched. + CHECK(bundle.filament_presets[1] == "Generic PLA"); + check_double_vector(bundle.filaments.find_preset("Generic PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.8 }); + check_double_vector(bundle.filaments.find_preset("Generic PLA @System", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 1: My PETG -> Generic PLA"); + CHECK(pub.skipped_keys.empty()); + } +} + +// The stripped author name can collide with an existing library preset ("Generic PLA"): the +// created copy must uniquify with the "(Published)" suffix rather than overwrite, reuse or +// mutate any of the receiver's own presets. +TEST_CASE("Published 3MF uniquifies an imported full material name on collision", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + // A legacy bundle preset literally named "Generic PLA" - collides with the stripped name. + Preset &bare = add_inmemory_preset(bundle.filaments, "Generic PLA"); + bare.config.opt_string("filament_type", 0u) = "PLA"; + bare.config.opt_string("filament_vendor", 0u) = "Generic"; + bare.config.opt("filament_retraction_length", true)->values = { 0.5 }; + // The author's exact preset. + Preset &qidi = add_inmemory_preset(bundle.filaments, "Generic PLA @Qidi Q2 0.4 nozzle"); + qidi.config.opt_string("filament_type", 0u) = "PLA"; + qidi.config.opt_string("filament_vendor", 0u) = "Generic"; + qidi.config.opt("filament_retraction_length", true)->values = { 0.5 }; + // The Orca library preset. + Preset &sys = add_inmemory_preset(bundle.filaments, "Generic PLA @System"); + sys.config.opt_string("filament_type", 0u) = "PLA"; + sys.config.opt_string("filament_vendor", 0u) = "Generic"; + sys.config.opt("filament_retraction_length", true)->values = { 0.5 }; + bundle.filament_presets = { "My PETG" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.preset_name = "Generic PLA @Qidi Q2 0.4 nozzle"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = published_pla_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The copy lands beside the collision, suffixed; every pre-existing preset keeps its own + // values. + CHECK(bundle.filament_presets[0] == "Generic PLA (Published)"); + check_double_vector(bundle.filaments.find_preset("Generic PLA (Published)", false, true)->config.opt("filament_retraction_length")->values, { 0.9 }); + check_double_vector(bundle.filaments.find_preset("Generic PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + check_double_vector(bundle.filaments.find_preset("Generic PLA @Qidi Q2 0.4 nozzle", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + check_double_vector(bundle.filaments.find_preset("Generic PLA @System", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Generic PLA (Published)"); + CHECK(pub.skipped_keys.empty()); +} + +// The overlay writes onto the edited layer only when that layer survives the load (slot 0's +// preset still matches the edited preset). When the user views a non-first slot's material and +// the published entry targets that slot, the final re-select would destroy the edited layer - +// so the values must land on the stored preset instead and survive. +TEST_CASE("Published 3MF writes to the stored preset when the edited layer is re-selected away", "[Preset][Bundle][Published]") +{ + // Two-slot author config: slot 1 carries the published retraction value. + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + config.opt("filament_type")->values = { "PETG", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFT99", "GFL99" }; + config.option("filament_retraction_length", true)->values = { 0.6, 0.9 }; + return config; + }; + + PresetBundle bundle; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + bundle.filament_presets = { "My PETG", "My PLA" }; + // The user is viewing slot 1's material. + REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false)); + + PublishedMaterialEntry entry; + entry.slot = 1; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.publish_color = true; + entry.color = "#ABCDEF"; + entry.keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The published values survived on the stored preset (the edited layer was re-selected to + // slot 0's material and must not have been the only copy). + Preset *stored = bundle.filaments.find_preset("My PLA", false, true); + REQUIRE(stored != nullptr); + CHECK(stored->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + check_double_vector(stored->config.opt("filament_retraction_length")->values, { 0.9 }); + // The load re-selected slot 0's material, mirroring a normal project load. + CHECK(bundle.filaments.get_edited_preset().name == "My PETG"); + // Selecting the slot's material afterwards surfaces the applied values. + REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false)); + CHECK(bundle.filaments.get_edited_preset().config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + check_double_vector(bundle.filaments.get_edited_preset().config.opt("filament_retraction_length")->values, { 0.9 }); + CHECK(pub.skipped_keys.empty()); +} + +// The created copy is named after the author's preset with the "@variant" tail stripped; +// trailing whitespace left behind by the truncation must be trimmed away, and names without +// a tail pass through unchanged. +TEST_CASE("publish_material_base_name strips the variant tail from a published preset name", "[Preset][Bundle][Published]") +{ + CHECK(publish_material_base_name("Generic PLA @System") == "Generic PLA"); + CHECK(publish_material_base_name("Generic PLA @Qidi Q2 0.4 nozzle") == "Generic PLA"); + // Truncation at '@' leaves the space before the tail; it must not survive. + CHECK(publish_material_base_name("Generic PLA @System") == "Generic PLA"); + CHECK(publish_material_base_name("Voron Generic PLA") == "Voron Generic PLA"); + CHECK(publish_material_base_name("") == ""); + // A tail-only name strips to nothing; the caller falls back to identity fields. + CHECK(publish_material_base_name("@System").empty()); +} + +// A full-published material arrives as a brand-new standalone preset: parentless, visible, +// project-embedded ("Preset Inside Project"), carrying the author's values and colour - and +// never touching any of the receiver's own presets. +TEST_CASE("Published 3MF imports a full material as a detached project-embedded preset", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + Preset &spare = add_inmemory_preset(bundle.filaments, "Spare PLA"); + spare.config.opt_string("filament_type", 0u) = "PLA"; + spare.config.opt("filament_retraction_length", true)->values = { 0.4 }; + bundle.filament_presets = { "My PETG" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.publish_color = true; + entry.color = "#ABCDEF"; + entry.filament_id = "AFL01"; + entry.setting_id = "Sid000111222"; + entry.preset_name = "Author PLA @Vendor"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = published_pla_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The slot lands on the freshly created copy, named after the stripped author name. + CHECK(bundle.filament_presets[0] == "Author PLA"); + Preset *copy = bundle.filaments.find_preset("Author PLA", false, true); + REQUIRE(copy != nullptr); + // Detached + project-embedded contract. + CHECK(copy->is_project_embedded); + CHECK(copy->inherits().empty()); + CHECK(copy->setting_id.empty()); + CHECK(copy->vendor == nullptr); + CHECK_FALSE(copy->is_system); + CHECK_FALSE(copy->is_default); + CHECK_FALSE(copy->is_external); + CHECK(copy->is_visible); + CHECK(copy->filament_id == "AFL01"); + CHECK(copy->config.opt("filament_settings_id")->values == std::vector{ "Author PLA" }); + // The published values and colour live on the copy. + check_double_vector(copy->config.opt("filament_retraction_length")->values, { 0.9 }); + CHECK(copy->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + // Universally compatible: no printer/print restrictions survive the import. + CHECK(copy->config.opt("compatible_printers")->values.empty()); + CHECK(copy->config.opt("compatible_prints")->values.empty()); + CHECK(copy->config.opt("compatible_printers_condition")->value.empty()); + CHECK(copy->config.opt("compatible_prints_condition")->value.empty()); + // Nothing pre-existing was touched. + check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt("filament_retraction_length")->values, { 0.6 }); + check_double_vector(bundle.filaments.find_preset("Spare PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.4 }); + CHECK(pub.skipped_keys.empty()); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Author PLA"); +} + +// Compatibility restrictions riding on the receiver's baseline preset must not leak onto the +// imported copy: a detached full material is usable with every printer and print profile. +TEST_CASE("Published 3MF clears printer restrictions on the imported full material", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &restricted = add_inmemory_preset(bundle.filaments, "Restricted PLA"); + restricted.config.opt_string("filament_type", 0u) = "PLA"; + restricted.config.opt("filament_retraction_length", true)->values = { 0.5 }; + restricted.config.set_key_value("compatible_printers", new ConfigOptionStrings({ "Unrelated Printer" })); + restricted.config.set_key_value("compatible_prints", new ConfigOptionStrings({ "Unrelated Print" })); + restricted.config.option("compatible_printers_condition", true)->value = "printer_settings_id==\"Nope\""; + restricted.config.option("compatible_prints_condition", true)->value = "print_settings_id==\"Nope\""; + bundle.filament_presets = { "Restricted PLA" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = published_pla_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The copy (named from the published type; no identity fields) has no restrictions left. + CHECK(bundle.filament_presets[0] == "PLA"); + Preset *copy = bundle.filaments.find_preset("PLA", false, true); + REQUIRE(copy != nullptr); + CHECK(copy->config.opt("compatible_printers")->values.empty()); + CHECK(copy->config.opt("compatible_prints")->values.empty()); + CHECK(copy->config.opt("compatible_printers_condition")->value.empty()); + CHECK(copy->config.opt("compatible_prints_condition")->value.empty()); + // The receiver's own restricted preset keeps its restrictions. + const Preset *original = bundle.filaments.find_preset("Restricted PLA", false, true); + REQUIRE(original != nullptr); + CHECK(original->config.opt("compatible_printers")->values == std::vector{ "Unrelated Printer" }); + CHECK(original->config.opt("compatible_printers_condition")->value == "printer_settings_id==\"Nope\""); + CHECK(pub.skipped_keys.empty()); +} + +// Identical Full materials (same setting_id + preset_name identity) share one created +// instance: an author who pointed several slots at one material gets one standalone copy, +// and the first entry's slot values win. +TEST_CASE("Published 3MF shares one imported copy between identical full slots", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + config.opt("filament_type")->values = { "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + config.opt("filament_ids")->values = { "AFL01", "AFL01" }; + config.option("filament_retraction_length", true)->values = { 0.9, 0.8 }; + return config; + }; + auto make_entry = [](int slot) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.filament_id = "AFL01"; + entry.setting_id = "Sid000111222"; + entry.preset_name = "Author PLA @Vendor"; + entry.full_keys = { "filament_retraction_length" }; + return entry; + }; + + PresetBundle bundle; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + Preset &other = add_inmemory_preset(bundle.filaments, "Other PETG"); + other.config.opt_string("filament_type", 0u) = "PETG"; + other.config.opt("filament_retraction_length", true)->values = { 0.65 }; + bundle.filament_presets = { "My PETG", "Other PETG" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_entry(0), make_entry(1) }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // Both slots point at the single shared copy; no second "(Published)" instance exists. + REQUIRE(bundle.filament_presets.size() == 2); + CHECK(bundle.filament_presets[0] == "Author PLA"); + CHECK(bundle.filament_presets[1] == "Author PLA"); + CHECK(bundle.filaments.find_preset("Author PLA", false, true) != nullptr); + CHECK(bundle.filaments.find_preset("Author PLA (Published)", false, true) == nullptr); + // The first entry's slot values won. + check_double_vector(bundle.filaments.find_preset("Author PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.9 }); + // Both slots reported, same target; originals untouched. + REQUIRE(pub.material_replacements.size() == 2); + CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Author PLA"); + CHECK(pub.material_replacements[1] == "slot 1: Other PETG -> Author PLA"); + check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt("filament_retraction_length")->values, { 0.6 }); + check_double_vector(bundle.filaments.find_preset("Other PETG", false, true)->config.opt("filament_retraction_length")->values, { 0.65 }); + CHECK(pub.skipped_keys.empty()); +} + +// Without a preset name the copy falls back to the stable material id; fully anonymous +// hand-crafted entries never share instances between slots (their dedup key is slot-scoped). +TEST_CASE("Published 3MF names unidentified full materials from their fallback fields", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + config.opt("filament_type")->values = { "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFT99" }; + config.option("filament_retraction_length", true)->values = { 0.9, 1.2 }; + return config; + }; + + SECTION("empty preset_name falls back to the filament_id") { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + bundle.filament_presets = { "My PLA" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.filament_id = "AFL01"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = published_pla_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + CHECK(bundle.filament_presets[0] == "AFL01"); + CHECK(bundle.filaments.find_preset("AFL01", false, true) != nullptr); + CHECK(pub.skipped_keys.empty()); + } + + SECTION("fully anonymous entries get slot-scoped copies") { + PresetBundle bundle; + Preset &first = add_inmemory_preset(bundle.filaments, "First PLA"); + first.config.opt_string("filament_type", 0u) = "PLA"; + first.config.opt("filament_retraction_length", true)->values = { 0.5 }; + Preset &second = add_inmemory_preset(bundle.filaments, "Second PLA"); + second.config.opt_string("filament_type", 0u) = "PLA"; + second.config.opt("filament_retraction_length", true)->values = { 0.55 }; + bundle.filament_presets = { "First PLA", "Second PLA" }; + + PublishedMaterialEntry entry0; + entry0.slot = 0; + entry0.full = true; + entry0.publish_type = true; + entry0.publish_type_value = "ABS"; // the only naming field present + entry0.full_keys = { "filament_retraction_length" }; + PublishedMaterialEntry entry1 = entry0; + entry1.slot = 1; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry0, entry1 }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // No shared identity: each slot gets its own uniquified copy. + CHECK(bundle.filament_presets[0] == "ABS"); + CHECK(bundle.filament_presets[1] == "ABS (Published)"); + CHECK(bundle.filaments.find_preset("ABS", false, true) != nullptr); + CHECK(bundle.filaments.find_preset("ABS (Published)", false, true) != nullptr); + CHECK(pub.skipped_keys.empty()); + } +} + +// Within-load dedup does not span loads: importing the same published file again into the same +// session creates a second, uniquified copy instead of mutating or reusing the first. +TEST_CASE("Re-importing a published full material uniquifies the second copy", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + bundle.filament_presets = { "My PETG" }; + + auto make_entry = [] { + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.filament_id = "AFL01"; + entry.setting_id = "Sid000111222"; + entry.preset_name = "Author PLA @Vendor"; + entry.full_keys = { "filament_retraction_length" }; + return entry; + }; + + for (int round = 0; round < 2; ++round) { + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_entry() }; + DynamicPrintConfig config = published_pla_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + if (round == 0) { + CHECK(bundle.filament_presets[0] == "Author PLA"); + CHECK(bundle.filaments.find_preset("Author PLA (Published)", false, true) == nullptr); + } else { + // The second import uniquifies beside the first instead of touching it. + CHECK(bundle.filament_presets[0] == "Author PLA (Published)"); + check_double_vector(bundle.filaments.find_preset("Author PLA (Published)", false, true)->config.opt("filament_retraction_length")->values, { 0.9 }); + check_double_vector(bundle.filaments.find_preset("Author PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.9 }); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: Author PLA -> Author PLA (Published)"); + } + CHECK(pub.skipped_keys.empty()); + } +} + +// A partially-published slot can carry a curated type and/or colour. The colour is applied +// regardless of the type match; a type mismatch with no same-type replacement keeps the +// receiver's material and reports the slot's keys as skipped. The receiver's slot count grows +// only as far as the highest slot with published content. +TEST_CASE("Published 3MF partial slots apply colour and gate keys by the published type", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + config.opt("filament_type")->values = { "PLA", "PETG" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFT99" }; + config.option("filament_retraction_length", true)->values = { 0.9, 1.2 }; + return config; + }; + + SECTION("matching type applies the keys and the colour") { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + bundle.filament_presets = { "My PLA" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.publish_color = true; + entry.color = "#ABCDEF"; + entry.keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // Type matched: keys and colour applied onto the receiver's preset in place. + Preset *pla_preset = bundle.filaments.find_preset("My PLA", false, true); + REQUIRE(pla_preset != nullptr); + check_double_vector(pla_preset->config.opt("filament_retraction_length")->values, { 0.9 }); + CHECK(pla_preset->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + CHECK(pub.skipped_keys.empty()); + CHECK(pub.material_replacements.empty()); + } + + SECTION("type mismatch without a replacement keeps the material and skips the keys") { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + bundle.filament_presets = { "My PLA" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.publish_type = true; + entry.publish_type_value = "ABS"; // no ABS in the receiver library + entry.publish_color = true; + entry.color = "#ABCDEF"; + entry.keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The slot keeps the receiver's material: the colour applies in place, the keys are + // skipped. + CHECK(bundle.filament_presets[0] == "My PLA"); + Preset *pla_preset = bundle.filaments.find_preset("My PLA", false, true); + REQUIRE(pla_preset != nullptr); + check_double_vector(pla_preset->config.opt("filament_retraction_length")->values, { 0.5 }); + CHECK(pla_preset->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + CHECK(contains_key(pub.skipped_keys, "material:ABS (filament_retraction_length)")); + CHECK(pub.material_replacements.empty()); + } + + SECTION("receiver slot count grows to fit the highest published slot and assigns matching type preset") { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.8 }; + // The receiver has a single slot; the file carries two, only slot 1 is published. + bundle.filament_presets = { "My PLA" }; + + PublishedMaterialEntry entry; + entry.slot = 1; + entry.publish_type = true; + entry.publish_type_value = "PETG"; + entry.keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The slot list was grown so author slot 1 has a material (the PETG preset), which + // then receives the author's values in place. + REQUIRE(bundle.filament_presets.size() == 2); + CHECK(bundle.filament_presets[1] == "My PETG"); + check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt("filament_retraction_length")->values, { 1.2 }); + check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + } +} + +// The receiver's slot list grows only as far as the highest published slot: a file whose author +// published nothing (or only a low slot) must not pull filler materials into the receiver's +// setup, and the receiver never grows to the file's count. +TEST_CASE("Published 3MF grows the receiver's slots only as far as the published slots", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + // Four author slots (a 4-filament model). + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3, 4 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" }; + config.opt("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" }; + return config; + }; + auto add_pla_preset = [](PresetBundle &bundle) { + Preset &preset = add_inmemory_preset(bundle.filaments, "My PLA"); + preset.config.opt_string("filament_type", 0u) = "PLA"; + preset.config.opt("filament_colour", true)->values = { "#123456" }; + return &preset; + }; + auto make_color_entry = [](int slot) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.publish_color = true; + entry.color = "#ABCDEF"; + return entry; + }; + + // A file whose author published nothing for any slot: the receiver's setup is untouched. + { + PresetBundle bundle; + add_pla_preset(bundle); + bundle.filament_presets = { "My PLA" }; + + PublishedConfig pub; + pub.published = true; // no material entries at all + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + CHECK(bundle.filament_presets.size() == 1); + CHECK(bundle.filaments.find_preset("My PLA")->config.opt("filament_colour")->values == std::vector{ "#123456" }); + CHECK(pub.skipped_keys.empty()); + } + + // Only slot 0 published: a single-slot receiver keeps its single slot; the file's other + // three slots pull nothing in. + { + PresetBundle bundle; + add_pla_preset(bundle); + bundle.filament_presets = { "My PLA" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_color_entry(0) }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + CHECK(bundle.filament_presets.size() == 1); + // The published colour lands on the slot's preset in place. + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + } + + // Slot 3 published: the receiver grows to 4 so the published slot exists. Unpublished + // filler slots repeat the receiver's last preset ("Add one filament" behaviour). + { + PresetBundle bundle; + add_pla_preset(bundle); + bundle.filament_presets = { "My PLA" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_color_entry(3) }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 4); + CHECK(bundle.filament_presets[1] == "My PLA"); + CHECK(bundle.filament_presets[2] == "My PLA"); + // Only "My PLA" exists in the library, so the published slot keeps the aliasing and the + // colour is written onto the shared preset (every slot references it). + CHECK(bundle.filament_presets[3] == "My PLA"); + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + // The project-level per-slot vectors were grown and seeded: fillers take their preset's + // colour, the published slot its published colour. + CHECK(bundle.project_config.opt("filament_colour")->values.size() == 4); + CHECK(bundle.project_config.opt("filament_colour")->values[1] == "#123456"); + CHECK(bundle.project_config.opt("filament_colour")->values[3] == "#ABCDEF"); + CHECK(bundle.project_config.opt("filament_multi_colour")->values.size() == 4); + CHECK(bundle.project_config.opt("filament_colour_type")->values.size() == 4); + CHECK(bundle.project_config.opt("filament_map")->values.size() == 4); + CHECK(bundle.project_config.opt("flush_volumes_matrix")->values.size() == 16); + } + + // Slots 0 and 2 published: the receiver grows to 3, never to the file's 4. + { + PresetBundle bundle; + add_pla_preset(bundle); + bundle.filament_presets = { "My PLA" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_color_entry(0), make_color_entry(2) }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + CHECK(bundle.filament_presets[1] == "My PLA"); + } + + // A receiver with more slots than the file's filament count keeps its setup: neither the + // preset list nor the project-level vectors are shrunk to the file's smaller size. + { + PresetBundle bundle; + add_pla_preset(bundle); + bundle.filament_presets = { "My PLA", "My PLA", "My PLA" }; + // Distinct project colours make a shrink observable. + bundle.project_config.opt("filament_colour")->values = { "#111111", "#222222", "#333333" }; + bundle.project_config.opt("filament_multi_colour")->values = { "#111111", "#222222", "#333333" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_color_entry(0) }; // highest published slot: 0 + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + // A one-filament file: num_filaments (1) is below the receiver's slot count (3). + config.opt("filament_diameter")->values = { 1.75 }; + config.opt("filament_self_index")->values = { 1 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt("filament_type")->values = { "PLA" }; + config.opt("filament_vendor")->values = { "Generic" }; + config.opt("filament_ids")->values = { "GFL99" }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + // No shrink: all three project entries survive, with slot 0 synced to the published + // colour at its unshifted index and slots 1-2 untouched. + CHECK(bundle.project_config.opt("filament_colour")->values == std::vector{ "#ABCDEF", "#222222", "#333333" }); + CHECK(bundle.project_config.opt("filament_multi_colour")->values == std::vector{ "#ABCDEF", "#222222", "#333333" }); + CHECK(bundle.project_config.opt("filament_map")->values.size() == 3); + // The published colour still reached slot 0's preset in place. + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + } +} + +// A published slot is seeded from an unused library preset and the values are written onto it +// in place, so the receiver's own material (slot 0) is never overwritten. +TEST_CASE("Published 3MF grows published slots to the receiver's last preset and recolors it in place", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3, 4 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" }; + config.opt("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" }; + return config; + }; + + // Receiver with its own material plus one more library preset; author publishes only slot 4 + // (Red). Growth always repeats the receiver's last filament ("Add one filament"), so the + // grown slot references the shared "My PLA" preset and the published red recolors it in + // place; the unused "Other PLA" preset is left untouched. + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + mine.config.opt("filament_colour", true)->values = { "#123456" }; + Preset &other = add_inmemory_preset(bundle.filaments, "Other PLA"); + other.config.opt_string("filament_type", 0u) = "PLA"; + other.config.opt("filament_colour", true)->values = { "#654321" }; + bundle.filament_presets = { "My PLA" }; + + PublishedMaterialEntry entry; + entry.slot = 3; + entry.publish_color = true; + entry.color = "#ABCDEF"; + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 4); + // Every grown slot (published or filler) repeats the receiver's last preset. + CHECK(bundle.filament_presets[1] == "My PLA"); + CHECK(bundle.filament_presets[2] == "My PLA"); + // The published red recolors the shared "My PLA" preset in place; the unused "Other PLA" + // preset is left untouched. + CHECK(bundle.filament_presets[3] == "My PLA"); + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + CHECK(bundle.filaments.find_preset("Other PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#654321" }); + // The project-level colours are sized and seeded for every grown slot. + CHECK(bundle.project_config.opt("filament_colour")->values.size() == 4); + CHECK(bundle.project_config.opt("filament_colour")->values[1] == "#123456"); + CHECK(bundle.project_config.opt("filament_colour")->values[3] == "#ABCDEF"); + CHECK(bundle.project_config.opt("filament_multi_colour")->values.size() == 4); + CHECK(bundle.project_config.opt("filament_colour_type")->values.size() == 4); + CHECK(bundle.project_config.opt("filament_map")->values.size() == 4); +} + +// Growth always repeats the receiver's last preset; a published slot only lands on its +// material identity when the aliased grown slot is re-pointed (de-alias fires on a preset +// key). Lock the identity priority there: an exact filament_id outranks an arbitrary unused +// preset, and an entry carrying only a family constrains the pick to that family. +TEST_CASE("Published 3MF re-points an aliased grown slot by published identity or family without a type requirement", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3, 4 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" }; + config.opt("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" }; + return config; + }; + + PublishedMaterialEntry entry; + entry.slot = 2; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.keys = { "filament_retraction_length" }; + // An unused preset sorting before everything else: an unconstrained pick would take it. + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + Preset &arbitrary = add_inmemory_preset(bundle.filaments, "Aaa PLA"); + arbitrary.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA" }; + + SECTION("an exact filament_id outranks the first unused preset") { + Preset &authored = add_inmemory_preset(bundle.filaments, "Zzz PLA"); + authored.config.opt_string("filament_type", 0u) = "PLA"; + authored.filament_id = "GFA00"; + entry.filament_id = "GFA00"; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + CHECK(bundle.filament_presets[1] == "My PLA"); + CHECK(bundle.filament_presets[2] == "Zzz PLA"); + // The exact-id preset outranks the type-only "Aaa PLA"; the re-point is reported. + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 2: My PLA -> Zzz PLA"); + } + + SECTION("a family-only entry picks an unused preset of that family") { + Preset &petg = add_inmemory_preset(bundle.filaments, "Bbb PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + entry.filament_type = "PETG"; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + // The family pick lands on the only PETG preset (the PLA presets and the receiver's own + // material lose), and the re-point is reported. + CHECK(bundle.filament_presets[2] == "Bbb PETG"); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 2: My PLA -> Bbb PETG"); + } +} + +// The GUI displays the edited preset, a snapshot of the selected collection preset taken at +// selection time. Since the overlay mutates the collection presets in place, the load must +// re-select the first slot's filament so the applied values - and slot replacements - surface +// in the GUI. +TEST_CASE("Published 3MF refreshes the edited preset so the applied material values surface", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75 }; + config.opt("filament_self_index")->values = { 1 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt("filament_type")->values = { "PLA" }; + config.opt("filament_vendor")->values = { "Generic" }; + config.opt("filament_ids")->values = { "GFL99" }; + config.option("filament_retraction_length", true)->values = { 0.9 }; + return config; + }; + auto make_entry = [] { + PublishedMaterialEntry entry; + entry.slot = 0; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.publish_color = true; + entry.color = "#ABCDEF"; + entry.keys = { "filament_retraction_length" }; + return entry; + }; + + SECTION("the edited preset carries the applied colour and keys") { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + bundle.filament_presets = { "My PLA" }; + // Mirror the GUI: the displayed preset is the collection's edited preset. + REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false)); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_entry() }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + const Preset &edited = bundle.filaments.get_edited_preset(); + // The slot references the edited preset, so the overlay lands on the edited layer: + // visible as a modification while the stored preset stays untouched. + CHECK(edited.name == "My PLA"); + CHECK(edited.config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + check_double_vector(edited.config.opt("filament_retraction_length")->values, { 0.9 }); + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#123456" }); + check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.5 }); + // The overlay is a visible, revertible modification of the edited preset. + CHECK(bundle.filaments.current_is_dirty()); + CHECK(pub.skipped_keys.empty()); + } + + SECTION("a slot replacement is reflected in the edited preset") { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + Preset &abs = add_inmemory_preset(bundle.filaments, "My ABS"); + abs.config.opt_string("filament_type", 0u) = "ABS"; + abs.config.opt("filament_retraction_length", true)->values = { 0.3 }; + bundle.filament_presets = { "My PLA" }; + REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false)); + + PublishedMaterialEntry entry = make_entry(); + entry.publish_type_value = "ABS"; // mismatch: replaced by the library's ABS + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets[0] == "My ABS"); + // The edited preset now displays the replacement with the author's values on top. + const Preset &edited = bundle.filaments.get_edited_preset(); + CHECK(edited.name == "My ABS"); + CHECK(edited.config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + check_double_vector(edited.config.opt("filament_retraction_length")->values, { 0.9 }); + } +} + +// The overlay lands on the edited layer when the slot references the collection's edited +// preset, so the user's unsaved in-memory edits on it survive a published load (only the +// published keys are touched) and the change shows as a visible, revertible modification. +TEST_CASE("Published 3MF preserves unsaved edits on the edited filament preset", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + pla.config.opt("filament_z_hop", true)->values = { 0.1 }; + bundle.filament_presets = { "My PLA" }; + REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false)); + + // The user has unsaved in-memory edits on the preset being shown. + bundle.filaments.get_edited_preset().config.opt("filament_z_hop")->values = { 0.7 }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.publish_color = true; + entry.color = "#ABCDEF"; + entry.keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = published_pla_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // Published values land on the edited layer... + const Preset &edited = bundle.filaments.get_edited_preset(); + CHECK(edited.name == "My PLA"); + CHECK(edited.config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + check_double_vector(edited.config.opt("filament_retraction_length")->values, { 0.9 }); + // ...the user's unsaved edit on a non-published key survives... + check_double_vector(edited.config.opt("filament_z_hop")->values, { 0.7 }); + // ...and the stored preset is untouched. + Preset *stored = bundle.filaments.find_preset("My PLA", false, true); + REQUIRE(stored != nullptr); + CHECK(stored->config.opt("filament_colour")->values == std::vector{ "#123456" }); + check_double_vector(stored->config.opt("filament_retraction_length")->values, { 0.5 }); + check_double_vector(stored->config.opt("filament_z_hop")->values, { 0.1 }); + // The overlay is a visible, revertible modification of the edited preset. + CHECK(bundle.filaments.current_is_dirty()); + CHECK(pub.skipped_keys.empty()); + CHECK(pub.material_replacements.empty()); +} + +// The published overlay must validate '#' variant indices: an out-of-range index is reported as +// skipped and must NOT resize/corrupt the receiver's vector, and a variant suffix on a scalar +// key is rejected instead of silently no-op'd. +TEST_CASE("Published 3MF rejects out-of-range vector variants and variant-suffixed scalar keys", "[Preset][Bundle][Published]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + // Vector key, size 2 (matches the receiver's resized vector); distinct values make the + // applied element observable. + config.opt("wiping_volumes_extruders")->values = { 140., 150. }; + config.opt_float("layer_height") = 0.28; + Preset::normalize(config); + + PresetBundle bundle; + bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values = { 10., 20. }; + bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1; + + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "wiping_volumes_extruders#5", "wiping_volumes_extruders#1", "wiping_volumes_extruders#abc", "layer_height#0" }; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // In-range variant applied element-wise; the out-of-range one did not resize the vector. + check_double_vector(bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values, { 10., 150. }); + CHECK(bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values.size() == 2); + // Out-of-range variant, malformed variant and variant-suffixed scalar are reported as + // skipped; the malformed one must not fall back to element 0. + CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders#5")); + CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders#abc")); + CHECK(contains_key(pub.skipped_keys, "layer_height#0")); + CHECK_FALSE(contains_key(pub.skipped_keys, "wiping_volumes_extruders#1")); + // The scalar was never applied. + CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.1, 0.000001)); +} + +// The print/printer overlay guards option types like the material pass does: a published key +// whose file-side option kind differs from the receiver's is reported as skipped instead of +// throwing ConfigurationError out of load_config_model, which would abort the whole project +// load. (Nullable variants share the type() of their non-nullable base, so this covers +// genuinely different option kinds - e.g. a string where a float vector is expected.) +TEST_CASE("Published 3MF reports type-mismatched keys as skipped instead of aborting", "[Preset][Bundle][Published]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + // The file carries the vector key as a string option (equal size to the receiver's)... + config.set_key_value("wiping_volumes_extruders", new ConfigOptionStrings({ "140", "150" })); + config.opt_float("layer_height") = 0.28; + + PresetBundle bundle; + // ...while the receiver's edited print preset holds the float variant of the same key: + // without the type guard, ConfigOptionVector::set() throws ConfigurationError out of + // load_config_model. + bundle.prints.get_edited_preset().config.set_key_value("wiping_volumes_extruders", new ConfigOptionFloats({ 10., 20. })); + bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1; + + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "wiping_volumes_extruders", "layer_height" }; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The load completes; the type-mismatched key is reported as skipped and the receiver's + // value is untouched; the matching scalar key still applies. + CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders")); + check_double_vector(bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values, { 10., 20. }); + CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 0.000001)); + CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height")); +} + +// A receiver filament preset missing its material identity (hand-edited file) must not crash +// the type gate: the gate reads it as a type mismatch, and the slot falls back to the +// "no replacement" path (keys skipped, colour still applied to the slot's preset). +TEST_CASE("Published 3MF survives a receiver preset missing its material identity", "[Preset][Bundle][Published]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75 }; + config.opt("filament_self_index")->values = { 1 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt("filament_type")->values = { "PLA" }; + config.opt("filament_vendor")->values = { "Generic" }; + config.opt("filament_ids")->values = { "GFL99" }; + config.option("filament_retraction_length", true)->values = { 0.9 }; + Preset::normalize(config); + + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + // Malformed receiver preset: the identity options are missing entirely. + pla.config.erase("filament_type"); + pla.config.erase("filament_vendor"); + pla.config.opt("filament_colour", true)->values = { "#123456" }; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + bundle.filament_presets = { "My PLA" }; + + PublishedMaterialEntry entry; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.filament_id = "GFL99"; + entry.slot = 0; + entry.publish_type = true; // exercises the type gate against the missing identity + // Require ABS: the receiver library (PLA-typed default preset, typeless slot preset) has no + // ABS candidate, so the gate falls into the "no replacement" path (a PLA requirement would + // legitimately replace the slot with the visible PLA default). + entry.publish_type_value = "ABS"; + entry.publish_color = true; + entry.color = "#ABCDEF"; + entry.keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The gate reads the missing identity as a type mismatch; no same-type replacement exists, + // so the keys are skipped while the colour applies to the slot's preset in place. No crash. + CHECK(contains_key(pub.skipped_keys, "material:GFL99 (filament_retraction_length)")); + CHECK(bundle.filament_presets[0] == "My PLA"); + Preset *mine_preset = bundle.filaments.find_preset("My PLA", false, true); + REQUIRE(mine_preset != nullptr); + CHECK(mine_preset->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); +} + +// Lock the exact contents and order of the printer allowlist (the union of the tab's +// "Retraction" and "Z-Hop" optgroup lists, Tab.cpp). +TEST_CASE("Printer publishable allowlist matches the printer tab's Retraction and Z-Hop optgroups", "[Preset][Bundle][Published]") +{ + auto keys_of = [](const std::vector& opts) { + std::vector keys; + keys.reserve(opts.size()); + for (const PublishablePrinterOption& opt : opts) + keys.emplace_back(opt.key); + return keys; + }; + + const std::vector expected_retraction = { + "retraction_length", "retract_restart_extra", "retraction_speed", "deretraction_speed", + "retraction_minimum_travel", "retract_when_changing_layer", "wipe", "wipe_distance", + "retract_before_wipe", "retract_after_wipe" + }; + const std::vector expected_z_hop = { + "retract_lift_enforce", "z_hop_types", "z_hop", "travel_slope", "retract_lift_above", + "retract_lift_below" + }; + + CHECK(keys_of(publishable_printer_retraction_options()) == expected_retraction); + CHECK(keys_of(publishable_printer_z_hop_options()) == expected_z_hop); + + std::set expected_union(expected_retraction.begin(), expected_retraction.end()); + expected_union.insert(expected_z_hop.begin(), expected_z_hop.end()); + CHECK(publishable_printer_keys() == expected_union); +} + +// Loading the same published file twice must not compound values on the receiver's presets: +// each load re-applies the same absolute values, so the result is idempotent. +TEST_CASE("Published 3MF reloading does not compound values on the receiver's presets", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75 }; + config.opt("filament_self_index")->values = { 1 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt("filament_type")->values = { "PLA" }; + config.opt("filament_vendor")->values = { "Generic" }; + config.opt("filament_ids")->values = { "GFL99" }; + config.option("filament_retraction_length", true)->values = { 0.9 }; + return config; + }; + auto make_entry = [] { + PublishedMaterialEntry entry; + entry.slot = 0; + entry.publish_color = true; + entry.color = "#ABCDEF"; + entry.keys = { "filament_retraction_length" }; + return entry; + }; + + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + pla.config.opt("filament_retraction_length", true)->values = { 0.5 }; + bundle.filament_presets = { "My PLA" }; + + auto load = [&] { + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_entry() }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + }; + load(); + // First load: the receiver's preset carries the published values (mutated in place). + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + load(); + // Each load re-applies the same values onto the (already mutated) preset: no accumulation. + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == std::vector{ "#ABCDEF" }); + check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_retraction_length")->values, { 0.9 }); +} + +// A receiver with several slots aliasing the same preset (multi-extruder profile with one +// filament) and an author publishing keys on several slots: each published slot is re-pointed +// at its own distinct preset so values never leak between slots. +TEST_CASE("Published 3MF gives each published slot its own preset on an aliased receiver", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3, 4 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" }; + config.opt("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" }; + config.option("filament_retraction_length", true)->values = { 0.6, 0.9, 1.2, 1.5 }; + return config; + }; + auto make_key_entry = [](int slot) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.keys = { "filament_retraction_length" }; + return entry; + }; + + // A 4-extruder receiver with a single filament preset: the slots alias [A, A, A, A] before + // the published pass. + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + mine.config.opt("filament_retraction_length", true)->values = { 0.5 }; + // Spare library presets for the re-pointing to fall back on. + for (const char *name : { "Extra PLA A", "Extra PLA B", "Extra PLA C" }) { + Preset &extra = add_inmemory_preset(bundle.filaments, name); + extra.config.opt_string("filament_type", 0u) = "PLA"; + extra.config.opt("filament_retraction_length", true)->values = { 0.5 }; + } + bundle.filament_presets = { "My PLA", "My PLA", "My PLA", "My PLA" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_key_entry(0), make_key_entry(1), make_key_entry(2), make_key_entry(3) }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 4); + // Every published slot references its own distinct preset: slot 0 keeps the receiver's + // material, slots 1-3 are re-pointed at the spare library presets. + CHECK(bundle.filament_presets[0] == "My PLA"); + CHECK(bundle.filament_presets[1] != bundle.filament_presets[0]); + CHECK(bundle.filament_presets[2] != bundle.filament_presets[0]); + CHECK(bundle.filament_presets[2] != bundle.filament_presets[1]); + CHECK(bundle.filament_presets[3] != bundle.filament_presets[0]); + CHECK(bundle.filament_presets[3] != bundle.filament_presets[1]); + CHECK(bundle.filament_presets[3] != bundle.filament_presets[2]); + // Each slot's stored preset carries its own slot's retraction (mutated in place). + const std::vector expected = { 0.6, 0.9, 1.2, 1.5 }; + for (size_t slot = 0; slot < 4; ++slot) { + Preset *preset = bundle.filaments.find_preset(bundle.filament_presets[slot], false, true); + REQUIRE(preset != nullptr); + check_double_vector(preset->config.opt("filament_retraction_length")->values, { expected[slot] }); + } + CHECK(pub.skipped_keys.empty()); +} + +// De-aliasing runs on the exported identity even without a checked Type row: the re-pointed +// slot lands on the exact published material (by filament_id) rather than an arbitrary spare, +// and the formerly silent re-point is surfaced through the replacements notification list. +TEST_CASE("Published 3MF de-aliases an aliased slot by published identity without a type requirement", "[Preset][Bundle][Published]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + config.opt("filament_type")->values = { "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99" }; + config.option("filament_retraction_length", true)->values = { 0.6, 0.9 }; + + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + mine.config.opt("filament_retraction_length", true)->values = { 0.5 }; + // A spare sorting before the exact match: an unconstrained pick would take it. + Preset &spare = add_inmemory_preset(bundle.filaments, "Aaa PLA"); + spare.config.opt_string("filament_type", 0u) = "PLA"; + spare.config.opt("filament_retraction_length", true)->values = { 0.5 }; + Preset &match = add_inmemory_preset(bundle.filaments, "Zzz PLA"); + match.config.opt_string("filament_type", 0u) = "PLA"; + match.config.opt("filament_retraction_length", true)->values = { 0.5 }; + match.filament_id = "GFA00"; + bundle.filament_presets = { "My PLA", "My PLA" }; + + PublishedMaterialEntry entry; + entry.slot = 1; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.filament_id = "GFA00"; + entry.keys = { "filament_retraction_length" }; + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 2); + CHECK(bundle.filament_presets[0] == "My PLA"); + CHECK(bundle.filament_presets[1] == "Zzz PLA"); + // The published key was written onto the re-pointed slot's own preset. + Preset *target = bundle.filaments.find_preset("Zzz PLA", false, true); + REQUIRE(target != nullptr); + check_double_vector(target->config.opt("filament_retraction_length")->values, { 0.9 }); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 1: My PLA -> Zzz PLA"); + CHECK(pub.skipped_keys.empty()); +} + +// Printer retraction keys are published per-extruder ("#N"): a receiver with a different +// extruder count still receives the in-range elements; out-of-range variants are reported as +// skipped instead of corrupting the receiver's vector. +TEST_CASE("Published 3MF applies per-extruder printer keys across extruder-count mismatches", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + // Author has 4 extruders. + config.opt("retraction_length")->values = { 0.6, 0.9, 1.2, 1.5 }; + Preset::normalize(config); + return config; + }; + + // Receiver with a single extruder: only "#0" is in range; "#1..#3" are skipped. + { + PresetBundle bundle; + bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8 }; + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "retraction_length#0", "retraction_length#1", "retraction_length#2", "retraction_length#3" }; + DynamicPrintConfig config = make_file_config(); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.6 }); + CHECK(contains_key(pub.skipped_keys, "retraction_length#1")); + CHECK(contains_key(pub.skipped_keys, "retraction_length#2")); + CHECK(contains_key(pub.skipped_keys, "retraction_length#3")); + CHECK_FALSE(contains_key(pub.skipped_keys, "retraction_length#0")); + } + + // Receiver with four extruders and a 1-extruder author: only "#0" is published; the + // receiver's other extruders keep their own values. + { + PresetBundle bundle; + bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8, 0.8, 0.8, 0.8 }; + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "retraction_length#0" }; + DynamicPrintConfig config = make_file_config(); + // The author's file carries a single-extruder value. + config.opt("retraction_length")->values = { 0.7 }; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.7, 0.8, 0.8, 0.8 }); + CHECK(pub.skipped_keys.empty()); + } +} + +// A published mixed filament serializes its definition (components, ratios, gradient) into the +// receiver's project_config - the project-level parallel arrays, not a filament preset. The +// mix's own blended colour is carried as publish_color so the receiver renders the swatch. +TEST_CASE("Published 3MF applies a mixed filament definition onto the receiver's project config", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + // Three author slots: two physical PLA/PETG plus one virtual mixed slot (index 2) + // blending slots 1 and 2 at 60/40 with a gradient. + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3 }; + config.opt("filament_extruder_variant")->values = { + "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" + }; + config.opt("filament_colour")->values = { "#FF0000", "#0000FF", "#800080" }; + config.opt("filament_type")->values = { "PLA", "PETG", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFT99", "GFL99" }; + // The mixed slot's definition. These keys are project-level arrays in the full config; + // on export they are masked so only the published slot's entry survives. + config.opt("filament_is_mixed")->values = { 0, 0, 1 }; + config.opt("filament_mixed_components")->values = { "", "", "1,2" }; + config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" }; + config.opt("filament_mixed_gradient")->values = { 0, 0, 1 }; + config.opt("filament_mixed_gradient_range")->values = { "", "", "0.9,0.1" }; + config.opt("filament_mixed_gradient_curve")->values = { "", "", "0,0.1|1,0.9" }; + config.opt("filament_mixed_gradient_per_part")->values = { 0, 0, 1 }; + return config; + }; + + // A receiver that already carries the mix slot at index 2 as an actual mixed slot (e.g. a + // two-physical-plus-one-mix project with the same layout): the incoming definition is a + // like-for-like override of the virtual slot and applies in place without relocation. + { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + bundle.filament_presets = { "My PLA", "My PETG", "My PLA" }; + + // Grow the receiver's project arrays to 3 slots first, as set_num_filaments would, + // then mark the third slot as the receiver's own mixed filament. + bundle.set_num_filaments(3); + bundle.project_config.opt("filament_is_mixed")->values[2] = 1; + bundle.project_config.opt("filament_mixed_components")->values[2] = "1,1"; + bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[2] = "0.5,0.5"; + + PublishedMaterialEntry mix; + mix.filament_type = "PLA"; + mix.filament_vendor = "Generic"; + mix.filament_id = "GFL99"; + mix.slot = 2; + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", + "filament_mixed_sublayer_ratios", "filament_mixed_gradient", + "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The definition landed in project_config's parallel arrays at the author slot. + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 3); + CHECK(is_mixed[2]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 3); + CHECK(components[2] == "1,2"); + const auto &ratios = bundle.project_config.opt("filament_mixed_sublayer_ratios")->values; + REQUIRE(ratios.size() == 3); + CHECK(ratios[2] == "0.6,0.4"); + const auto &gradient = bundle.project_config.opt("filament_mixed_gradient")->values; + CHECK(gradient[2]); + const auto &range = bundle.project_config.opt("filament_mixed_gradient_range")->values; + CHECK(range[2] == "0.9,0.1"); + const auto &curve = bundle.project_config.opt("filament_mixed_gradient_curve")->values; + CHECK(curve[2] == "0,0.1|1,0.9"); + const auto &per_part = bundle.project_config.opt("filament_mixed_gradient_per_part")->values; + CHECK(per_part[2]); + // The mix's blended colour crossed into project_config for the swatch. + const auto &colour = bundle.project_config.opt("filament_colour")->values; + REQUIRE(colour.size() == 3); + CHECK(colour[2] == "#800080"); + // The other slots were not overwritten by the mask. + CHECK_FALSE(is_mixed[0]); + CHECK_FALSE(is_mixed[1]); + // Nothing skipped: every serialized mixed key was applied. + CHECK(pub.skipped_keys.empty()); + // Like-for-like override: no slot was relocated. + CHECK(pub.material_replacements.empty()); + } + + // A receiver with fewer slots: the slot is grown and seeded before the definition applies. + { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA" }; + + PublishedMaterialEntry mix; + mix.slot = 2; + mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 3); + CHECK(is_mixed[2]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 3); + CHECK(components[2] == "1,2"); + CHECK(pub.skipped_keys.empty()); + } +} + +// A published mixed filament whose definition cannot be applied is reported as skipped instead +// of aborting the load: the entry lists a mixed key that the file's payload does not carry. +TEST_CASE("Published 3MF reports an unappliable mixed filament definition as skipped", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA" }; + + // Two author slots (so slot 1 is in range) but the payload omits the mixed arrays: the + // entry lists them, the file config does not. + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75 }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + config.opt("filament_type")->values = { "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic" }; + + PublishedMaterialEntry mix; + mix.slot = 1; + mix.keys = { "filament_mixed_components", "filament_mixed_sublayer_ratios" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The receiver grows to two slots; the missing payload keys are reported as skipped rather + // than dropped silently (material_label is empty for this entry). + REQUIRE(bundle.filament_presets.size() == 2); + CHECK(contains_key(pub.skipped_keys, "material: (filament_mixed_components)")); + CHECK(contains_key(pub.skipped_keys, "material: (filament_mixed_sublayer_ratios)")); +} + +// A published mixed filament must never convert one of the receiver's real, physical slots +// into a virtual mix: definitions that collide with a physical slot are relocated past every +// positional (real-filament) destination, while ones colliding with an existing mixed slot +// override it in place. +TEST_CASE("Published 3MF relocates a mixed filament instead of overwriting a physical slot", "[Preset][Bundle][Published]") +{ + // An author project with slots whose last slot is a mixed filament. + auto make_file_config = [](size_t num_author_slots, size_t num_tail_mixes = 1) { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + std::vector diameters(num_author_slots, 1.75); + std::vector self_index; + std::vector variants; + std::vector types; + for (size_t i = 0; i < num_author_slots; ++i) { + self_index.push_back(int(i + 1)); + variants.emplace_back("Direct Drive Standard"); + types.push_back(i % 2 == 0 ? "PLA" : "PETG"); + } + config.opt("filament_diameter")->values = diameters; + config.opt("filament_self_index")->values = self_index; + config.opt("filament_extruder_variant")->values = variants; + config.opt("filament_colour")->values = { "#FF0000", "#00AA00", "#0000FF", "#FFFF00", "#800080" }; + config.opt("filament_colour")->values.resize(num_author_slots, "#808080"); + config.opt("filament_type")->values = types; + config.opt("filament_vendor")->values.assign(num_author_slots, "Generic"); + config.opt("filament_ids")->values.resize(num_author_slots); + // The last author slots are mixed ones (components differ per slot so + // the definitions are distinguishable after relocation). + const size_t first_mix_slot = num_author_slots - num_tail_mixes; + config.opt("filament_is_mixed")->values.assign(num_author_slots, 0); + config.opt("filament_mixed_components")->values.assign(num_author_slots, ""); + config.opt("filament_mixed_sublayer_ratios")->values.assign(num_author_slots, ""); + for (size_t i = first_mix_slot; i < num_author_slots; ++i) { + config.opt("filament_is_mixed")->values[i] = 1; + config.opt("filament_mixed_components")->values[i] = + i % 2 == 0 ? std::string("1,2") : std::string("1,3"); + config.opt("filament_mixed_sublayer_ratios")->values[i] = + i % 2 == 0 ? std::string("0.6,0.4") : std::string("0.3,0.7"); + } + return config; + }; + + // The reported bug: an author publishes with physical filaments on slots 1-2 and a mixed + // filament on slot 5; the receiver runs five real filaments of his own. Slot 5 must stay + // untouched and the mix lands as a newly appended virtual slot 6. + { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA", "My PLA", "My PLA", "My PLA", "My PLA" }; + bundle.set_num_filaments(5, "#123456"); + const std::vector receiver_colours = + bundle.project_config.opt("filament_colour")->values; + + PublishedMaterialEntry mix; + mix.filament_type = "PLA"; + mix.filament_vendor = "Generic"; + mix.slot = 4; + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", + "filament_mixed_sublayer_ratios", "filament_mixed_gradient", + "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + DynamicPrintConfig config = make_file_config(5); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The receiver grew by exactly one extra virtual slot. + REQUIRE(bundle.filament_presets.size() == 6); + // All five physical slots kept their meaning: no mixed flag, untouched names/colours. + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 6); + CHECK_FALSE(is_mixed[0]); + CHECK_FALSE(is_mixed[1]); + CHECK_FALSE(is_mixed[2]); + CHECK_FALSE(is_mixed[3]); + CHECK_FALSE(is_mixed[4]); + CHECK(is_mixed[5]); + CHECK(std::equal(receiver_colours.begin(), receiver_colours.end(), + bundle.project_config.opt("filament_colour")->values.begin())); + CHECK(bundle.filament_presets[0] == "My PLA"); + CHECK(bundle.filament_presets[4] == "My PLA"); + // The definition itself is readable at the new index. + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 6); + CHECK(components[5] == "1,2"); + const auto &ratios = bundle.project_config.opt("filament_mixed_sublayer_ratios")->values; + REQUIRE(ratios.size() == 6); + CHECK(ratios[5] == "0.6,0.4"); + // The blended colour seeds the swatch of the new slot only. + const auto &colour = bundle.project_config.opt("filament_colour")->values; + REQUIRE(colour.size() == 6); + CHECK(colour[5] == "#800080"); + // The relocation is surfaced to the user through the post-import notice (the de-alias + // pass may contribute further messages, so presence is asserted, not the count). + bool relocated_reported = false; + for (const std::string &message : pub.material_replacements) + if (message.find("slot 4 -> slot 5") != std::string::npos) + relocated_reported = true; + CHECK(relocated_reported); + CHECK(pub.skipped_keys.empty()); + } + + // A definition colliding with the receiver's own mixed filament is overridden in place: + // nothing grows, nothing is reported as moved. + { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA", "My PLA", "My PLA" }; + bundle.set_num_filaments(3); + bundle.project_config.opt("filament_is_mixed")->values[2] = 1; + bundle.project_config.opt("filament_mixed_components")->values[2] = "1,1"; + bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[2] = "0.9,0.1"; + + PublishedMaterialEntry mix; + mix.slot = 2; + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", + "filament_mixed_sublayer_ratios" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + DynamicPrintConfig config = make_file_config(3); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + CHECK(bundle.filament_presets.size() == 3); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 3); + CHECK_FALSE(is_mixed[0]); + CHECK_FALSE(is_mixed[1]); + CHECK(is_mixed[2]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 3); + CHECK(components[2] == "1,2"); + const auto &ratios = bundle.project_config.opt("filament_mixed_sublayer_ratios")->values; + REQUIRE(ratios.size() == 3); + CHECK(ratios[2] == "0.6,0.4"); + CHECK(pub.skipped_keys.empty()); + CHECK(pub.material_replacements.empty()); + } + + // Author publishes four physical filaments plus two mixed ones on slots 5 and 6; the + // receiver runs five real filaments. Both mixes relocate onto consecutive fresh slots, + // preserving their author order (slot 5 -> slot 6, slot 6 -> slot 7); no receiver slot is + // converted into a virtual mix. + { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA", "My PLA", "My PLA", "My PLA", "My PLA" }; + bundle.set_num_filaments(5, "#123456"); + const std::vector receiver_colours = + bundle.project_config.opt("filament_colour")->values; + + auto make_mix_entry = [](int authored_slot, const char *color) { + PublishedMaterialEntry entry; + entry.slot = authored_slot; + entry.publish_color = true; + entry.color = color; + entry.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" }; + return entry; + }; + PublishedMaterialEntry mix_a = make_mix_entry(4, "#800080"); + PublishedMaterialEntry mix_b = make_mix_entry(5, "#FF69B4"); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix_a, mix_b }; + DynamicPrintConfig config = make_file_config(6, 2); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // Two fresh virtual slots were appended. + REQUIRE(bundle.filament_presets.size() == 7); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 7); + for (size_t i = 0; i < 5; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(is_mixed[5]); + CHECK(is_mixed[6]); + // The definitions follow their author order. + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 7); + CHECK(components[5] == "1,2"); + CHECK(components[6] == "1,3"); + const auto &ratios = bundle.project_config.opt("filament_mixed_sublayer_ratios")->values; + REQUIRE(ratios.size() == 7); + CHECK(ratios[5] == "0.6,0.4"); + CHECK(ratios[6] == "0.3,0.7"); + // The five real slots kept their colours; each mix's blended colour seeded its new slot. + const auto &colour = bundle.project_config.opt("filament_colour")->values; + REQUIRE(colour.size() == 7); + CHECK(std::equal(receiver_colours.begin(), receiver_colours.end(), colour.begin())); + CHECK(colour[5] == "#800080"); + CHECK(colour[6] == "#FF69B4"); + // Both relocations are reported with the correct mapping. + bool a_reported = false, b_reported = false; + for (const std::string &message : pub.material_replacements) { + if (message.find("slot 4 -> slot 5") != std::string::npos) + a_reported = true; + if (message.find("slot 5 -> slot 6") != std::string::npos) + b_reported = true; + } + CHECK(a_reported); + CHECK(b_reported); + CHECK(pub.skipped_keys.empty()); + // The relocation table is exposed for the model-reference remapping. + REQUIRE(pub.mixed_slot_relocations.size() == 2); + CHECK(pub.mixed_slot_relocations.at(4) == 5); + CHECK(pub.mixed_slot_relocations.at(5) == 6); + } +} + +// A receiver that already owns a MIXED filament must keep the physical-first invariant after a +// published-3MF import: when incoming physical filaments would land on (or ahead of) the +// receiver's mixed slot, that mix is displaced to a fresh tail slot instead of being left +// interleaved with them (the R,M,R bug). +TEST_CASE("Published 3MF relocates the receiver's mixed filament past the incoming physical slots", "[Preset][Bundle][Published]") +{ + // Build the receiver's tool-changer with three slots, the third being the receiver's own + // mixed filament. A SEMM (single_extruder_multi_material) receiver sizes its slot list by + // hand, so a lower slot count than the printer's nozzle count is preserved on load - a + // non-SEMM tool-changer would top the preset list up to the nozzle count and shift the + // expected sizes (the rebalance logic under test is the same either way). + auto make_receiver = [](PresetBundle &bundle, const std::string &components, const std::string &ratios) { + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA", "My PLA", "My PLA" }; + bundle.set_num_filaments(3, "#123456"); + bundle.printers.get_edited_preset().config.opt("single_extruder_multi_material", true)->value = true; + bundle.project_config.opt("filament_is_mixed")->values[2] = 1; + bundle.project_config.opt("filament_mixed_components")->values[2] = components; + bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[2] = ratios; + bundle.project_config.opt("filament_colour")->values[2] = "#800080"; + bundle.project_config.opt("filament_multi_colour")->values[2] = "#800080"; + }; + auto make_real_entry = [](int slot, const char *color) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.publish_color = true; + entry.color = color; + return entry; + }; + // A four-physical author project with no mixed slots (colour publish only), as in the + // reported Ferrari reference file. + auto make_config_4_real = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3, 4 }; + config.opt("filament_extruder_variant")->values = { + "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" + }; + config.opt("filament_colour")->values = { "#FF0000", "#000000", "#FFFFFF", "#FFFF00" }; + config.opt("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" }; + return config; + }; + + // [R, R, M] + four colour-only physical slots at authored 0..3 -> [R, R, R, R, M]. + { + PresetBundle bundle; + make_receiver(bundle, "1,2", "0.5,0.5"); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_real_entry(0, "#FF0000"), make_real_entry(1, "#000000"), + make_real_entry(2, "#FFFFFF"), make_real_entry(3, "#FFFF00") }; + DynamicPrintConfig config = make_config_4_real(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The receiver grows by one extra virtual slot; the mix lands at the tail. + REQUIRE(bundle.filament_presets.size() == 5); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 5); + for (size_t i = 0; i < 4; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(is_mixed[4]); + // The definition travelled with its swatch colour; the vacated slot 2 became physical. + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 5); + CHECK(components[4] == "1,2"); + CHECK(components[2].empty()); + const auto &colour = bundle.project_config.opt("filament_colour")->values; + REQUIRE(colour.size() == 5); + CHECK(colour[4] == "#800080"); + CHECK(colour[2] == "#FFFFFF"); + CHECK(pub.mixed_slot_relocations.at(2) == 4); + bool relocated_reported = false; + for (const std::string &message : pub.material_replacements) + if (message.find("slot 2 -> slot 4") != std::string::npos && + message.find("mixed filament") != std::string::npos) + relocated_reported = true; + CHECK(relocated_reported); + CHECK(pub.skipped_keys.empty()); + } + + // [R, R, M] plus a payload mix authored at slot 3: the receiver mix (displaced to slot 3) sits + // ahead of the appended payload mix (slot 4), preserving physical-first tail ordering. + { + PresetBundle bundle; + make_receiver(bundle, "1,2", "0.5,0.5"); + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3, 4 }; + config.opt("filament_extruder_variant")->values = { + "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" + }; + config.opt("filament_colour")->values = { "#FF0000", "#000000", "#0000FF", "#800080" }; + config.opt("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" }; + // Authored slot 3 is a payload mixed definition blending slots 1 and 3. + config.opt("filament_is_mixed")->values = { 0, 0, 0, 1 }; + config.opt("filament_mixed_components")->values = { "", "", "", "1,3" }; + config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "", "0.6,0.4" }; + config.opt("filament_mixed_gradient")->values = { 0, 0, 0, 1 }; + config.opt("filament_mixed_gradient_range")->values = { "", "", "", "0.9,0.1" }; + config.opt("filament_mixed_gradient_curve")->values = { "", "", "", "0,0.1|1,0.9" }; + config.opt("filament_mixed_gradient_per_part")->values = { 0, 0, 0, 1 }; + + PublishedMaterialEntry mix; + mix.slot = 3; + mix.filament_type = "PLA"; + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios", + "filament_mixed_gradient", "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_real_entry(0, "#FF0000"), make_real_entry(1, "#000000"), + make_real_entry(2, "#0000FF"), mix }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 5); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 5); + CHECK_FALSE(is_mixed[0]); + CHECK_FALSE(is_mixed[1]); + CHECK_FALSE(is_mixed[2]); + CHECK(is_mixed[3]); // receiver's mix, displaced to slot 3 first + CHECK(is_mixed[4]); // payload's mix, appended after + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 5); + CHECK(components[3] == "1,2"); + CHECK(components[4] == "1,3"); + REQUIRE(pub.mixed_slot_relocations.size() == 2); + CHECK(pub.mixed_slot_relocations.at(2) == 3); + CHECK(pub.mixed_slot_relocations.at(3) == 4); + CHECK(pub.skipped_keys.empty()); + } +} + +// Multiple receiver mixed slots interleaved with multiple incoming physical slots all rebalance +// onto consecutive tail slots in index order (no cascade/overlap). +TEST_CASE("Published 3MF rebalances several receiver mixed slots past the physical region", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA", "My PLA", "My PLA" }; + bundle.set_num_filaments(3, "#123456"); + bundle.printers.get_edited_preset().config.opt("single_extruder_multi_material", true)->value = true; + // Receiver: slot 1 and slot 2 are mixed. + bundle.project_config.opt("filament_is_mixed")->values[1] = 1; + bundle.project_config.opt("filament_is_mixed")->values[2] = 1; + bundle.project_config.opt("filament_mixed_components")->values[1] = "1,2"; + bundle.project_config.opt("filament_mixed_components")->values[2] = "1,3"; + bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[1] = "0.5,0.5"; + bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[2] = "0.4,0.6"; + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3 }; + config.opt("filament_extruder_variant")->values = { + "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" + }; + config.opt("filament_colour")->values = { "#FF0000", "#00AA00", "#0000FF" }; + config.opt("filament_type")->values = { "PLA", "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99" }; + + auto make_real_entry = [](int slot, const char *color) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.publish_color = true; + entry.color = color; + return entry; + }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_real_entry(0, "#FF0000"), make_real_entry(1, "#00AA00"), make_real_entry(2, "#0000FF") }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 5); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 5); + for (size_t i = 0; i < 3; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(is_mixed[3]); + CHECK(is_mixed[4]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 5); + CHECK(components[3] == "1,2"); + CHECK(components[4] == "1,3"); + REQUIRE(pub.mixed_slot_relocations.size() == 2); + CHECK(pub.mixed_slot_relocations.at(1) == 3); + CHECK(pub.mixed_slot_relocations.at(2) == 4); + CHECK(pub.skipped_keys.empty()); +} + +// The receiver's printer gates how many PHYSICAL filament slots a published 3MF may add: a +// non-SEMM tool-changer feeds filament N from nozzle N, so a published slot past the nozzle +// count cannot become a physical filament. It becomes an empty mixed-filament placeholder +// instead - a virtual tail slot the GUI flags (broken mix) and the user fills with components +// from their own filaments. SEMM receivers keep the ungated behaviour. +TEST_CASE("Published 3MF turns a surplus slot past the printer's filament capacity into an empty mixed placeholder", "[Preset][Bundle][Published]") +{ + // An author project with physical slots, no mixed ones. + auto make_file_config = [](size_t num_author_slots) { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + std::vector diameters(num_author_slots, 1.75); + std::vector self_index; + std::vector variants; + for (size_t i = 0; i < num_author_slots; ++i) { + self_index.push_back(int(i + 1)); + variants.emplace_back("Direct Drive Standard"); + } + config.opt("filament_diameter")->values = diameters; + config.opt("filament_self_index")->values = self_index; + config.opt("filament_extruder_variant")->values = variants; + config.opt("filament_colour")->values.resize(num_author_slots, "#808080"); + config.opt("filament_type")->values.assign(num_author_slots, "PLA"); + config.opt("filament_vendor")->values.assign(num_author_slots, "Generic"); + config.opt("filament_ids")->values.resize(num_author_slots); + return config; + }; + // A non-SEMM receiver with nozzles running copies of one preset. + auto make_receiver = [](PresetBundle &bundle, size_t nozzles, size_t slots) { + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets.assign(slots, "My PLA"); + bundle.set_num_filaments(slots, "#123456"); + auto &printer_config = bundle.printers.get_edited_preset().config; + printer_config.opt("single_extruder_multi_material", true)->value = false; + printer_config.opt("nozzle_diameter", true)->values.assign(nozzles, 0.4); + }; + auto make_physical_entry = [](int slot, const char *color) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.publish_color = true; + entry.color = color; + return entry; + }; + + // The reported case: an author publishes with a filament on slot 5; the receiver is a + // 4-filament tool-changer. The receiver keeps its four physical slots and the surplus + // material lands as an empty mixed placeholder at the tail. + { + PresetBundle bundle; + make_receiver(bundle, 4, 4); + const std::vector receiver_colours = + bundle.project_config.opt("filament_colour")->values; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_physical_entry(4, "#ABCDEF") }; + DynamicPrintConfig config = make_file_config(5); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The receiver grew by exactly one virtual slot, not a fifth physical one. + REQUIRE(bundle.filament_presets.size() == 5); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 5); + for (size_t i = 0; i < 4; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(is_mixed[4]); + // The placeholder carries no definition: the GUI's integrity check flags it and + // blocks slicing until the user assigns components. + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 5); + CHECK(components[4].empty()); + // The four physical slots kept their meaning and colours. + CHECK(std::equal(receiver_colours.begin(), receiver_colours.end(), + bundle.project_config.opt("filament_colour")->values.begin())); + CHECK(bundle.filament_presets[0] == "My PLA"); + CHECK(bundle.filament_presets[3] == "My PLA"); + // The published colour seeds the placeholder's swatch. + CHECK(bundle.project_config.opt("filament_colour")->values[4] == "#ABCDEF"); + // The conversion is surfaced through the post-import notice. + bool placeholder_reported = false; + for (const std::string &message : pub.material_replacements) + if (message.find("unassigned mixed filament") != std::string::npos) + placeholder_reported = true; + CHECK(placeholder_reported); + CHECK(pub.skipped_keys.empty()); + CHECK(pub.mixed_slot_relocations.empty()); + } + + // Two surplus slots (5 and 6) become two consecutive empty placeholders. + { + PresetBundle bundle; + make_receiver(bundle, 4, 4); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_physical_entry(4, "#ABCDEF"), make_physical_entry(5, "#F0F0F0") }; + DynamicPrintConfig config = make_file_config(6); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 6); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 6); + for (size_t i = 0; i < 4; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(is_mixed[4]); + CHECK(is_mixed[5]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 6); + CHECK(components[4].empty()); + CHECK(components[5].empty()); + const auto &colour = bundle.project_config.opt("filament_colour")->values; + REQUIRE(colour.size() == 6); + CHECK(colour[4] == "#ABCDEF"); + CHECK(colour[5] == "#F0F0F0"); + CHECK(pub.skipped_keys.empty()); + CHECK(pub.mixed_slot_relocations.empty()); + } + + // A surplus slot past both the receiver's list and the capacity packs onto the next free + // tail slot (never max(authored, next_free), which would grow filler physical slots past + // the capacity), and the relocation is recorded for the model-reference remapping. + { + PresetBundle bundle; + make_receiver(bundle, 2, 2); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_physical_entry(3, "#ABCDEF") }; + DynamicPrintConfig config = make_file_config(4); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 3); + CHECK_FALSE(is_mixed[0]); + CHECK_FALSE(is_mixed[1]); + CHECK(is_mixed[2]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 3); + CHECK(components[2].empty()); + REQUIRE(pub.mixed_slot_relocations.size() == 1); + CHECK(pub.mixed_slot_relocations.at(3) == 2); + bool relocation_reported = false; + for (const std::string &message : pub.material_replacements) + if (message.find("slot 3 -> slot 2") != std::string::npos && + message.find("unassigned mixed filament") != std::string::npos) + relocation_reported = true; + CHECK(relocation_reported); + CHECK(pub.skipped_keys.empty()); + } + + // A Full Publish entry past the capacity becomes a placeholder too: no standalone + // detached copy is created for a material that got no physical slot. + { + PresetBundle bundle; + make_receiver(bundle, 4, 4); + + PublishedMaterialEntry entry = make_physical_entry(4, "#ABCDEF"); + entry.full = true; + entry.preset_name = "Generic PLA @System"; + entry.filament_id = "GFL99"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(5); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 5); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 5); + CHECK(is_mixed[4]); + // No detached copy under the stripped name or its uniquified forms. + CHECK(bundle.filaments.find_preset("Generic PLA", false, true) == nullptr); + CHECK(bundle.filaments.find_preset("Generic PLA (Published)", false, true) == nullptr); + CHECK(pub.skipped_keys.empty()); + } + + // A SEMM receiver (the default printer preset) sizes its slot list by hand: the published + // slot past the nozzle count still grows physically, as before the capacity gate. + { + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA", "My PLA", "My PLA", "My PLA" }; + bundle.set_num_filaments(4, "#123456"); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_physical_entry(4, "#ABCDEF") }; + DynamicPrintConfig config = make_file_config(5); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 5); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 5); + for (size_t i = 0; i < 5; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(bundle.project_config.opt("filament_colour")->values[4] == "#ABCDEF"); + CHECK(pub.skipped_keys.empty()); + } + + // A pre-existing oversized slot list is never shrunk: a published entry pointing at one + // of its slots is applied positionally even though the list exceeds the nozzle count. + { + PresetBundle bundle; + make_receiver(bundle, 4, 5); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_physical_entry(4, "#ABCDEF") }; + DynamicPrintConfig config = make_file_config(5); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 5); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 5); + for (size_t i = 0; i < 5; ++i) + CHECK_FALSE(is_mixed[i]); + // The published colour reached the addressed slot's (shared) preset in place. + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == + std::vector{ "#ABCDEF" }); + CHECK(bundle.project_config.opt("filament_colour")->values[4] == "#ABCDEF"); + CHECK(pub.skipped_keys.empty()); + } + + // On a single-physical-slot receiver an empty mix could never be edited (the sidebar's + // mixed section needs two physical filaments), so the surplus entry is dropped and + // reported instead of becoming an unfixable placeholder. + { + PresetBundle bundle; + make_receiver(bundle, 1, 1); + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_physical_entry(1, "#ABCDEF") }; + DynamicPrintConfig config = make_file_config(2); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + CHECK(bundle.filament_presets.size() == 1); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 1); + CHECK_FALSE(is_mixed[0]); + REQUIRE(pub.skipped_keys.size() == 1); + CHECK(pub.skipped_keys.front().find("printer supports only 1") != std::string::npos); + } + + // A payload mixed definition is exempt from the capacity gate: mixes are virtual slots + // that consume no nozzle, so a published mix past the nozzle count still lands. + { + PresetBundle bundle; + make_receiver(bundle, 4, 4); + + PublishedMaterialEntry mix; + mix.slot = 4; + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + DynamicPrintConfig config = make_file_config(5); + config.opt("filament_is_mixed")->values.assign(5, 0); + config.opt("filament_mixed_components")->values.assign(5, ""); + config.opt("filament_mixed_sublayer_ratios")->values.assign(5, ""); + config.opt("filament_is_mixed")->values[4] = 1; + config.opt("filament_mixed_components")->values[4] = "1,2"; + config.opt("filament_mixed_sublayer_ratios")->values[4] = "0.6,0.4"; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 5); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 5); + CHECK(is_mixed[4]); + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 5); + CHECK(components[4] == "1,2"); + const auto &ratios = bundle.project_config.opt("filament_mixed_sublayer_ratios")->values; + REQUIRE(ratios.size() == 5); + CHECK(ratios[4] == "0.6,0.4"); + CHECK(pub.skipped_keys.empty()); + } +} + +// A single-extruder receiver collapses the author's per-extruder printer slots onto its single +// slot: the first serialized variant of a base key is applied, the remaining variants of that +// base key are reported as skipped. +TEST_CASE("Published 3MF collapses a multi-extruder publish onto a single-extruder receiver", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + // Author has two extruders. + config.opt("retraction_length")->values = { 0.6, 0.9 }; + config.opt("retraction_speed")->values = { 30.0, 40.0 }; + Preset::normalize(config); + return config; + }; + + // Both extruders published: the first serialized variant (#0, left) lands on the receiver's + // single slot; the second variant (#1) is reported as skipped. + { + PresetBundle bundle; + bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8 }; + bundle.printers.get_edited_preset().config.opt("retraction_speed")->values = { 25.0 }; + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "retraction_length#0", "retraction_length#1", "retraction_speed#0", "retraction_speed#1" }; + DynamicPrintConfig config = make_file_config(); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.6 }); + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_speed")->values, { 30.0 }); + CHECK(contains_key(pub.skipped_keys, "retraction_length#1")); + CHECK(contains_key(pub.skipped_keys, "retraction_speed#1")); + CHECK_FALSE(contains_key(pub.skipped_keys, "retraction_length#0")); + CHECK_FALSE(contains_key(pub.skipped_keys, "retraction_speed#0")); + } + + // Only the second extruder published: the single-extruder receiver still applies it (the + // author's "right" is the only serialized slot) and reports nothing skipped. + { + PresetBundle bundle; + bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8 }; + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "retraction_length#1" }; + DynamicPrintConfig config = make_file_config(); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.9 }); + CHECK(pub.skipped_keys.empty()); + } +} + +// A multi-extruder "similar setup" receiver overrides each published extruder slot element-wise +// (no collapsing): each '#N' variant applies to the matching receiver slot, out-of-range ones are +// reported as skipped. +TEST_CASE("Published 3MF overrides each extruder slot on a similar multi-extruder receiver", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000", "#00FF00" }; + // Author has two extruders. + config.opt("retraction_length")->values = { 0.6, 0.9 }; + Preset::normalize(config); + return config; + }; + + // Receiver with two extruders: both published slots override element-wise. + { + PresetBundle bundle; + bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8, 0.8 }; + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "retraction_length#0", "retraction_length#1" }; + DynamicPrintConfig config = make_file_config(); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.6, 0.9 }); + CHECK(pub.skipped_keys.empty()); + } + + // Receiver with three extruders: slots 0 and 1 override, slot 2 keeps its own value. + { + PresetBundle bundle; + bundle.printers.get_edited_preset().config.opt("retraction_length")->values = { 0.8, 0.8, 0.7 }; + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "retraction_length#0", "retraction_length#1" }; + DynamicPrintConfig config = make_file_config(); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + check_double_vector(bundle.printers.get_edited_preset().config.opt("retraction_length")->values, { 0.6, 0.9, 0.7 }); + CHECK(pub.skipped_keys.empty()); + } +} + // The nozzle-count top-up in update_multi_material_filament_presets() grows filament_presets on // its own, so a physical count derived from that list reports a slot no per-filament array has // yet. That is what made the extruder-count handler conclude there was nothing to add and leave @@ -1011,3 +4244,953 @@ TEST_CASE("Sizing down to the nozzle count plus mixes is what eats the mixed tai CHECK(bundle.project_config.option("filament_mixed_components")->values[5] == "1,2"); } } + +// After a published-3MF import relocated mixed-filament definitions, the freshly loaded +// model's slot references must follow: object/volume "extruder" configs and multi-material +// color-painting states (which store the one-based slot number) are re-pointed to where each +// definition landed; everything else keeps its state. +TEST_CASE("remap_model_filament_slots repoints extruder configs and color painting", "[Preset][Bundle][Published]") +{ + auto make_model = [] { + Model model; + ModelObject *object_a = model.add_object(); + object_a->name = "relocated mix"; + ModelVolume *vol_a = object_a->add_volume(make_cube(10., 10., 10.)); + vol_a->config.set_key_value("extruder", new ConfigOptionInt(5)); // author slot 5 (0-based 4) + // Author painted one facet with the mix (slot 5) and another with a physical (slot 2). + { + TriangleSelector selector(vol_a->mesh()); + selector.set_facet(0, EnforcerBlockerType(5)); + selector.set_facet(1, EnforcerBlockerType(2)); + vol_a->mmu_segmentation_facets.set_data(selector.serialize()); + } + // A second object that does not reference the relocated slot at all. Painted with a + // real, non-relocated state (NONE is never serialized: an unsplit triangle without a + // state is the unpainted default and is skipped by TriangleSelector::serialize()). + ModelObject *object_b = model.add_object(); + object_b->name = "untouched"; + object_b->config.set_key_value("extruder", new ConfigOptionInt(1)); + ModelVolume *vol_b = object_b->add_volume(make_cube(5., 5., 5.)); + vol_b->config.set_key_value("extruder", new ConfigOptionInt(2)); + { + TriangleSelector selector(vol_b->mesh()); + selector.set_facet(0, EnforcerBlockerType(2)); + vol_b->mmu_segmentation_facets.set_data(selector.serialize()); + } + return model; + }; + + const std::map relocations = {{4, 5}}; + + Model model = make_model(); + Slic3r::remap_model_filament_slots(model, relocations); + + const ModelVolume *vol_a = model.objects[0]->volumes.front(); + CHECK(vol_a->config.extruder() == 6); // author slot 5 -> final slot 6 + // Painted states follow: the mix facet moved 5 -> 6, the physical one is untouched. + REQUIRE(TriangleSelector::has_facets(vol_a->mmu_segmentation_facets.get_data(), EnforcerBlockerType(6))); + REQUIRE_FALSE(TriangleSelector::has_facets(vol_a->mmu_segmentation_facets.get_data(), EnforcerBlockerType(5))); + CHECK(TriangleSelector::has_facets(vol_a->mmu_segmentation_facets.get_data(), EnforcerBlockerType(2))); + + const ModelVolume *vol_b = model.objects[1]->volumes.front(); + CHECK(vol_b->config.extruder() == 2); + // The untouched volume's paint (a non-relocated state) survives as-is. + CHECK(TriangleSelector::has_facets(vol_b->mmu_segmentation_facets.get_data(), EnforcerBlockerType(2))); + + // The mapping is applied simultaneously: each entry reads the original slot number, so + // relocating onto another relocated-from slot number must not chase chains. With the + // 0-based relocations {3->4, 4->6} the 1-based config map is {4->5, 5->7}: a volume on + // 1-based slot 4 lands on 5 and does NOT continue to 7. + Model chained = make_model(); + chained.objects[0]->volumes.front()->config.set_key_value("extruder", new ConfigOptionInt(4)); + Slic3r::remap_model_filament_slots(chained, std::map{{3, 4}, {4, 6}}); + CHECK(chained.objects[0]->volumes.front()->config.extruder() == 5); + // The chained model's paint follows its own single-step mapping: painted state 5 -> 7, + // and nothing lands back on 5. + CHECK(TriangleSelector::has_facets(chained.objects[0]->volumes.front()->mmu_segmentation_facets.get_data(), + EnforcerBlockerType(7))); + CHECK_FALSE(TriangleSelector::has_facets(chained.objects[0]->volumes.front()->mmu_segmentation_facets.get_data(), + EnforcerBlockerType(5))); +} + +// The slot ceiling (EnforcerBlockerType::ExtruderMax) is what the color-painting encoding can +// address, so a mixed filament that does not fit must be dropped and reported instead of being +// forced onto one of the receiver's physical filaments. +TEST_CASE("Published 3MF drops a mixed filament that does not fit the slot limit and reports it", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA" }; + // A receiver already at the format's slot ceiling. + bundle.set_num_filaments(unsigned(EnforcerBlockerType::ExtruderMax), "#123456"); + const std::vector receiver_colours = + bundle.project_config.opt("filament_colour")->values; + + PublishedMaterialEntry mix; + mix.filament_type = "PLA"; + mix.filament_vendor = "Generic"; + mix.slot = int(EnforcerBlockerType::ExtruderMax) + 6; // beyond the ceiling + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", + "filament_mixed_sublayer_ratios", "filament_mixed_gradient", + "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // Nothing grew and no slot became a virtual mix. + REQUIRE(bundle.filament_presets.size() == size_t(EnforcerBlockerType::ExtruderMax)); + for (size_t i = 0; i < bundle.filament_presets.size(); ++i) + CHECK_FALSE(bundle.is_mixed_filament(i)); + // The receiver's colours were not touched. + CHECK(bundle.project_config.opt("filament_colour")->values == receiver_colours); + // The mix was reported instead of being applied. + REQUIRE(contains_key(pub.skipped_keys, "material:PLA (mixed filament definition: filament slot limit reached)")); + CHECK(pub.material_replacements.empty()); +} + +// The publish dialog can only produce definitions whose components reference existing physical +// slots, so a payload whose components point at slots that do not exist (or at another mixed +// slot) or that carries fewer than two components is broken. The load reports it through the +// same channel as every other rejected input instead of shipping a mix the GUI integrity check +// would only flag later. +TEST_CASE("Published 3MF rejects a mixed filament definition with impossible components", "[Preset][Bundle][Published]") +{ + // A two-physical-plus-one-mix author file; the definition under test sits on slot 2. + auto make_file_config = [](const std::string &components) { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3 }; + config.opt("filament_extruder_variant")->values = { + "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" + }; + config.opt("filament_colour")->values = { "#FF0000", "#0000FF", "#800080" }; + config.opt("filament_type")->values = { "PLA", "PETG", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic" }; + config.opt("filament_is_mixed")->values = { 0, 0, 1 }; + config.opt("filament_mixed_components")->values = { "", "", components }; + config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" }; + return config; + }; + + PublishedMaterialEntry mix; + mix.filament_type = "PLA"; + mix.filament_vendor = "Generic"; + mix.filament_id = "GFL99"; + mix.slot = 2; + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" }; + + for (const char *components : { "1,4", "1,3", "1" }) { + // The claimed components: "1,4" names a slot past the final count, "1,3" names the mix + // slot itself (1-based), "1" is not enough components to blend. + INFO("components = " << components); + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + bundle.filament_presets = { "My PLA" }; + + mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" }; + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + DynamicPrintConfig config = make_file_config(components); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The definition was rejected, not applied: the grown slot is finalized as an empty + // mixed placeholder instead of keeping the seeded preset and masquerading as a real + // filament. + REQUIRE(bundle.filament_presets.size() == 3); + CHECK(bundle.is_mixed_filament(2)); + CHECK(bundle.project_config.opt("filament_mixed_components")->values[2].empty()); + CHECK(bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[2].empty()); + // Reported through the shared rejection channel. + if (std::string(components) == "1") + CHECK(contains_key(pub.skipped_keys, "material:GFL99 (mixed filament definition: needs at least two components)")); + else + CHECK(contains_key(pub.skipped_keys, "material:GFL99 (mixed filament definition: components reference missing slots)")); + // The slot change was surfaced like the other slot adaptations. + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0].find("slot 2: mixed filament definition could not be imported") != std::string::npos); + // The blended colour was not written into the (shared) slot preset either. + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == + std::vector{ "#123456" }); + } +} + +// The reported scenario: an author publishes two mixed filaments whose components are +// full-published physical slots; the receiver is a smaller tool-changer, so some of those +// component slots become empty mixed placeholders. A mix whose component turned into a +// placeholder can never be valid (mixes cannot reference mixes): it is rejected, and its +// grown slot must be finalized as an empty mixed placeholder too - not keep the seeded +// preset and masquerade as a real filament carrying the mix identity. +TEST_CASE("Published 3MF finalizes a mixed filament rejected over a placeholder component as an empty placeholder", "[Preset][Bundle][Published]") +{ + // An author project: six physical slots plus two tail mixes, the second referencing the + // sixth physical slot (H2C-style: slot 7 = 1+2, slot 8 = 2+6). + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = std::vector(8, 1.75); + config.opt("filament_self_index")->values = { 1, 2, 3, 4, 5, 6, 7, 8 }; + config.opt("filament_extruder_variant")->values = std::vector(8, "Direct Drive Standard"); + config.opt("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00", + "#FF00FF", "#00FFFF", "#800080", "#804000" }; + config.opt("filament_type")->values.assign(8, "PLA"); + config.opt("filament_vendor")->values.assign(8, "Generic"); + config.opt("filament_is_mixed")->values = { 0, 0, 0, 0, 0, 0, 1, 1 }; + config.opt("filament_mixed_components")->values = { "", "", "", "", "", "", "1,2", "2,6" }; + config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "", "", "", "", "0.6,0.4", "0.5,0.5" }; + // The export always serializes all seven masked mixed arrays, not just the ones in + // use; the unused gradient arrays ride along as defaults. + config.opt("filament_mixed_gradient")->values = { 0, 0, 0, 0, 0, 0, 0, 0 }; + config.opt("filament_mixed_gradient_range")->values.assign(8, ""); + config.opt("filament_mixed_gradient_curve")->values.assign(8, ""); + config.opt("filament_mixed_gradient_per_part")->values = { 0, 0, 0, 0, 0, 0, 0, 0 }; + return config; + }; + // A non-SEMM receiver with four nozzles and four slots (tool-changer style). + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets.assign(4, "My PLA"); + bundle.set_num_filaments(4, "#123456"); + auto &printer_config = bundle.printers.get_edited_preset().config; + printer_config.opt("single_extruder_multi_material", true)->value = false; + printer_config.opt("nozzle_diameter", true)->values.assign(4, 0.4); + + auto make_full_entry = [](int slot) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.full = true; + entry.full_keys = { "filament_retraction_length" }; + return entry; + }; + auto make_mix_entry = [](int slot, const char *color) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.filament_type = "PLA"; + entry.filament_vendor = "Generic"; + entry.publish_color = true; + entry.color = color; + entry.keys = { "filament_is_mixed", "filament_mixed_components", + "filament_mixed_sublayer_ratios", "filament_mixed_gradient", + "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }; + return entry; + }; + + // The dialog's emit order: the full-published physical slots (1, 2, 5, 6) and both mixes. + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_full_entry(0), make_full_entry(1), make_full_entry(4), make_full_entry(5), + make_mix_entry(6, "#800080"), make_mix_entry(7, "#804000") }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The receiver grew to the author's slot count, all virtual territory at the tail. + REQUIRE(bundle.filament_presets.size() == 8); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 8); + // Slots 0-3 stayed physical; authored slots 5 and 6 (0-based 4 and 5) became capacity + // placeholders. + for (size_t i = 0; i < 4; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(is_mixed[4]); + CHECK(is_mixed[5]); + CHECK(bundle.project_config.opt("filament_mixed_components")->values[4].empty()); + CHECK(bundle.project_config.opt("filament_mixed_components")->values[5].empty()); + // The first mix applied onto its uncontended tail slot. + CHECK(is_mixed[6]); + CHECK(bundle.project_config.opt("filament_mixed_components")->values[6] == "1,2"); + // The second mix was rejected - its second component (authored slot 6) turned into a + // placeholder - and its slot was finalized as an empty mixed placeholder instead of + // keeping the seeded preset as a phantom real filament. + CHECK(is_mixed[7]); + CHECK(bundle.project_config.opt("filament_mixed_components")->values[7].empty()); + CHECK(bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[7].empty()); + REQUIRE(pub.skipped_keys.size() == 1); + CHECK(pub.skipped_keys[0] == "material:PLA (mixed filament definition: components reference missing slots)"); + // Two Full Publish detach lines (slots 0-1), two capacity placeholder lines (slots 4-5), + // and the rejected mix's finalization line (slot 7). + REQUIRE(pub.material_replacements.size() == 5); + CHECK(std::any_of(pub.material_replacements.begin(), pub.material_replacements.end(), + [](const std::string &line) { + return line.find("slot 7: mixed filament definition could not be imported") != std::string::npos; + })); +} + +// The receiver must not overflow its physical capacity when it grows slots to reach a published +// mixed definition: an unpublished mixed slot that lands as a gap past the nozzle count becomes +// an empty mixed placeholder, not a physical filament. An author with six physical slots (0-5) +// and two tail mixes (slots 6 and 7) publishes only 0-5 and 7; the receiver has four nozzles. +// Slots 4 and 5 become surplus placeholders, slot 7 keeps its authored mix position, and the +// unpublished gap slot 6 is finalized as a virtual placeholder - never a fifth physical slot. +TEST_CASE("Published 3MF turns an unpublished gap slot past the printer's capacity into an empty mixed placeholder", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets.assign(4, "My PLA"); + bundle.set_num_filaments(4, "#123456"); + auto &printer_config = bundle.printers.get_edited_preset().config; + printer_config.opt("single_extruder_multi_material", true)->value = false; + printer_config.opt("nozzle_diameter", true)->values.assign(4, 0.4); + + // 8 authored slots: 0-5 physical, 6 unpublished mixed, 7 published mixed. The payload masks + // the unpublished slot's mixed flag (filter_published_config), so slot 6 reads as physical. + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = std::vector(8, 1.75); + config.opt("filament_self_index")->values = { 1, 2, 3, 4, 5, 6, 7, 8 }; + config.opt("filament_extruder_variant")->values = std::vector(8, "Direct Drive Standard"); + config.opt("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00", + "#FF00FF", "#00FFFF", "#800080", "#804000" }; + config.opt("filament_type")->values.assign(8, "PLA"); + config.opt("filament_vendor")->values.assign(8, "Generic"); + config.opt("filament_is_mixed")->values = { 0, 0, 0, 0, 0, 0, 0, 1 }; + config.opt("filament_mixed_components")->values = { "", "", "", "", "", "", "", "1,2" }; + config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "", "", "", "", "", "0.5,0.5" }; + config.opt("filament_mixed_gradient")->values = std::vector(8, 0); + config.opt("filament_mixed_gradient_range")->values.assign(8, ""); + config.opt("filament_mixed_gradient_curve")->values.assign(8, ""); + config.opt("filament_mixed_gradient_per_part")->values = std::vector(8, 0); + + auto make_full_entry = [](int slot) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.filament_type = "PLA"; + entry.full = true; + entry.full_keys = { "filament_retraction_length" }; + return entry; + }; + auto make_mix_entry = [](int slot, const char *color) { + PublishedMaterialEntry entry; + entry.slot = slot; + entry.filament_type = "PLA"; + entry.publish_color = true; + entry.color = color; + entry.keys = { "filament_is_mixed", "filament_mixed_components", + "filament_mixed_sublayer_ratios", "filament_mixed_gradient", + "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }; + return entry; + }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_full_entry(0), make_full_entry(1), make_full_entry(2), make_full_entry(3), + make_full_entry(4), make_full_entry(5), make_mix_entry(7, "#804000") }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 8); + const auto &is_mixed = bundle.project_config.opt("filament_is_mixed")->values; + REQUIRE(is_mixed.size() == 8); + // Slots 0-3 stay physical; 4 and 5 are surplus placeholders; 6 is the unpublished gap; 7 is + // the published mix. All four tail slots are virtual. + for (size_t i = 0; i < 4; ++i) + CHECK_FALSE(is_mixed[i]); + CHECK(is_mixed[4]); + CHECK(is_mixed[5]); + CHECK(is_mixed[6]); + CHECK(is_mixed[7]); + // Surplus physical slots (4,5) and the unpublished gap (6) carry no definition; the + // published mix on slot 7 keeps its own. + const auto &components = bundle.project_config.opt("filament_mixed_components")->values; + REQUIRE(components.size() == 8); + CHECK(components[4].empty()); + CHECK(components[5].empty()); + CHECK(components[6].empty()); + CHECK(components[7] == "1,2"); + // Exactly four physical slots remain (never a fifth past the nozzle count). + size_t physical_count = 0; + for (bool mixed : is_mixed) + if (!mixed) + ++physical_count; + CHECK(physical_count == 4); + // The unpublished gap's conversion is surfaced through the post-import notice. + bool gap_reported = false; + for (const std::string &message : pub.material_replacements) + if (message.find("slot 6: unassigned mixed filament") != std::string::npos) + gap_reported = true; + CHECK(gap_reported); + CHECK(pub.skipped_keys.empty()); +} + +// The relocation shifts cells inside the file's per-slot mixed arrays; a payload too short to +// actually carry the definition degrades to empty cells, which the definition validation then +// reports - an empty mix must not ship as a virtual slot. +TEST_CASE("Published 3MF reports a relocated mixed filament whose payload cells are missing", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA", "My PLA", "My PLA", "My PLA", "My PLA" }; + bundle.set_num_filaments(5, "#123456"); + const std::vector receiver_colours = + bundle.project_config.opt("filament_colour")->values; + + PublishedMaterialEntry mix; + mix.filament_type = "PLA"; + mix.filament_vendor = "Generic"; + mix.filament_id = "GFL99"; + mix.slot = 3; // authored slot 3; the receiver's five real slots occupy 0-4 + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", + "filament_mixed_sublayer_ratios", "filament_mixed_gradient", + "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + // The file's mixed arrays only cover its single physical slot: the definition data for + // slot 3 does not exist in the payload. + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75 }; + config.opt("filament_self_index")->values = { 1 }; + config.opt("filament_extruder_variant")->values = { "Direct Drive Standard" }; + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt("filament_type")->values = { "PLA" }; + config.opt("filament_vendor")->values = { "Generic" }; + config.opt("filament_is_mixed")->values = { 0 }; + config.opt("filament_mixed_components")->values = { "" }; + config.opt("filament_mixed_sublayer_ratios")->values = { "" }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The mix was relocated past the physical territory... + REQUIRE(pub.mixed_slot_relocations.size() == 1); + CHECK(pub.mixed_slot_relocations.at(3) == 5); + REQUIRE(pub.material_replacements.size() == 2); + CHECK(pub.material_replacements[0].find("slot 3 -> slot 5") != std::string::npos); + // ...and the receiver grew to hold the destination slot, but the definition itself was + // rejected: the relocated cells degraded to empty defaults, the slot was finalized as an + // empty mixed placeholder (not a real filament), and both facts were reported. + REQUIRE(bundle.filament_presets.size() == 6); + CHECK(bundle.is_mixed_filament(5)); + CHECK(bundle.project_config.opt("filament_mixed_components")->values[5].empty()); + CHECK(bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[5].empty()); + CHECK(contains_key(pub.skipped_keys, "material:GFL99 (mixed filament definition: needs at least two components)")); + CHECK(pub.material_replacements[1].find("slot 5: mixed filament definition could not be imported") != std::string::npos); + // The five real slots kept their colours. + CHECK(std::equal(receiver_colours.begin(), receiver_colours.end(), + bundle.project_config.opt("filament_colour")->values.begin())); +} + +// A grown published slot always repeats the receiver's last preset; it can only move to the +// published material's identity when a replacement is warranted (an aliased slot that would +// otherwise leak keys, or a type mismatch). The tier priority candidate_score uses is locked +// here: exact preset name > bare name > exact setting_id > exact filament_id > vendor+type, +// with the lower tiers reported as a substitute. +TEST_CASE("Published 3MF re-points an aliased grown slot's material by identity tiers", "[Preset][Bundle][Published]") +{ + auto make_file_config = [] { + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3, 4 }; + config.opt("filament_extruder_variant")->values = { + "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" + }; + config.opt("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" }; + config.opt("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" }; + config.opt("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" }; + return config; + }; + + PublishedMaterialEntry entry; + entry.slot = 2; + entry.filament_type = "PLA"; + + SECTION("an exact preset name outranks the bare-name form") + { + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + Preset &bare = add_inmemory_preset(bundle.filaments, "Authored PLA"); + bare.config.opt_string("filament_type", 0u) = "PLA"; + Preset &exact = add_inmemory_preset(bundle.filaments, "Authored PLA @Vendor"); + exact.config.opt_string("filament_type", 0u) = "PLA"; + bundle.filament_presets = { "My PLA" }; + + entry.preset_name = "Authored PLA @Vendor"; + entry.keys = { "filament_retraction_length" }; + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + CHECK(bundle.filament_presets[1] == "My PLA"); + // The aliased grown slot is re-pointed at the exact-name preset; the bare-name and the + // receiver's own preset lose. + CHECK(bundle.filament_presets[2] == "Authored PLA @Vendor"); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 2: My PLA -> Authored PLA @Vendor"); + } + + SECTION("a bare name outranks an exact setting_id") + { + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + Preset &bare = add_inmemory_preset(bundle.filaments, "Authored PLA"); + bare.config.opt_string("filament_type", 0u) = "PLA"; + Preset &sid = add_inmemory_preset(bundle.filaments, "Bbb PLA"); + sid.config.opt_string("filament_type", 0u) = "PLA"; + sid.setting_id = "SID123"; + bundle.filament_presets = { "My PLA" }; + + entry.preset_name = "Authored PLA @Vendor"; // no library preset carries this name + entry.setting_id = "SID123"; + entry.keys = { "filament_retraction_length" }; + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + CHECK(bundle.filament_presets[1] == "My PLA"); + CHECK(bundle.filament_presets[2] == "Authored PLA"); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 2: My PLA -> Authored PLA"); + } + + SECTION("an exact setting_id outranks an exact filament_id") + { + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA"); + mine.config.opt_string("filament_type", 0u) = "PLA"; + Preset &sid = add_inmemory_preset(bundle.filaments, "Bbb PLA"); + sid.config.opt_string("filament_type", 0u) = "PLA"; + sid.setting_id = "SID123"; + Preset &fid = add_inmemory_preset(bundle.filaments, "Ccc PLA"); + fid.config.opt_string("filament_type", 0u) = "PLA"; + fid.filament_id = "GFA00"; + bundle.filament_presets = { "My PLA" }; + + entry.preset_name = "Authored PLA @Vendor"; // no library preset carries this name + entry.setting_id = "SID123"; + entry.filament_id = "GFA00"; + entry.keys = { "filament_retraction_length" }; + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + CHECK(bundle.filament_presets[1] == "My PLA"); + CHECK(bundle.filament_presets[2] == "Bbb PLA"); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 2: My PLA -> Bbb PLA"); + } + + SECTION("a vendor+type match is reported as a substitute") + { + PresetBundle bundle; + Preset &mine = add_inmemory_preset(bundle.filaments, "My PETG"); + mine.config.opt_string("filament_type", 0u) = "PETG"; + Preset &exact_vendor = add_inmemory_preset(bundle.filaments, "Aaa PLA"); + exact_vendor.config.opt_string("filament_type", 0u) = "PLA"; + exact_vendor.config.opt_string("filament_vendor", 0u) = "Generic"; + Preset &other_vendor = add_inmemory_preset(bundle.filaments, "Zzz PLA"); + other_vendor.config.opt_string("filament_type", 0u) = "PLA"; + other_vendor.config.opt_string("filament_vendor", 0u) = "Other"; + bundle.filament_presets = { "My PETG" }; + + entry.filament_vendor = "Generic"; // no name or id identity: the family tiers decide + entry.publish_type = true; + entry.publish_type_value = "PLA"; // the grown slot seeds "My PETG" -> the gate reads a mismatch + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = make_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + REQUIRE(bundle.filament_presets.size() == 3); + CHECK(bundle.filament_presets[1] == "My PETG"); + // The same-vendor PLA outranks the type-only candidate... + CHECK(bundle.filament_presets[2] == "Aaa PLA"); + // ...and since it is not an exact material match, the load says so. + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 2: My PETG -> Aaa PLA (substitute)"); + } +} + +// Structural keys (identity links like filament_ids / inherits / printer_settings_id) are +// never applied onto the receiver and never reported: a hand-crafted file listing them must +// not trigger the "could not be applied" warning, while unknown keys still do. +TEST_CASE("Published 3MF silently ignores structural keys in published_keys", "[Preset][Bundle][Published]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt_float("layer_height") = 0.28; + Preset::normalize(config); + + PresetBundle bundle; + bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1; + const std::vector ids_before = + bundle.filaments.get_edited_preset().config.opt("filament_settings_id")->values; + + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "filament_ids", "inherits", "printer_settings_id", "layer_height", "not_a_setting" }; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The real setting applied... + CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 1e-6)); + CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height")); + // ...the structural keys were neither applied nor reported... + CHECK_FALSE(contains_key(pub.skipped_keys, "filament_ids")); + CHECK_FALSE(contains_key(pub.skipped_keys, "inherits")); + CHECK_FALSE(contains_key(pub.skipped_keys, "printer_settings_id")); + CHECK(bundle.filaments.get_edited_preset().config.opt("filament_settings_id")->values == ids_before); + // ...while an unknown key still reports. + CHECK(contains_key(pub.skipped_keys, "not_a_setting")); +} + +// A whole-vector key requires the receiver's vector to have the same number of elements as the +// author's: pasting a 3-extruder list into a 2-extruder machine would overwrite the wrong +// elements, so the key is reported as skipped and the receiver keeps its own values. +TEST_CASE("Published 3MF skips a whole-vector key whose size does not match the receiver", "[Preset][Bundle][Published]") +{ + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_colour")->values = { "#FF0000" }; + config.opt_float("layer_height") = 0.28; + // Author's wiping matrix sized for three extruders. + config.set_key_value("wiping_volumes_extruders", new ConfigOptionFloats({ 10., 20., 30. })); + Preset::normalize(config); + + PresetBundle bundle; + // Receiver sized for two extruders. + bundle.prints.get_edited_preset().config.set_key_value("wiping_volumes_extruders", new ConfigOptionFloats({ 40., 50. })); + bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1; + + PublishedConfig pub; + pub.published = true; + pub.published_keys = { "wiping_volumes_extruders", "layer_height" }; + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + check_double_vector(bundle.prints.get_edited_preset().config.opt("wiping_volumes_extruders")->values, + { 40., 50. }); + CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders")); + // The matching scalar key still applied. + CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height")); + CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 1e-6)); +} + +// The uniquify chain continues past the first suffix: with both "X" and "X (Published)" already +// present, the next imported copy of "X" lands as "X (Published 2)" and leaves the others alone. +TEST_CASE("Published 3MF uniquifies a second imported full material as (Published 2)", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + petg.config.opt("filament_retraction_length", true)->values = { 0.6 }; + Preset &bare = add_inmemory_preset(bundle.filaments, "Generic PLA"); + bare.config.opt_string("filament_type", 0u) = "PLA"; + bare.config.opt("filament_retraction_length", true)->values = { 0.5 }; + Preset &pub1 = add_inmemory_preset(bundle.filaments, "Generic PLA (Published)"); + pub1.config.opt_string("filament_type", 0u) = "PLA"; + pub1.config.opt("filament_retraction_length", true)->values = { 0.5 }; + bundle.filament_presets = { "My PETG" }; + + PublishedMaterialEntry entry; + entry.slot = 0; + entry.full = true; + entry.publish_type = true; + entry.publish_type_value = "PLA"; + entry.preset_name = "Generic PLA @Qidi Q2 0.4 nozzle"; + entry.full_keys = { "filament_retraction_length" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { entry }; + DynamicPrintConfig config = published_pla_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + CHECK(bundle.filament_presets[0] == "Generic PLA (Published 2)"); + check_double_vector(bundle.filaments.find_preset("Generic PLA (Published 2)", false, true) + ->config.opt("filament_retraction_length")->values, + { 0.9 }); + check_double_vector(bundle.filaments.find_preset("Generic PLA", false, true) + ->config.opt("filament_retraction_length")->values, + { 0.5 }); + check_double_vector(bundle.filaments.find_preset("Generic PLA (Published)", false, true) + ->config.opt("filament_retraction_length")->values, + { 0.5 }); + REQUIRE(pub.material_replacements.size() == 1); + CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Generic PLA (Published 2)"); + CHECK(pub.skipped_keys.empty()); +} + +// A mixed filament's blended colour is a swatch for the project's colour strip only: it must +// never be written into the slot's (possibly shared) preset config, or every slot referencing +// that preset would turn into the blend colour. +TEST_CASE("Published 3MF never writes a mixed filament's blended colour into the slot's preset", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_colour", true)->values = { "#123456" }; + Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG"); + petg.config.opt_string("filament_type", 0u) = "PETG"; + bundle.filament_presets = { "My PLA", "My PLA", "My PLA" }; + bundle.set_num_filaments(3); + bundle.project_config.opt("filament_is_mixed")->values[2] = 1; + bundle.project_config.opt("filament_mixed_components")->values[2] = "1,1"; + bundle.project_config.opt("filament_mixed_sublayer_ratios")->values[2] = "0.5,0.5"; + + PublishedMaterialEntry mix; + mix.filament_type = "PLA"; + mix.filament_vendor = "Generic"; + mix.filament_id = "GFL99"; + mix.slot = 2; + mix.publish_color = true; + mix.color = "#800080"; + mix.keys = { "filament_is_mixed", "filament_mixed_components", + "filament_mixed_sublayer_ratios", "filament_mixed_gradient", + "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { mix }; + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.opt("filament_diameter")->values = { 1.75, 1.75, 1.75 }; + config.opt("filament_self_index")->values = { 1, 2, 3 }; + config.opt("filament_extruder_variant")->values = { + "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" + }; + config.opt("filament_colour")->values = { "#FF0000", "#0000FF", "#800080" }; + config.opt("filament_type")->values = { "PLA", "PETG", "PLA" }; + config.opt("filament_vendor")->values = { "Generic", "Generic", "Generic" }; + config.opt("filament_is_mixed")->values = { 0, 0, 1 }; + config.opt("filament_mixed_components")->values = { "", "", "1,2" }; + config.opt("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" }; + config.opt("filament_mixed_gradient")->values = { 0, 0, 1 }; + config.opt("filament_mixed_gradient_range")->values = { "", "", "0.9,0.1" }; + config.opt("filament_mixed_gradient_curve")->values = { "", "", "0,0.1|1,0.9" }; + config.opt("filament_mixed_gradient_per_part")->values = { 0, 0, 1 }; + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The definition applied like-for-like onto the virtual slot... + CHECK(bundle.filament_presets.size() == 3); + CHECK(bundle.is_mixed_filament(2)); + CHECK(bundle.project_config.opt("filament_mixed_components")->values[2] == "1,2"); + // ...the blend colour landed in the project strip only... + CHECK(bundle.project_config.opt("filament_colour")->values[2] == "#800080"); + // ...and the shared preset kept its own colour: slots 0 and 1 render unchanged. + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == + std::vector{ "#123456" }); + CHECK(bundle.filament_presets[0] == "My PLA"); + CHECK(bundle.filament_presets[1] == "My PLA"); + CHECK(pub.skipped_keys.empty()); + CHECK(pub.material_replacements.empty()); +} + +// Duplicate entries for the same authored slot only occur in hand-crafted files (the dialog +// emits one entry per slot); the load's contract under that input is deterministic last-wins, +// not corruption. +TEST_CASE("Published 3MF applies duplicate entries for one slot last-wins", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA"); + pla.config.opt_string("filament_type", 0u) = "PLA"; + pla.config.opt("filament_colour", true)->values = { "#000000" }; + bundle.filament_presets = { "My PLA" }; + + auto make_entry = [](const char *color) { + PublishedMaterialEntry entry; + entry.slot = 0; + entry.publish_color = true; + entry.color = color; + entry.keys = { "filament_retraction_length" }; + return entry; + }; + + PublishedConfig pub; + pub.published = true; + pub.material_keys = { make_entry("#AA0000"), make_entry("#BB0000") }; + DynamicPrintConfig config = published_pla_file_config(); + Preset::normalize(config); + bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub); + + // The second entry won both the project strip and the slot's preset. + CHECK(bundle.project_config.opt("filament_colour")->values[0] == "#BB0000"); + CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt("filament_colour")->values == + std::vector{ "#BB0000" }); + check_double_vector(bundle.filaments.find_preset("My PLA", false, true) + ->config.opt("filament_retraction_length")->values, + { 0.9 }); + CHECK(pub.skipped_keys.empty()); + CHECK(pub.material_replacements.empty()); +} + +// normalize_filament_type maps "PLA High Speed" onto the canonical family "PLA" (a space- +// separated modifier is dropped) but leaves dash-separated composite types like "PA-CF" intact, +// and passes through unknown types and the empty string unchanged. +TEST_CASE("normalize_filament_type strips a space modifier but keeps dash types", "[Preset][Bundle][Published]") +{ + CHECK(normalize_filament_type("PLA High Speed") == "PLA"); + CHECK(normalize_filament_type("PA-CF") == "PA-CF"); + CHECK(normalize_filament_type("PETG-CF") == "PETG-CF"); + CHECK(normalize_filament_type("PLA") == "PLA"); + CHECK(normalize_filament_type("ABC") == "ABC"); + CHECK(normalize_filament_type("") == ""); +} + +// collect_dirty_settings_keys feeds the Publish dialog's pre-check: it must be the set union of +// the dirty options across the edited print, printer and filament presets. +TEST_CASE("collect_dirty_settings_keys unions the dirty settings from all three presets", "[Preset][Bundle][Published]") +{ + PresetBundle bundle; + // The edited preset is initialised as a copy of the selected (default) preset, so a single + // edit makes exactly that option dirty. deep_diff reports scalar keys by name but per-element + // vector keys as "key#", so a vector edit surfaces as "key#0". + bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.28; + bundle.filaments.get_edited_preset().config.opt("filament_type", true)->values = { "ABS" }; + bundle.printers.get_edited_preset().config.opt("nozzle_diameter", true)->values = { 0.6 }; + + const std::vector dirty = collect_dirty_settings_keys(bundle); + for (const char *key : { "layer_height", "filament_type#0", "nozzle_diameter#0" }) + CHECK(contains_key(dirty, key)); +} + +// The publish denylist and the mixed-key list are single sources of truth for the import path: +// lock their members so a silent edit to either cannot drift away from the contract the import +// and export masks rely on. +TEST_CASE("Published 3MF denylist and mixed-key sets match the import/export contract", "[Preset][Bundle][Published]") +{ + const std::set& structural = publish_structural_keys(); + // Structural / inheritance keys must never be applied onto a receiver's presets. + for (const char *key : { "printer_settings_id", "filament_settings_id", "print_settings_id", + "compatible_printers", "compatible_prints", "compatible_printers_condition", + "compatible_prints_condition", "default_filament_profile", "default_print_profile", + "inherits", "extruder_count", "printer_model", "filament_ids" }) + CHECK(structural.count(key) == 1); + // ...but a per-slot publishable material key is not structural. + CHECK(structural.count("filament_retraction_length") == 0); + CHECK(structural.count("filament_colour") == 0); + + const std::set& mixed = publish_mixed_keys(); + CHECK(mixed == std::set{ + "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios", + "filament_mixed_gradient", "filament_mixed_gradient_range", "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }); + // Mixed keys are project-level arrays, not material-preset keys, so none is structural. + for (const std::string &key : mixed) + CHECK(structural.count(key) == 0); +} + +namespace { + +// data_dir() is a process-wide global that import_presets extracts into; scope it to the test. +struct ScopedDataDir +{ + std::string previous = data_dir(); + explicit ScopedDataDir(const fs::path &dir) { set_data_dir(dir.string()); } + ~ScopedDataDir() { set_data_dir(previous); } +}; + +std::string read_file(const fs::path &file) +{ + std::ifstream in(file.string(), std::ios::binary); + return std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()); +} + +void write_zip(const fs::path &zip_file, const std::vector> &entries) +{ + mz_zip_archive zip; + mz_zip_zero_struct(&zip); + REQUIRE(open_zip_writer(&zip, zip_file.string())); + for (const auto &[name, content] : entries) + REQUIRE(mz_zip_writer_add_mem(&zip, name.c_str(), content.data(), content.size(), MZ_DEFAULT_COMPRESSION)); + REQUIRE(mz_zip_writer_finalize_archive(&zip)); + REQUIRE(close_zip_writer(&zip)); +} + +bool any_filename_contains(const fs::path &root, const std::string &needle) +{ + for (fs::recursive_directory_iterator it(root), end; it != end; ++it) + if (it->path().filename().string().find(needle) != std::string::npos) + return true; + return false; +} + +} // namespace + +TEST_CASE("Config import confines zip entries, preset names and bundle ids to the preset directory", "[Preset][Bundle][Regression]") +{ + ScopedTemporaryDir temp_dir; + const fs::path data_root = temp_dir.path() / "datadir"; + const fs::path src_dir = temp_dir.path() / "src"; + fs::create_directories(src_dir); + ScopedDataDir scoped_data_dir(data_root); + + PresetBundle bundle; + AppConfig app_config; + const auto confirm = [](std::string const &) { return 1; }; + const auto import = [&](const fs::path &file) { + std::vector files{file.string()}; + bundle.import_presets(files, confirm, ForwardCompatibilitySubstitutionRule::Disable, app_config); + return files; + }; + + const fs::path good_file = src_dir / "Good.json"; + write_print_preset(bundle.prints.default_preset().config, good_file, "Good"); + const std::string good_json = read_file(good_file); + + // Four levels up from where import_presets writes (/user/default/temp) is temp_dir + // itself, so anything that escapes lands where the scan below can see it. + const std::string up = "../../../../"; + const std::string up_win = "..\\..\\..\\..\\"; + + SECTION("zip entry names with either separator are reduced to a basename") { + const fs::path zip = src_dir / "bundle.zip"; + write_zip(zip, {{up + "zip-escape.json", "{}"}, {up_win + "zip-escape.json", "{}"}, {"presets/Good.json", good_json}}); + import(zip); + CHECK(bundle.prints.find_preset("Good") != nullptr); + CHECK_FALSE(any_filename_contains(temp_dir.path(), "zip-escape")); + } + + SECTION("a preset name that walks out of the preset directory is rejected") { + for (const std::string &name : {up + "name-escape", up_win + "name-escape"}) { + const fs::path file = src_dir / "escape.json"; + write_print_preset(bundle.prints.default_preset().config, file, name); + CHECK(import(file).empty()); + CHECK_FALSE(any_filename_contains(temp_dir.path(), "name-escape")); + } + } + + SECTION("a bundle id that walks out of the bundle directory is rejected") { + const fs::path zip = src_dir / "bundle.zip"; + write_zip(zip, {{BUNDLE_STRUCTURE_JSON_NAME, "{\"id\": \"" + up + "bundle-escape\"}"}, {"Good.json", good_json}}); + CHECK(import(zip).empty()); + CHECK_FALSE(any_filename_contains(temp_dir.path(), "bundle-escape")); + } +} diff --git a/tests/libslic3r/test_utils.cpp b/tests/libslic3r/test_utils.cpp index c039069b2a..484438127c 100644 --- a/tests/libslic3r/test_utils.cpp +++ b/tests/libslic3r/test_utils.cpp @@ -2,6 +2,13 @@ #include "libslic3r/Utils.hpp" +#include "test_utils.hpp" + +#include +#include +#include +#include + #ifndef _WIN32 #include // getuid #endif @@ -52,3 +59,32 @@ TEST_CASE("per-user temp root is unchanged on Windows, isolated elsewhere", "[ut REQUIRE_THAT(root, Catch::Matchers::StartsWith(base + "/orcaslicer_")); #endif } + +TEST_CASE("copy_file reports the OS error when the destination cannot be written", "[utils]") { + ScopedTemporaryFile source(".txt"); + { + std::ofstream ofs(source.string(), std::ios::binary); + ofs << "orca"; + } + REQUIRE(boost::filesystem::exists(source.path())); + + // A directory that was never created, so the copy fails on every platform. + const boost::filesystem::path destination = source.path().parent_path() / "orca-missing-dir" / "copy.txt"; + REQUIRE_FALSE(boost::filesystem::exists(destination.parent_path())); + + std::string error_message; + REQUIRE(copy_file(source.string(), destination.string(), error_message) == FAIL_COPY_FILE); + REQUIRE_FALSE(error_message.empty()); + +#ifdef _WIN32 + // The Windows branch formats GetLastError() itself. Writing that as + // "Error: " + errCode adds an integer to a string literal, which indexes into the + // literal instead of appending and runs off its end for any code above 7. + const std::string prefix = "Error: "; + REQUIRE(error_message.rfind(prefix, 0) == 0); + + const std::string code = error_message.substr(prefix.size()); + REQUIRE_FALSE(code.empty()); + REQUIRE(std::all_of(code.begin(), code.end(), [](unsigned char c) { return std::isdigit(c) != 0; })); +#endif // _WIN32 +} diff --git a/tests/libslic3r/test_wipe_tower.cpp b/tests/libslic3r/test_wipe_tower.cpp new file mode 100644 index 0000000000..2987dce9da --- /dev/null +++ b/tests/libslic3r/test_wipe_tower.cpp @@ -0,0 +1,93 @@ +#include + +#include + +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/GCode/WipeTower.hpp" +#include "libslic3r/GCode/WipeTower2.hpp" + +using namespace Slic3r; +using Catch::Matchers::WithinAbs; + +// A Bambu P1S project that reproduced the off-plate brim: two PLAs priming 30 and 45 mm3 in +// separate adhesiveness categories on a 35 mm tower, 0.21 mm layers, 0.4 nozzle (0.5 mm lines), +// 150 % infill gap (0.75 mm line pitch), rib width 8, 16 mm tall. +static std::vector cube_purges(int first_category = 100) +{ + return {{30.f, first_category}, {45.f, 0}}; +} + +TEST_CASE("Cone base polygon bulges past the body box", "[WipeTower]") { + // Zero angle: plain body box. + const Polygon box = WipeTower2::cone_base_polygon(35., 20., 100., 0.); + CHECK(box.points.size() == 4); + CHECK(get_extents(box).size() == Point::new_scale(Vec2d(35., 20.))); + // A 25-degree cone on a 100 mm tower: base radius R = tan(12.5deg)*100 = 22.2 mm, + // which exceeds the body half-depth, so the footprint bulges to center +- R in y + // (support_scale keeps the x extent compressed near the body). + const Polygon base = WipeTower2::cone_base_polygon(35., 20., 100., 25.); + const BoundingBox bb = get_extents(base); + const double R = std::tan(25. / 2. * M_PI / 180.) * 100.; + CHECK_THAT(unscaled(bb.min.y()), WithinAbs(10. - R, 0.1)); + CHECK_THAT(unscaled(bb.max.y()), WithinAbs(10. + R, 0.1)); + // The footprint always contains the body box. + CHECK(diff(Polygons{box}, Polygons{base}).empty()); +} + +TEST_CASE("Type1 block-stack depth quantizes each purge to whole lines", "[WipeTower]") { + // A 0.5 mm line at 0.21 mm carries 0.0955 mm3 per mm, so across the 34 mm between the + // perimeters 30 mm3 is 10 lines and 45 mm3 is 14: 7.5 + 10.5 at the 0.75 mm pitch behind + // one perimeter width. The generated mesh of the project measured exactly this. + CHECK_THAT(WipeTower::estimate_tower_blocks_depth(cube_purges(), 35.f, 0.21f, 0.4f, 1.5f), WithinAbs(18.5f, 0.01f)); + // Sharing one category, a layer can never purge into every filament (one of them starts + // the layer), so the block is sized by its worst layer and the 10-line purge drops out. + CHECK_THAT(WipeTower::estimate_tower_blocks_depth(cube_purges(0), 35.f, 0.21f, 0.4f, 1.5f), WithinAbs(11.0f, 0.01f)); + CHECK_THAT(WipeTower::estimate_tower_blocks_depth({}, 35.f, 0.2f, 0.4f, 1.f), WithinAbs(0.f, 1e-6f)); + // A width narrower than two perimeter widths cannot hold purge lines. + CHECK_THAT(WipeTower::estimate_tower_blocks_depth({{45.f, 0}}, 0.9f, 0.2f, 0.4f, 1.f), WithinAbs(0.f, 1e-6f)); +} + +TEST_CASE("A nozzle change adds its ramming lines to the block", "[WipeTower]") { + // 10 mm of 1.75 mm filament (24.05 mm3) laid as 1.0 mm nozzle-change lines at 0.2 mm + // (0.1914 mm2 each) is 125.7 mm; across the 48.5 mm available that is 3 lines of 1.0 mm. + std::vector purges{{100.f, 0}, {100.f, 0}}; + const float without_change = WipeTower::estimate_tower_blocks_depth(purges, 50.f, 0.2f, 0.4f, 1.f); + purges.front().filament_change_length = 10.f; + CHECK_THAT(WipeTower::estimate_tower_blocks_depth(purges, 50.f, 0.2f, 0.4f, 1.f) - without_change, WithinAbs(3.f, 1e-4f)); +} + +TEST_CASE("Rib tower footprint estimate covers the generated footprint", "[WipeTower]") { + // The generated first-layer wall bbox of the project measured 29.56 mm from the sliced + // G-code; the volume-only estimate said 23.585 mm. + const float side = WipeTower::estimate_rib_tower_bbox_side(cube_purges(), 35.f, 0.21f, 0.4f, 1.5f, 8.f, 0.f, 16.f); + CHECK(side >= 29.56f); + CHECK(side <= 29.56f + 4.f); // without grossly over-reserving plate space + // Separate categories stack their blocks, so the footprint must not shrink when they differ. + CHECK(side >= WipeTower::estimate_rib_tower_bbox_side(cube_purges(0), 35.f, 0.21f, 0.4f, 1.5f, 8.f, 0.f, 16.f)); + CHECK_THAT(WipeTower::estimate_rib_tower_bbox_side({}, 35.f, 0.2f, 0.4f, 1.f, 8.f, 0.f, 16.f), WithinAbs(0.f, 1e-6f)); +} + +TEST_CASE("Rib footprint extends the ribs, not the body, below the stability minimum", "[WipeTower]") { + // A 10 mm body under a 90 mm print: the ribs stretch to the minimum depth's diagonal, and + // the rib width is capped at half the body, so the square grows to minimum + 5 / sqrt(2). + const float min_depth = WipeTower::get_limit_depth_by_height(90.f); + REQUIRE(min_depth > 10.f); + CHECK_THAT(WipeTower::rib_footprint_side(10.f, 10.f, 8.f, 0.f, 90.f), WithinAbs(min_depth + 5.f / std::sqrt(2.f), 1e-4f)); + // The extra rib length runs along the diagonal, so it shows as its projection on each axis. + const float plain = WipeTower::rib_footprint_side(30.f, 30.f, 8.f, 0.f, 5.f); + CHECK_THAT(plain, WithinAbs(30.f + 8.f / std::sqrt(2.f), 1e-4f)); + CHECK_THAT(WipeTower::rib_footprint_side(30.f, 30.f, 8.f, 4.f, 5.f) - plain, WithinAbs(4.f / std::sqrt(2.f), 1e-4f)); + // A negative extra length cannot pull the ribs inside the diagonal. + CHECK_THAT(WipeTower::rib_footprint_side(30.f, 30.f, 8.f, -4.f, 5.f), WithinAbs(plain, 1e-4f)); + CHECK_THAT(WipeTower::rib_footprint_side(0.f, 30.f, 8.f, 0.f, 5.f), WithinAbs(0.f, 1e-6f)); +} + +TEST_CASE("Brim width estimate matches each generator's loop quantization", "[WipeTower]") { + // 3 mm configured, 0.4 nozzle, 0.2 first layer: 0.4571 mm spacing, 7 loops. WipeTower2 + // prints and reports the 7 loops; WipeTower reports half a spacing of line width on top. + const float spacing = 0.5f - 0.2f * float(1. - M_PI_4); + CHECK_THAT(WipeTower::estimate_brim_real_width(3.f, 0.4f, 0.2f, true), WithinAbs(7.f * spacing, 1e-4f)); + CHECK_THAT(WipeTower::estimate_brim_real_width(3.f, 0.4f, 0.2f, false), WithinAbs(7.5f * spacing, 1e-4f)); + CHECK_THAT(WipeTower::estimate_brim_real_width(0.f, 0.4f, 0.2f, true), WithinAbs(0.f, 1e-6f)); +} diff --git a/tests/libslic3r/test_wipe_tower_estimate.cpp b/tests/libslic3r/test_wipe_tower_estimate.cpp new file mode 100644 index 0000000000..20644200f3 --- /dev/null +++ b/tests/libslic3r/test_wipe_tower_estimate.cpp @@ -0,0 +1,351 @@ +#include + +#include "libslic3r/BoundingBox.hpp" +#include "libslic3r/ClipperUtils.hpp" +#include "libslic3r/GCode/WipeTower.hpp" +#include "libslic3r/GCode/WipeTower2.hpp" +#include "libslic3r/GCode/WipeTowerEstimate.hpp" +#include "libslic3r/PrintConfig.hpp" + +#include +#include +#include + +using namespace Slic3r; +using Catch::Matchers::WithinAbs; + +// Rectangle wall, one nozzle, 100 mm3 prime volume on a 50 mm wide tower at 0.2 mm layers: one +// purge is 10 mm of depth. The flush matrix is off here; the shipped-default case covers it. +// Built as PresetBundle::full_config builds the GUI's: apply() creates each enum as a +// ConfigOptionEnumGeneric, where full_print_config() would clone the static defaults' +// ConfigOptionEnum. The estimate has to read either. +static DynamicPrintConfig preset_shaped_defaults() +{ + DynamicPrintConfig config; + config.apply(FullPrintConfig::defaults()); + return config; +} + +static DynamicPrintConfig make_config(const char *wall_type = "rectangle") +{ + DynamicPrintConfig config = preset_shaped_defaults(); + config.set_key_value("prime_tower_width", new ConfigOptionFloat(50.)); + config.set_key_value("prime_volume", new ConfigOptionFloat(100.)); + config.set_key_value("filament_prime_volume", new ConfigOptionFloats({100.})); + config.set_key_value("filament_adhesiveness_category", new ConfigOptionInts({0})); + config.set_key_value("prime_tower_infill_gap", new ConfigOptionPercent(100.)); + config.set_key_value("wipe_tower_extra_spacing", new ConfigOptionPercent(100.)); + config.set_key_value("prime_tower_brim_width", new ConfigOptionFloat(3.)); + config.set_deserialize_strict("wipe_tower_wall_type", wall_type); + config.set_key_value("wipe_tower_rib_width", new ConfigOptionFloat(8.)); + config.set_key_value("wipe_tower_extra_rib_length", new ConfigOptionFloat(0.)); + config.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4})); + config.set_key_value("initial_layer_print_height", new ConfigOptionFloat(0.2)); + config.set_deserialize_strict("timelapse_type", "0"); + config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(false)); + config.set_key_value("raft_layers", new ConfigOptionInt(0)); + config.set_key_value("purge_in_prime_tower", new ConfigOptionBool(false)); + config.set_key_value("single_extruder_multi_material", new ConfigOptionBool(false)); + return config; +} + +static std::vector filaments(size_t count) +{ + std::vector ids(count); + std::iota(ids.begin(), ids.end(), 0u); + return ids; +} + +// The first `count` filaments on the given planner; Type2 unless a case says otherwise. +static WipeTowerFootprint estimate(const ConfigBase &config, size_t count, double layer_height, double height, WipeTowerType type = WipeTowerType::Type2) +{ + return estimate_wipe_tower_footprint(config, type, filaments(count), layer_height, height); +} + +// What both planners print for a 3 mm brim at 0.4 nozzle and 0.2 first layer (0.4571 mm loops). +static double printed_brim(double configured, WipeTowerType type) +{ + return WipeTower::estimate_brim_real_width(float(configured), 0.4f, 0.2f, type == WipeTowerType::Type2); +} + +TEST_CASE("A rectangle wall tower is sized by the purge volume", "[WipeTowerEstimate]") { + const DynamicPrintConfig config = make_config(); + // Three filaments purge twice per layer; a 5 mm object keeps the stability floor at 5 mm. + const WipeTowerFootprint fp = estimate(config, 3, 0.2, 5.); + CHECK_THAT(fp.width, WithinAbs(50., 1e-9)); + CHECK_THAT(fp.depth, WithinAbs(20., 1e-9)); + CHECK_THAT(fp.height, WithinAbs(5., 1e-9)); + CHECK_THAT(fp.brim_width, WithinAbs(printed_brim(3., WipeTowerType::Type2), 1e-6)); + // Thinner layers need more depth for the same volume. + CHECK_THAT(estimate(config, 3, 0.1, 5.).depth, WithinAbs(40., 1e-9)); +} + +TEST_CASE("Each planner spaces its purge lines by its own option", "[WipeTowerEstimate]") { + // Type2 reads wipe_tower_extra_spacing and Type1 prime_tower_infill_gap; neither sees the + // other's key. Type2's extra flow cancels out of its depth. + DynamicPrintConfig config = make_config(); + config.set_key_value("wipe_tower_extra_flow", new ConfigOptionPercent(250.)); + CHECK_THAT(estimate(config, 3, 0.2, 5.).depth, WithinAbs(20., 1e-9)); + config.set_key_value("wipe_tower_extra_spacing", new ConfigOptionPercent(150.)); + CHECK_THAT(estimate(config, 3, 0.2, 5.).depth, WithinAbs(30., 1e-9)); + const double type1_spaced = estimate(config, 3, 0.2, 5., WipeTowerType::Type1).depth; + config.set_key_value("prime_tower_infill_gap", new ConfigOptionPercent(150.)); + CHECK_THAT(estimate(config, 3, 0.2, 5.).depth, WithinAbs(30., 1e-9)); + // Type1 stacks whole lines behind one 0.5 mm perimeter width, so only the stack scales. + CHECK_THAT(estimate(config, 3, 0.2, 5., WipeTowerType::Type1).depth - 0.5, WithinAbs(1.5 * (type1_spaced - 0.5), 1e-6)); +} + +TEST_CASE("Type1 sizes the tower from each filament's own prime volume", "[WipeTowerEstimate]") { + // The Bambu P1S project of the WipeTower cases: 30 and 45 mm3 in two categories on a 35 mm + // tower at 0.21 mm, 150 % gap, is 18.5 mm of stacked blocks (11 mm sharing one category). + DynamicPrintConfig config = make_config(); + config.set_key_value("prime_tower_width", new ConfigOptionFloat(35.)); + config.set_key_value("prime_tower_infill_gap", new ConfigOptionPercent(150.)); + config.set_key_value("initial_layer_print_height", new ConfigOptionFloat(0.21)); + config.set_key_value("filament_prime_volume", new ConfigOptionFloats({30., 45.})); + config.set_key_value("filament_adhesiveness_category", new ConfigOptionInts({100, 0})); + const std::vector purges{{30.f, 100}, {45.f, 0}}; + const double blocks = WipeTower::estimate_tower_blocks_depth(purges, 35.f, 0.21f, 0.4f, 1.5f); + REQUIRE_THAT(blocks, WithinAbs(18.5, 0.01)); + CHECK_THAT(estimate(config, 2, 0.21, 5., WipeTowerType::Type1).depth, WithinAbs(blocks, 1e-4)); + // The ids pick the volumes, so their order does not matter and a lone filament has no purge. + CHECK_THAT(estimate_wipe_tower_footprint(config, WipeTowerType::Type1, {1, 0}, 0.21, 5.).depth, WithinAbs(blocks, 1e-4)); + CHECK_THAT(estimate(config, 1, 0.21, 5., WipeTowerType::Type1).depth, WithinAbs(0., 1e-9)); + config.set_key_value("filament_adhesiveness_category", new ConfigOptionInts({0, 0})); + CHECK_THAT(estimate(config, 2, 0.21, 5., WipeTowerType::Type1).depth, WithinAbs(11., 0.01)); + // A rib wall squares the same stack. + config.set_deserialize_strict("wipe_tower_wall_type", "rib"); + const WipeTowerFootprint rib = estimate(config, 2, 0.21, 5., WipeTowerType::Type1); + CHECK_THAT(rib.width, WithinAbs(rib.depth, 1e-9)); + CHECK_THAT(rib.depth, WithinAbs(WipeTower::estimate_rib_tower_bbox_side({{30.f, 0}, {45.f, 0}}, 35.f, 0.21f, 0.4f, 1.5f, 8.f, 0.f, 5.f), 1e-4)); +} + +TEST_CASE("A second nozzle adds the ramming of one nozzle change per layer", "[WipeTowerEstimate]") { + // Two filaments on two nozzles: the tool order crosses once per layer, and Type1 rams 10 mm + // of filament as three 1.0 mm nozzle-change lines (see the WipeTower case). + DynamicPrintConfig config = make_config(); + config.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4, 0.4})); + config.set_key_value("filament_change_length", new ConfigOptionFloats({10., 10.})); + config.set_key_value("filament_diameter", new ConfigOptionFloats({1.75, 1.75})); + config.set_key_value("filament_map", new ConfigOptionInts({1, 1})); + const double same_nozzle = estimate(config, 2, 0.2, 5., WipeTowerType::Type1).depth; + config.set_key_value("filament_map", new ConfigOptionInts({1, 2})); + CHECK_THAT(estimate(config, 2, 0.2, 5., WipeTowerType::Type1).depth - same_nozzle, WithinAbs(3., 1e-4)); +} + +TEST_CASE("The tower is sized for the first layer when it is the thinnest", "[WipeTowerEstimate]") { + // Both planners reserve the worst layer: a 0.28 mm print with a 0.2 mm first layer needs + // the 0.2 mm depth, while a thicker first layer changes nothing. + DynamicPrintConfig config = make_config(); + const double at_thinnest = estimate(config, 3, 0.2, 5.).depth; + CHECK_THAT(estimate(config, 3, 0.28, 5.).depth, WithinAbs(at_thinnest, 1e-9)); + config.set_key_value("initial_layer_print_height", new ConfigOptionFloat(0.3)); + CHECK(estimate(config, 3, 0.28, 5.).depth < at_thinnest); +} + +TEST_CASE("Object height sets the stability floor and the auto brim", "[WipeTowerEstimate]") { + DynamicPrintConfig config = make_config(); + // Two filaments purge once: 10 mm, lifted to the 20 mm floor of a 100 mm tower. + CHECK_THAT(estimate(config, 2, 0.2, 100.).depth, WithinAbs(20., 1e-9)); + config.set_key_value("prime_tower_brim_width", new ConfigOptionFloat(-1.)); + const double auto_brim = WipeTower::get_auto_brim_by_height(50.f); + CHECK_THAT(estimate(config, 2, 0.2, 50.).brim_width, WithinAbs(printed_brim(auto_brim, WipeTowerType::Type2), 1e-6)); + CHECK_THAT(estimate(config, 2, 0.2, 50., WipeTowerType::Type1).brim_width, WithinAbs(printed_brim(auto_brim, WipeTowerType::Type1), 1e-6)); +} + +TEST_CASE("A single filament only gets a tower when one is printed anyway", "[WipeTowerEstimate]") { + DynamicPrintConfig config = make_config(); + CHECK_THAT(estimate(config, 1, 0.2, 100.).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate(config, 0, 0.2, 100.).width, WithinAbs(0., 1e-9)); + + // Wrapping detection prints a tower on the first layers whatever the filament count: the + // Type1 planner's fixed 10 mm, the stability floor otherwise. + config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(true)); + CHECK_THAT(estimate(config, 1, 0.2, 100.).depth, WithinAbs(20., 1e-9)); + CHECK_THAT(estimate(config, 1, 0.2, 100., WipeTowerType::Type1).depth, WithinAbs(WipeTower::get_wrapping_detection_depth(), 1e-9)); + config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(false)); + + // A raft is not one of them: normalize_fdm_2 clears enable_prime_tower for a plate that + // purges one filament unless smooth timelapse or wrapping detection is on, so a raft + // alone leaves no tower to reserve for. + config.set_key_value("raft_layers", new ConfigOptionInt(3)); + CHECK_THAT(estimate(config, 1, 0.2, 100.).depth, WithinAbs(0., 1e-9)); + config.set_key_value("raft_layers", new ConfigOptionInt(0)); + + config.set_deserialize_strict("timelapse_type", "1"); + // A tower printed with no tool change is exactly the planner's idle depth: there is + // nothing to purge, and WipeTower2 sizes it at the stability floor. + CHECK_THAT(estimate(config, 1, 0.2, 100.).depth, WithinAbs(20., 1e-9)); + CHECK_THAT(estimate(config, 1, 0.2, 5.).depth, WithinAbs(WipeTower::get_limit_depth_by_height(5.f), 1e-9)); +} + +TEST_CASE("A tool change reserves a tower even with nothing to purge", "[WipeTowerEstimate]") { + // The purge volumes are configurable down to zero, but the tool changes are still printed + // on the tower and both planners still floor it - so the estimate has to floor it too. + // Type1 plans per filament and already reserves one; Type2 has only the volume to go on. + const double height = GENERATE(5., 100.); + const float floor = WipeTower::get_limit_depth_by_height(float(height)); + const char *wall = GENERATE("rectangle", "rib"); + DynamicPrintConfig config = make_config(wall); + config.set_key_value("prime_volume", new ConfigOptionFloat(0.)); + config.set_key_value("filament_prime_volume", new ConfigOptionFloats({0.})); + + CHECK(estimate(config, 3, 0.2, height, WipeTowerType::Type2).depth >= floor); + CHECK(estimate(config, 3, 0.2, height, WipeTowerType::Type1).depth >= floor); + // Still nothing for a lone filament with no other reason. + CHECK_THAT(estimate(config, 1, 0.2, height, WipeTowerType::Type2).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate(config, 1, 0.2, height, WipeTowerType::Type1).depth, WithinAbs(0., 1e-9)); +} + +TEST_CASE("Both wall types agree on whether there is a tower at all", "[WipeTowerEstimate]") { + // A wall type may only change the shape of the tower, never whether one is reserved: + // reporting no tower for one that is built collapses the validation hull to a point. + const double height = GENERATE(5., 100.); + DynamicPrintConfig rect = make_config(); + DynamicPrintConfig rib = make_config("rib"); + + // No tool change and nothing else that prints a tower - neither wall type reserves one. + CHECK_THAT(estimate(rect, 1, 0.2, height).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate(rib, 1, 0.2, height).depth, WithinAbs(0., 1e-9)); + + // Not even on a dual-nozzle printer, where a lone filament still needs no purge. + rect.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4, 0.4})); + rib.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4, 0.4})); + CHECK_THAT(estimate(rect, 1, 0.2, height).depth, WithinAbs(0., 1e-9)); + CHECK_THAT(estimate(rib, 1, 0.2, height).depth, WithinAbs(0., 1e-9)); + + // With a tool change both reserve one, and both respect the stability floor. + CHECK(estimate(rect, 2, 0.2, height).depth >= WipeTower::get_limit_depth_by_height(float(height))); + CHECK(estimate(rib, 2, 0.2, height).depth >= WipeTower::get_limit_depth_by_height(float(height))); +} + +TEST_CASE("A rib wall squares the tower and caps the rib width", "[WipeTowerEstimate]") { + DynamicPrintConfig config = make_config("rib"); + // sqrt(200 / 0.2) = 31.62 mm square, plus the 8 mm rib bulge along the diagonal. + const double body = std::sqrt(1000.); + WipeTowerFootprint fp = estimate(config, 3, 0.2, 5.); + CHECK_THAT(fp.depth, WithinAbs(8. / std::sqrt(2.) + body, 1e-5)); + CHECK_THAT(fp.width, WithinAbs(fp.depth, 1e-9)); + // The extra rib length runs along the diagonal and grows the footprint by its projection. + config.set_key_value("wipe_tower_extra_rib_length", new ConfigOptionFloat(4.)); + CHECK_THAT(estimate(config, 3, 0.2, 5.).depth, WithinAbs((8. + 4.) / std::sqrt(2.) + body, 1e-5)); + // A tiny tower caps the rib width at half its depth: 5 mm body, 2.5 mm rib. + config.set_key_value("wipe_tower_extra_rib_length", new ConfigOptionFloat(0.)); + config.set_key_value("prime_volume", new ConfigOptionFloat(5.)); + CHECK_THAT(estimate(config, 2, 0.2, 5.).depth, WithinAbs(2.5 / std::sqrt(2.) + 5., 1e-5)); +} + +TEST_CASE("Every wall and tower type is read the same from a preset and a static config", "[WipeTowerEstimate]") { + // The GUI, arrange and the CLI pass a DynamicPrintConfig whose enums are + // ConfigOptionEnumGeneric; Print passes a static config whose enums are ConfigOptionEnum. + // Both the wall type and the planner selection are read by value, so both give the same shape. + const char *wall_type = GENERATE("rectangle", "cone", "rib"); + const char *tower_type = GENERATE("type1", "type2"); + DynamicPrintConfig preset = make_config(wall_type); + preset.set_deserialize_strict("wipe_tower_type", tower_type); + REQUIRE(dynamic_cast(preset.option("wipe_tower_wall_type")) != nullptr); + + FullPrintConfig static_config; + static_config.apply(preset, true); + REQUIRE(static_config.wipe_tower_wall_type.serialize() == wall_type); + REQUIRE(static_config.wipe_tower_type.serialize() == tower_type); + + const WipeTowerType type = resolve_wipe_tower_type(preset); + CHECK(type == (std::string(tower_type) == "type1" ? WipeTowerType::Type1 : WipeTowerType::Type2)); + CHECK(resolve_wipe_tower_type(static_config) == type); + + // Three filaments purge twice per layer on a 5 mm object. + const WipeTowerFootprint fp = estimate(preset, 3, 0.2, 5., type); + const WipeTowerFootprint from_static = estimate(static_config, 3, 0.2, 5., type); + CHECK(fp.depth > 0.); + if (std::string(wall_type) == "rib") + CHECK_THAT(fp.width, WithinAbs(fp.depth, 1e-9)); + else + CHECK_THAT(fp.width, WithinAbs(50., 1e-9)); + CHECK_THAT(from_static.width, WithinAbs(fp.width, 1e-9)); + CHECK_THAT(from_static.depth, WithinAbs(fp.depth, 1e-9)); + CHECK_THAT(from_static.brim_width, WithinAbs(fp.brim_width, 1e-9)); + + // Smooth timelapse is the other enum the estimate reads: a lone filament gets a tower + // through both storages too. + preset.set_deserialize_strict("timelapse_type", "1"); + static_config.apply(preset, true); + CHECK(estimate(preset, 1, 0.2, 5., type).depth > 0.); + CHECK(estimate(static_config, 1, 0.2, 5., type).depth > 0.); +} + +TEST_CASE("The first-layer outline bulges only for a Type2 cone wall", "[WipeTowerEstimate]") { + // Read off a preset-shaped config, whose enums are ConfigOptionEnumGeneric: a cast to + // ConfigOptionEnum sees no wall type there and would never find the cone. + DynamicPrintConfig config = make_config("cone"); + config.set_key_value("wipe_tower_cone_angle", new ConfigOptionFloat(25.)); + REQUIRE(dynamic_cast(config.option("wipe_tower_wall_type")) != nullptr); + const Polygon box = Polygon::new_scale({{0., 0.}, {35., 0.}, {35., 20.}, {0., 20.}}); + auto is_box = [&box](const Polygon &outline) { return diff(Polygons{outline}, Polygons{box}).empty(); }; + + // A 25-degree cone on a 100 mm tower has a 22 mm base radius, past the 10 mm half-depth. + const Polygon cone = estimate_wipe_tower_first_layer_outline(config, WipeTowerType::Type2, 35., 20., 100.); + CHECK(unscaled(get_extents(cone).max.y()) > 20. + 1.); + CHECK(diff(Polygons{box}, Polygons{cone}).empty()); + // Type1 ignores the cone option, and the other wall types have no cone. + CHECK(is_box(estimate_wipe_tower_first_layer_outline(config, WipeTowerType::Type1, 35., 20., 100.))); + for (const char *wall_type : {"rectangle", "rib"}) { + config.set_deserialize_strict("wipe_tower_wall_type", wall_type); + CHECK(is_box(estimate_wipe_tower_first_layer_outline(config, WipeTowerType::Type2, 35., 20., 100.))); + } + // The static config Print holds gives the same outline. + config.set_deserialize_strict("wipe_tower_wall_type", "cone"); + FullPrintConfig static_config; + static_config.apply(config, true); + const Polygon from_static = estimate_wipe_tower_first_layer_outline(static_config, WipeTowerType::Type2, 35., 20., 100.); + CHECK(from_static.points == cone.points); +} + +TEST_CASE("A Bambu Lab printer always gets the Type1 planner", "[WipeTowerEstimate]") { + DynamicPrintConfig config = make_config(); + config.set_deserialize_strict("wipe_tower_type", "type2"); + config.set_key_value("printer_model", new ConfigOptionString("Bambu Lab X1 Carbon")); + CHECK(resolve_wipe_tower_type(config) == WipeTowerType::Type1); + config.set_key_value("printer_model", new ConfigOptionString("Voron 2.4")); + CHECK(resolve_wipe_tower_type(config) == WipeTowerType::Type2); + config.erase("wipe_tower_type"); + CHECK(resolve_wipe_tower_type(config) == WipeTowerType::Type2); +} + +TEST_CASE("A dual nozzle purges every filament plus the filament change", "[WipeTowerEstimate]") { + DynamicPrintConfig config = make_config(); + config.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4, 0.4})); + config.set_key_value("filament_change_length", new ConfigOptionFloats({10., 10.})); + config.set_key_value("filament_diameter", new ConfigOptionFloats({1.75, 1.75})); + // Two purges of 100 mm3 plus one 10 mm filament change: (200 + 10 * pi * 1.75^2 / 4) / (0.2 * 50). + const double change_volume = 10. * PI * 1.75 * 1.75 / 4.; + CHECK_THAT(estimate(config, 2, 0.2, 5.).depth, WithinAbs((200. + change_volume) / 10., 1e-9)); +} + +TEST_CASE("The shipped defaults size the tower from the flush matrix", "[WipeTowerEstimate]") { + // Both keys default to true, so the shipped configuration purges the flush volumes rather + // than the prime volume, with no infill gap on top - the flush volumes already hold it. + DynamicPrintConfig config = preset_shaped_defaults(); + REQUIRE(config.opt_bool("purge_in_prime_tower")); + REQUIRE(config.opt_bool("single_extruder_multi_material")); + config.set_key_value("prime_tower_width", new ConfigOptionFloat(50.)); + config.set_deserialize_strict("wipe_tower_wall_type", "rectangle"); + config.set_key_value("nozzle_diameter", new ConfigOptionFloats({0.4})); + + const double flush_volume = WipeTower2::estimate_semm_flush_volume(config, 2); + const double expected = std::max(double(WipeTower::get_limit_depth_by_height(5.f)), flush_volume / (0.2 * 50.)); + CHECK_THAT(estimate(config, 2, 0.2, 5.).depth, WithinAbs(expected, 1e-6)); +} + +TEST_CASE("A config missing a tower key falls back to that key's default", "[WipeTowerEstimate]") { + // The signature takes any ConfigBase: an absent key must read as its declared default. + const DynamicPrintConfig full = make_config(); + DynamicPrintConfig partial = full; + partial.erase("wipe_tower_extra_spacing"); + REQUIRE(partial.option("wipe_tower_extra_spacing") == nullptr); + + DynamicPrintConfig defaulted = full; + defaulted.set_key_value("wipe_tower_extra_spacing", + print_config_def.get("wipe_tower_extra_spacing")->default_value->clone()); + CHECK_THAT(estimate(partial, 3, 0.2, 5.).depth, WithinAbs(estimate(defaulted, 3, 0.2, 5.).depth, 1e-9)); +}