Compare commits

...
Author SHA1 Message Date
Lam Wei Lun 99de46b9d0 Rename the texture picker's pack section and OrcaCloud shortcut
Use "Texture libraries" for the plugin pack group and "Explore more
texture libraries" for the button that opens the OrcaCloud plugin hub.
2026-10-15 19:19:28 +08:00
Lam Wei Lun b8d40ed61f Load the OrcaCloud plugin page directly when the pane is already open
The texture picker's Download from OrcaCloud shortcut asks for a specific page. Re-running the
session handoff to reach it flashed the sign-in interstitial every time; instead an already-open
pane is navigated straight to the page URL, keeping its session. The handoff is still used on the
first open, where a session has to be established.

set_cloud_mode() gains an optional page_url for this. The homepage nav passes none, so its ordinary
re-open keeps the page the pane already holds (guide §8/§10).
2026-10-15 18:46:30 +08:00
Lam Wei Lun 54e6df0235 Group texture packs under one picker section and add a Download from OrcaCloud shortcut
The texture picker lists every plugin pack as an indented, collapsible child of one
"Resource Texture" section, which now always renders so the new button is reachable even
before any pack is installed. A pack whose name carries a " by ..." suffix shows that part
on a smaller, dimmed second line, keeping the name line short.

The button switches to the Home tab and opens the OrcaCloud pane on the plugin hub filtered
to resource textures. A native open now also drives the homepage shell (nav highlight and the
cloud-mode layout), and the requested page is remembered so a sign-in re-seed keeps it.

Also fixes the PluginsDialog fuzzy-search script path, which pointed at a directory the file
is not in.
2026-10-15 18:10:33 +08:00
SoftFever 79ed246d26 Refresh the texture picker's look and scrolling 2026-10-15 15:34:05 +08:00
SoftFever 92b5301bf6 Fix trackpad scroll bounce in ImGui panels 2026-10-15 15:34:05 +08:00
SoftFever 20c157b0f4 Merge branch 'main' into feature/resource-texture-pack-plugins 2026-10-15 13:57:38 +08:00
Kiss Lorand b75af485ce Fix DPI scaling for Move gizmo alignment icons (#16498)
Fix Move gizmo alignment icon scaling

Apply the native Windows DPI factor when sizing Move gizmo alignment icons.

This matches the sizing path used by other 16 px gizmo controls,
including support paint, seam, fuzzy-skin, and MMU painting tools.
2026-10-15 06:03:09 +03:00
6a7763f08d ENH: Add "Drop" button to move gizmo for objects, parts, and modifiers. And unify all Drop actions / checks (#16323)
* Add Drop to bed button for objects, parts and modifiers to the Position panel

Ported from BambuStudio PR #11511. The button appears under the position
inputs when the selection is not resting on the bed and moves it along
global Z. Works for a single full instance as well as a single part or
modifier, and is undoable.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7uDtamzhNGT6PFwmVdRJ8

* Reuse existing 'Drop to Bed' string so the button is already translated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7uDtamzhNGT6PFwmVdRJ8

* Add tooltip to the Drop to Bed button

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7uDtamzhNGT6PFwmVdRJ8

* unify function usage across UI

* Update GizmoObjectManipulation.cpp

* show tooltip even its disabled

* update

* Update GizmoObjectManipulation.cpp

* update

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: yw4z <ywsyildiz@gmail.com>
2026-10-15 03:22:28 +03:00
mosfet80 70e5b330be Fix openSUSE package names for ninja and WebKitGTK (#15384)
`ninja-build` and `webkit2gtk4-devel` no longer exist on Tumbleweed. Use `ninja`, and use `webkitgtk3-devel` for WebKit. It is the GTK3 build and ships the `webkit2gtk-4.1.pc` that `src/slic3r/CMakeLists.txt` requires.
2026-10-14 16:22:30 -05:00
xNoVoSx 5e94368863 Write the first layer temperatures of the printed filaments into the G-code header (#16422)
The `; first_layer_bed_temperature` and `; first_layer_temperature` compatibility lines were always taken from filament 1. Anycubic's GoKlipper reads the bed line to heat the bed, so a plate printed only with another filament got the wrong temperature.

The bed line now follows `bed_temperature_formula` (highest used filament or first printing filament). The nozzle line uses the first printing filament. Both values are computed before the by-object loop.
2026-10-14 16:11:57 -05:00
Kris Austin 0df9b782bb Remove unused CMake modules (#16452)
* Remove unused CMake modules

FindAlienWx.cmake and FindPerlEmbed.cmake came over from the Slic3r Perl
build. Nothing calls find_package(AlienWx) or find_package(PerlEmbed).

LibFindMacros.cmake was only included by those two modules.

bin2h.cmake lost its last caller when the avrdude sources were removed
in e6fcbfae07.

* ci: build changes under cmake/

The Build all path filters did not include cmake/, so a change to a
find module or to ClangClShowIncludes.cmake ran no build on a pull
request or on a push to main.
2026-10-14 15:36:54 -05:00
Kris Austinandyw4z d5403ccc94 fix: pin CPython's build Python and externals on Windows (#16420)
* fix: pin the Python that builds CPython on Windows

CPython's build needs a Python to run its own build scripts. Without a local
Python 3.10-3.13, find_python.bat installs the newest pythonx86 from nuget.
That became 3.15.0 on 2026-10-09, and 3.15 removed
SourceFileLoader.load_module, which 3.12's makeopcodetargets.py calls:

  AttributeError: 'SourceFileLoader' object has no attribute 'load_module'

Download Python 3.13.16 from nuget.org as its own dependency, with a hash
(pythonarm64 on ARM64 hosts, pythonx86 elsewhere), and pass it to PCbuild as
PYTHON, which find_python.bat takes before any other Python. PYTHONHOME and
PYTHONPATH are dropped from that step, since PCbuild runs the scripts
without -E. DEP_PYTHON_HOST points at an existing Python 3.10-3.13 instead,
for machines that cannot reach nuget.org.

Drop 01-windows-nuget.patch. It fixed the dead nuget.exe download link in
find_python.bat, and that fallback is never reached now. Existing deps trees
keep working: with no patch command, ExternalProject skips the patch step
instead of re-applying the patch to patched sources.

* fetch externals separately

---------

Co-authored-by: yw4z <ywsyildiz@gmail.com>
2026-10-14 15:36:26 -05:00
SoftFever 32d690ecee docs: add code signing policy and details about Windows release binaries 2026-10-15 01:48:46 +08:00
Rodrigo FaselliandIan Bassi 303d7a1a07 Option: Focus Mode (Show/ Hide inactive plates) (#16325)
* Option: Show/ Hide inactive plates

* Hide inactive plate volumes by bounds

Switch inactive-plate volume hiding to use geometric overlap with the current plate bounding box instead of object and wipe tower ID mapping. This also guards against a missing current plate and keeps any active volume visible when its transformed bounds overlap the current plate footprint.

* Hide inactive plates per instance and keep them unpickable

Decide what is shown per object instance, from its footprint over the
current plate, so the parts of an object are never split; a selected
instance stays visible.

Keep the hidden state between frames instead of restoring it after each
one. GLVolume::on_hidden_plate records what this update hid, so it only
shows that again and leaves the volumes gizmos hide alone, and the raycasts
made while handling the mouse see the same scene as the frame. Nothing runs
while the inactive plates are shown.

Deactivate the raycasters of the other plates, whose icons still took
clicks where nothing was drawn, move the axes to the current plate, and
limit the option to Prepare, as Preview already shows the current plate
alone.

* Follow the selected plate with the camera while the others are hidden

Selecting a plate in Prepare while the inactive plates are hidden moves
the camera target over it and keeps the angle, distance and height, so
the plate comes into view where the previous one was. A zoom to all
plates, as after loading a project or adding a plate, frames the current
plate instead.

* Add a compact plate switcher while inactive plates are hidden

The hidden plates can no longer be clicked, and the object list gets
crowded on large projects. While they are hidden in Prepare, a column of
numbered buttons in the top left corner, a compact counterpart of the
Preview plate toolbar, selects the plate to show. It highlights the
current plate, dims the empty ones and gives the plate name in the
tooltip.

* Simplify the inactive plates update and the plate switcher layout

Decide and apply each volume's state in one pass instead of filling a
per-volume vector first, and hoist the switcher's loop-invariant colors.
Keep the height the bottom-left corner takes next to the constants it is
made of, with the canvas toolbar's button spacing named, rather than
repeating that layout in the switcher.

* Rename the option to Focus mode

The canvas menu entry reads Focus mode, lit while the other plates are
hidden, and is off by default, so nothing changes until it is turned on.
The app config key is focus_mode. Turning it on zooms to the current
plate, so the plate fills the view.

* Show plate thumbnails in the plate switcher

The switcher shows the thumbnails of the Preview plate toolbar instead of
numbered buttons, with rounded corners concentric with the window's.
Plater::plate_toolbar_image_version() lets it refresh them without
clearing the Preview toolbar's dirty flag.

* Don't draw the sinking contours of inactive volumes

GLVolume::render() skips an inactive volume, but its sinking contour was
still drawn, so an object on a hidden plate left its outline behind.

* Show the plate switcher's scrollbar only on hover, as in Preview

The plate switcher now shares the Preview plate toolbar's scrollbar: the
same width and colors, shown only while the mouse is over the column or
drags the bar, with the mouse wheel scrolling it either way, and tighter
window corners while it shows. Both toolbars draw it through the same
helpers, so they cannot drift apart; the Preview toolbar behaves as before.

Drop the frame style the switcher no longer uses since it draws
thumbnails instead of buttons.

* Make the plate switcher's thumbnails cheaper and match the Preview toolbar

The thumbnail version moves after almost every edit, and the switcher
redrew both thumbnails of every plate each time. It now redraws only the
current plate's, the only one edited while the others are hidden, and the
other plates' once their thumbnails are invalidated, through the new
Plater::update_plate_thumbnails().

IMToolbarItem::set_thumbnail() uploads a plate thumbnail without keeping
a copy of its pixels, a megabyte per plate, and replaces the item setup
the Preview toolbar and the switcher each repeated. The switcher's hover
border takes the Preview toolbar's color.

---------

Co-authored-by: Ian Bassi <ian.bassi@outlook.com>
2026-10-14 14:23:41 -03:00
Ian Bassi c0e6db227d Export G-code statistics as JSON (#16487)
* Export G-code statistics as JSON

Add an export of a plate's G-code statistics for scripts and
spreadsheets that compare prints or settings: print time, travel,
filament and feature totals, the same per layer, and the filament of
each object instance and its supports. The times are the preview's
estimates, summed the way the preview sums them.

The GUI writes <name>.statistics.json next to an exported G-code when
"Export statistics with G-code" is enabled in Preferences (Control >
Slicing). The CLI writes plate_N.statistics.json to the output directory
with --export-statistics, and exits with -25 when it cannot.

The per-instance masses now carry the object's name and the instance's
index, so the export can name them. The vertices the processor adds
inside a move for the actual speed view repeat the move's extrusion, so
the filament sums skip them.

* Move the statistics export preference to the Developer tab

"Export statistics with G-code" now sits in Developer > Settings
instead of Control > Slicing.

* Store the G-code statistics inside exported 3MF files

With the preference on, an exported sliced 3MF file holds each plate's
statistics as Metadata/plate_N.statistics.json, next to its G-code. The
print job sent to a printer and the autosave backup leave them out.

The CLI does the same with --export-3mf, and writes the separate file
only next to the G-code in --outputdir, as without it the G-code is a
temporary file. The file is written in binary mode, so it reads the
same on every platform and matches the copy in the 3MF.

* Address review of the statistics export

- Number each object instance by its position among its model object's
  copies. A copy of another rotation or scale, and every copy on a belt
  printer, slices as a print object of its own, where it was always
  instance 0; a copy after a non-printable one shifted too.
- Take the filament totals from the processor's statistics, which count
  the flush as the preview's filament table does, and list each
  filament's model, support, prime tower and flush use. The summary's
  cost is now filament_cost, as the preview's cost adds the time cost.
- State when the version goes up.
- Drop the preference's default, which get_bool already gives.
2026-10-14 14:22:04 -03:00
Ian Chua a078155890 fix: remove external open icon from orcacloud tab (#16489) 2026-10-15 01:15:08 +08:00
Rodrigo Faselli 3b6e27d725 add missing header GLGizmoTextureDisplacement.cpp (#16488)
Update GLGizmoTextureDisplacement.cpp
2026-10-14 14:14:05 -03:00
SoftFever 6254ab643a fix missing icon in control panel for Windows 2026-10-15 01:08:01 +08:00
Ian Chua 9ab0178dd0 feat: add orcacloud to homepage (#16402)
* feat: add orcacloud to homepage

* feat: add OrcaCloud webview pane and session handoff from the app

* feat: OrcaCloud pane crash, per-open re-sign-in, and web-session persistence

* fix: clang-tidy
2026-10-15 01:01:25 +08:00
Lam Wei Lun 4cf16febf6 Fix plugin unit test 2026-10-14 18:23:32 +08:00
Lam Wei Lun 70e52a105f Fix clang tidy 2026-10-14 17:38:52 +08:00
Lam Wei Lun b525cb79e1 Merge main 2026-10-14 17:31:35 +08:00
Lam Wei Lun 86ab598899 Add a ResourceTexture plugin capability for height-map texture packs
A fifth Python plugin capability type, ResourceTexture, contributes
height-map textures to the Texture Displacement picker. A pack returns a
directory of images; the host validates it once at load and lists the
images under a section of their own.

- New capability type ResourceTexture, type string "resource-texture",
  exposed to plugin authors as
  orca.resource_texture.ResourceTexturePluginCapabilityBase with one
  required method, get_texture_dir().
- The directory is resolved once on the load worker, under the GIL, and
  cached as a plain string: the picker runs inside the ImGui frame, where
  a trampoline call would block on the GIL behind a load/unload worker, or
  throw past EndPopup.
- Validation resolves the directory and requires containment in the plugin
  root, re-checked per file so a symlink inside the pack cannot resolve
  outside it. Containment is the only security control here and it guards
  exfiltration: layer bytes are embedded in every saved .3mf, so a pack
  that could return ~/.ssh/id_rsa would leak it through any shared project.
- New host registry PluginResourceTextures, filled from both the plugin
  and the capability lifecycle callbacks plus a startup sweep. It holds
  strings only and never a capability reference, so nothing outlives
  Py_Finalize.
- The picker lists pack textures read-only, one collapsible section per
  pack, in a fixed-size scrolling popup whose height follows the window. A
  generation counter invalidates the cached listing, and each gizmo's
  cached thumbnails, when the set of packs changes.
- Tests in tests/slic3rutils/test_plugin_resource_texture.cpp cover the
  validator (parent-directory and absolute escapes, an escaping symlink,
  the extension whitelist, the 64-file cap), the listing and its
  load/disable/re-enable/unload lifecycle, hostile get_texture_dir()
  overrides, decode parity with hand-picked files, and a .3mf round trip
  with the pack uninstalled.

No persistence work is needed: a layer carries its own image bytes and 3MF
writes them into the archive, so uninstalling a pack leaves existing
projects intact.

Behaviour change worth noting for review: the picker popup is now a fixed
size that scrolls, rather than sizing itself to its content, and it gains
one collapsible section per pack. That sizing applies to every user, not
only those with packs installed.
2026-10-14 17:15:39 +08:00
81 changed files with 3812 additions and 940 deletions
+2
View File
@@ -8,6 +8,7 @@ on:
paths:
- 'deps/**'
- 'deps_src/**'
- 'cmake/**'
- 'src/**'
- '**/CMakeLists.txt'
- 'version.inc'
@@ -31,6 +32,7 @@ on:
paths:
- 'deps/**'
- 'deps_src/**'
- 'cmake/**'
- 'src/**'
- '**/CMakeLists.txt'
- 'version.inc'
+2 -1
View File
@@ -1345,7 +1345,8 @@ set (CPACK_PACKAGE_INSTALL_DIRECTORY ${CPACK_PACKAGE_NAME})
set (CPACK_PACKAGE_ICON "${CMAKE_SOURCE_DIR}/resources/images\\\\OrcaSlicer.ico")
set (CPACK_NSIS_MUI_ICON "${CPACK_PACKAGE_ICON}")
set (CPACK_NSIS_MUI_UNIICON "${CPACK_PACKAGE_ICON}")
set (CPACK_NSIS_INSTALLED_ICON_NAME "$INSTDIR\\\\orca-slicer.exe")
# Relative to the install dir: CPack's NSIS template prefixes $INSTDIR itself.
set (CPACK_NSIS_INSTALLED_ICON_NAME "orca-slicer.exe")
set (CPACK_PACKAGE_CHECKSUM SHA256)
set (CPACK_PACKAGE_INSTALL_REGISTRY_KEY "OrcaSlicer")
set (CPACK_NSIS_ENABLE_UNINSTALL_BEFORE_INSTALL ON)
+12 -1
View File
@@ -86,7 +86,8 @@ The [wiki](https://www.orcaslicer.com/wiki) aims to provide a detailed explanati
## Stable Release
📥 **[Download the Latest Stable Release](https://github.com/OrcaSlicer/OrcaSlicer/releases/latest)**
Visit our GitHub Releases page for the latest stable version of OrcaSlicer, recommended for most users.
Visit our GitHub Releases page for the latest stable version of OrcaSlicer, recommended for most users.
Windows release binaries are code signed; see our [Code signing policy](#code-signing-policy).
## Nightly Builds
@@ -244,6 +245,16 @@ OrcaSlicer began in that same spirit, drawing from BambuStudio, PrusaSlicer, and
The OrcaSlicer logo was designed by community member [Justin Levine](https://github.com/jal-co).
# Code Signing Policy
Free code signing provided by [SignPath.io](https://about.signpath.io), certificate by [SignPath Foundation](https://signpath.org).
Windows release binaries are built by GitHub Actions from this repository and signed through the SignPath Foundation program. Every signing request is manually approved.
**Team roles:** [SoftFever](https://github.com/SoftFever) is the committer, reviewer and approver for all signed releases.
**Privacy policy:** See the [OrcaSlicer privacy policy](https://www.orcaslicer.com/privacy-policy/).
# License
- **OrcaSlicer** is licensed under the GNU Affero General Public License, version 3.
+46
View File
@@ -42,6 +42,7 @@ call :add_arg build_deps bool d deps "Download and build the dependencies, neede
call :add_arg build_slicer bool s slicer "Build OrcaSlicer"
call :add_arg build_tests bool "" tests "Build the unit tests"
call :add_arg run_tests bool "" run-tests "Build the unit tests and run them"
call :add_arg make_installer bool "" installer "Build OrcaSlicer and package it into an NSIS installer"
call :add_arg pack_deps bool p pack "Bundle the built dependencies into a zip file"
call :add_arg install_deps bool u install-deps "Install or update CMake, Perl and Git with WinGet"
call :add_arg install_vs string "" install-vs "Also install Visual Studio: buildtools or ide"
@@ -185,6 +186,9 @@ if "%build_deps%%build_slicer%%pack_deps%%install_deps%%install_vs%" == "" (
if "%run_tests%" == "ON" set "build_slicer=ON"
)
REM The installer packages the slicer build, so it always needs one.
if "%make_installer%" == "ON" set "build_slicer=ON"
REM Options like --config or -j only shape a build. Without one of the actions
REM there is nothing for them to shape, so say so rather than resolving a whole
REM build and reporting it took no time. The block above may have added one.
@@ -222,6 +226,30 @@ if "%build_slicer%" == "ON" if "%install_slicer%" == "ON" if not "%slicer_target
echo --install needs the executable, but --slicer-target names "%slicer_target%".
exit /b 1
)
REM cpack runs the same install, so it has the same need.
if "%make_installer%" == "ON" if not "%slicer_target%" == "" if /I not "%slicer_target%" == "OrcaSlicer" (
echo --installer needs the executable, but --slicer-target names "%slicer_target%".
exit /b 1
)
REM cpack finds makensis on PATH or through the registry key the NSIS installer
REM writes, so look in the same places, and before the build rather than after
REM it. The value is read past its REG_SZ marker because the name column is
REM localised and may hold spaces.
if not "%make_installer%" == "ON" goto :nsis_ready
if "%dry_run%" == "ON" goto :nsis_ready
where makensis >nul 2>nul && goto :nsis_ready
for %%v in (32 64) do (
for /f "delims=" %%l in ('reg query "HKLM\SOFTWARE\NSIS" /ve /reg:%%v 2^>nul ^| findstr REG_SZ') do (
set "nsis_line=%%l"
set "nsis_line=!nsis_line:*REG_SZ=!"
for /f "tokens=*" %%d in ("!nsis_line!") do if exist "%%d\makensis.exe" goto :nsis_ready
)
)
echo NSIS was not found, and --installer needs it to build the installer.
echo Install it with "winget install NSIS.NSIS", then run this again.
exit /b 1
:nsis_ready
if not "%vs_version%" == "" if "!vs_gen_%vs_version%!" == "" (
echo Unknown Visual Studio release "%vs_version%". Known releases: 2019, 2022, 2026.
exit /b 1
@@ -734,6 +762,14 @@ if "%build_slicer%" == "ON" (
call :print_and_run cmake --build "%build_dir%" --target install --config %build_type%
%error_check%
)
REM cpack installs into its own staging tree, so this does not need -i.
REM Unlike CI it skips the preset cache, whose packaging step prunes the
REM checkout's preset JSONs.
if "%make_installer%" == "ON" (
call :print_and_run cpack -G NSIS -C %build_type% --config "%build_dir%\CPackConfig.cmake" -B "%build_dir%"
%error_check%
)
)
REM Elapsed wall clock. The 1%%a-100 trick strips a leading zero, which set /A
@@ -820,6 +856,16 @@ REM worked out the same way in either run.
if "%build_slicer%" == "ON" if not "%linked%" == "ON" echo Target %slicer_target%
if "%build_slicer%" == "ON" if not "%using_ninja%" == "ON" echo Solution %build_full%\!solution!
if "%pack_deps%" == "ON" if defined bundle echo Bundle !bundle!
if "%make_installer%" == "ON" (
REM The file name carries the version, which only CMake knows, so name
REM the newest match, or the pattern when there is none yet.
set "installer="
for /f "delims=" %%f in ('dir /b /o-d "!build_full!\OrcaSlicer_Windows_Installer_*.exe" 2^>nul') do (
if not defined installer set "installer=!build_full!\%%f"
)
if not defined installer set "installer=!build_full!\OrcaSlicer_Windows_Installer_*.exe"
echo Installer !installer!
)
echo.
echo Next
-109
View File
@@ -1,109 +0,0 @@
# Find the wxWidgets module based on the information provided by the Perl Alien::wxWidgets module.
# Check for the Perl & PerlLib modules
include(LibFindMacros)
libfind_package(AlienWx Perl)
libfind_package(AlienWx PerlLibs)
if (AlienWx_DEBUG)
message(STATUS " AlienWx_FIND_COMPONENTS=${AlienWx_FIND_COMPONENTS}")
endif()
# Execute an Alien::Wx module to find the relevant information regarding
# the wxWidgets used by the Perl interpreter.
# Perl specific stuff
set(AlienWx_TEMP_INCLUDE ${CMAKE_CURRENT_BINARY_DIR}/AlienWx_TEMP_INCLUDE.txt)
execute_process(
COMMAND ${PERL_EXECUTABLE} -e "
# Import Perl modules.
use strict;
use warnings;
use Text::ParseWords;
BEGIN {
# CMake sets the environment variables CC and CXX to the detected C compiler.
# There is an issue with the Perl ExtUtils::CBuilder, which does not handle whitespaces
# in the paths correctly on Windows, so we rather drop the CMake auto-detected paths.
delete \$ENV{CC};
delete \$ENV{CXX};
}
use Alien::wxWidgets;
use ExtUtils::CppGuess;
# Test for a Visual Studio compiler
my \$cpp_guess = ExtUtils::CppGuess->new;
my \$mswin = \$^O eq 'MSWin32';
my \$msvc = \$cpp_guess->is_msvc;
# List of wxWidgets components to be used.
my @components = split /;/, '${AlienWx_FIND_COMPONENTS}';
# Query the available data from Alien::wxWidgets.
my \$version = Alien::wxWidgets->version;
my \$config = Alien::wxWidgets->config;
my \$compiler = Alien::wxWidgets->compiler;
my \$linker = Alien::wxWidgets->linker;
my \$include_path = ' ' . Alien::wxWidgets->include_path;
my \$defines = ' ' . Alien::wxWidgets->defines;
my \$cflags = Alien::wxWidgets->c_flags;
my \$linkflags = Alien::wxWidgets->link_flags;
my \$libraries = ' ' . Alien::wxWidgets->libraries(@components);
my \$gui_toolkit = Alien::wxWidgets->config->{toolkit};
#my @libraries = Alien::wxWidgets->link_libraries(@components);
#my @implib = Alien::wxWidgets->import_libraries(@components);
#my @shrlib = Alien::wxWidgets->shared_libraries(@components);
#my @keys = Alien::wxWidgets->library_keys; # 'gl', 'adv', ...
#my \$library_path = Alien::wxWidgets->shared_library_path;
#my \$key = Alien::wxWidgets->key;
#my \$prefix = Alien::wxWidgets->prefix;
my \$filename = '${AlienWx_TEMP_INCLUDE}';
open(my $fh, '>', \$filename) or die \"Could not open file '\$filename' \$!\";
# Convert a space separated lists to CMake semicolon separated lists,
# escape the backslashes,
# export the resulting list to a temp file.
sub cmake_set_var {
my (\$varname, \$content) = @_;
# Remove line separators.
\$content =~ s/\\r|\\n//g;
# Escape the path separators.
\$content =~ s/\\\\/\\\\\\\\\\\\\\\\/g;
my @words = shellwords(\$content);
print \$fh \"set(AlienWx_\$varname \\\"\" . join(';', @words) . \"\\\")\\n\";
}
cmake_set_var('VERSION', \$version);
\$include_path =~ s/ -I/ /g;
cmake_set_var('INCLUDE_DIRS', \$include_path);
\$libraries =~ s/ -L/ -LIBPATH:/g if \$msvc;
cmake_set_var('LIBRARIES', \$libraries);
#cmake_set_var('LIBRARY_DIRS', );
#\$defines =~ s/ -D/ /g;
cmake_set_var('DEFINITIONS', \$defines);
#cmake_set_var('DEFINITIONS_DEBUG', );
cmake_set_var('CXX_FLAGS', \$cflags);
cmake_set_var('GUI_TOOLKIT', \$gui_toolkit);
close \$fh;
")
include(${AlienWx_TEMP_INCLUDE})
file(REMOVE ${AlienWx_TEMP_INCLUDE})
unset(AlienWx_TEMP_INCLUDE)
if (AlienWx_DEBUG)
message(STATUS " AlienWx_VERSION = ${AlienWx_VERSION}")
message(STATUS " AlienWx_INCLUDE_DIRS = ${AlienWx_INCLUDE_DIRS}")
message(STATUS " AlienWx_LIBRARIES = ${AlienWx_LIBRARIES}")
message(STATUS " AlienWx_LIBRARY_DIRS = ${AlienWx_LIBRARY_DIRS}")
message(STATUS " AlienWx_DEFINITIONS = ${AlienWx_DEFINITIONS}")
message(STATUS " AlienWx_DEFINITIONS_DEBUG = ${AlienWx_DEFINITIONS_DEBUG}")
message(STATUS " AlienWx_CXX_FLAGS = ${AlienWx_CXX_FLAGS}")
message(STATUS " AlienWx_GUI_TOOLKIT = ${AlienWx_GUI_TOOLKIT}")
endif()
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(AlienWx
REQUIRED_VARS AlienWx_INCLUDE_DIRS AlienWx_LIBRARIES
# HANDLE_COMPONENTS
VERSION_VAR AlienWx_VERSION)
-88
View File
@@ -1,88 +0,0 @@
# Find the dependencies for linking with the Perl runtime library.
# Check for the Perl & PerlLib modules
include(LibFindMacros)
libfind_package(PerlEmbed Perl)
libfind_package(PerlEmbed PerlLibs)
# Execute an Alien::Wx module to find the relevant information regarding
# the wxWidgets used by the Perl interpreter.
# Perl specific stuff
set(PerlEmbed_TEMP_INCLUDE ${CMAKE_CURRENT_BINARY_DIR}/PerlEmbed_TEMP_INCLUDE.txt)
execute_process(
COMMAND ${PERL_EXECUTABLE} -MExtUtils::Embed -e "
# Import Perl modules.
use strict;
use warnings;
use Config;
use Text::ParseWords;
use ExtUtils::CppGuess;
# Test for a Visual Studio compiler
my \$cpp_guess = ExtUtils::CppGuess->new;
my \$mswin = \$^O eq 'MSWin32';
my \$msvc = \$cpp_guess->is_msvc;
# Query the available data from Alien::wxWidgets.
my \$ccflags;
my \$ldflags;
{ local *STDOUT; open STDOUT, '>', \\\$ccflags; ccflags; }
{ local *STDOUT; open STDOUT, '>', \\\$ldflags; ldopts; }
\$ccflags = ' ' . \$ccflags;
\$ldflags = ' ' . \$ldflags;
my \$filename = '${PerlEmbed_TEMP_INCLUDE}';
open(my $fh, '>', \$filename) or die \"Could not open file '\$filename' \$!\";
# Convert a space separated lists to CMake semicolon separated lists,
# escape the backslashes,
# export the resulting list to a temp file.
sub cmake_set_var {
my (\$varname, \$content) = @_;
# Remove line separators.
\$content =~ s/\\r|\\n//g;
# Escape the path separators.
\$content =~ s/\\\\/\\\\\\\\\\\\\\\\/g;
my @words = shellwords(\$content);
print \$fh \"set(PerlEmbed_\$varname \\\"\" . join(';', @words) . \"\\\")\\n\";
}
cmake_set_var('ARCHNAME', \$Config{archname});
cmake_set_var('CCFLAGS', \$ccflags);
\$ldflags =~ s/ -L/ -LIBPATH:/g if \$msvc;
cmake_set_var('LD', \$Config{ld});
cmake_set_var('LDFLAGS', \$ldflags);
cmake_set_var('CCCDLFLAGS', \$Config{cccdlflags});
cmake_set_var('LDDLFLAGS', \$Config{lddlflags});
cmake_set_var('DLEXT', \$Config{dlext});
close \$fh;
")
include(${PerlEmbed_TEMP_INCLUDE})
file(REMOVE ${PerlEmbed_TEMP_INCLUDE})
unset(PerlEmbed_TEMP_INCLUDE)
if (PerlEmbed_DEBUG)
# First show the configuration extracted by FindPerl & FindPerlLibs:
message(STATUS " PERL_INCLUDE_PATH = ${PERL_INCLUDE_PATH}")
message(STATUS " PERL_LIBRARY = ${PERL_LIBRARY}")
message(STATUS " PERL_EXECUTABLE = ${PERL_EXECUTABLE}")
message(STATUS " PERL_SITESEARCH = ${PERL_SITESEARCH}")
message(STATUS " PERL_SITELIB = ${PERL_SITELIB}")
message(STATUS " PERL_VENDORARCH = ${PERL_VENDORARCH}")
message(STATUS " PERL_VENDORLIB = ${PERL_VENDORLIB}")
message(STATUS " PERL_ARCHLIB = ${PERL_ARCHLIB}")
message(STATUS " PERL_PRIVLIB = ${PERL_PRIVLIB}")
message(STATUS " PERL_EXTRA_C_FLAGS = ${PERL_EXTRA_C_FLAGS}")
# Second show the configuration extracted by this module (FindPerlEmbed):
message(STATUS " PerlEmbed_ARCHNAME = ${PerlEmbed_ARCHNAME}")
message(STATUS " PerlEmbed_CCFLAGS = ${PerlEmbed_CCFLAGS}")
message(STATUS " PerlEmbed_CCCDLFLAGS = ${PerlEmbed_CCCDLFLAGS}")
message(STATUS " LD = ${PerlEmbed_LD}")
message(STATUS " PerlEmbed_LDFLAGS = ${PerlEmbed_LDFLAGS}")
message(STATUS " PerlEmbed_LDDLFLAGS = ${PerlEmbed_LDDLFLAGS}")
endif()
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(PerlEmbed
REQUIRED_VARS PerlEmbed_CCFLAGS PerlEmbed_LDFLAGS
VERSION_VAR PERL_VERSION)
-265
View File
@@ -1,265 +0,0 @@
# Version 2.2
# Public Domain, originally written by Lasse Kärkkäinen <tronic>
# Maintained at https://github.com/Tronic/cmake-modules
# Please send your improvements as pull requests on Github.
# Find another package and make it a dependency of the current package.
# This also automatically forwards the "REQUIRED" argument.
# Usage: libfind_package(<prefix> <another package> [extra args to find_package])
macro (libfind_package PREFIX PKG)
set(${PREFIX}_args ${PKG} ${ARGN})
if (${PREFIX}_FIND_REQUIRED)
set(${PREFIX}_args ${${PREFIX}_args} REQUIRED)
endif()
find_package(${${PREFIX}_args})
set(${PREFIX}_DEPENDENCIES ${${PREFIX}_DEPENDENCIES};${PKG})
unset(${PREFIX}_args)
endmacro()
# A simple wrapper to make pkg-config searches a bit easier.
# Works the same as CMake's internal pkg_check_modules but is always quiet.
macro (libfind_pkg_check_modules)
find_package(PkgConfig QUIET)
if (PKG_CONFIG_FOUND)
pkg_check_modules(${ARGN} QUIET)
endif()
endmacro()
# Avoid useless copy&pasta by doing what most simple libraries do anyway:
# pkg-config, find headers, find library.
# Usage: libfind_pkg_detect(<prefix> <pkg-config args> FIND_PATH <name> [other args] FIND_LIBRARY <name> [other args])
# E.g. libfind_pkg_detect(SDL2 sdl2 FIND_PATH SDL.h PATH_SUFFIXES SDL2 FIND_LIBRARY SDL2)
function (libfind_pkg_detect PREFIX)
# Parse arguments
set(argname pkgargs)
foreach (i ${ARGN})
if ("${i}" STREQUAL "FIND_PATH")
set(argname pathargs)
elseif ("${i}" STREQUAL "FIND_LIBRARY")
set(argname libraryargs)
else()
set(${argname} ${${argname}} ${i})
endif()
endforeach()
if (NOT pkgargs)
message(FATAL_ERROR "libfind_pkg_detect requires at least a pkg_config package name to be passed.")
endif()
# Find library
libfind_pkg_check_modules(${PREFIX}_PKGCONF ${pkgargs})
if (pathargs)
find_path(${PREFIX}_INCLUDE_DIR NAMES ${pathargs} HINTS ${${PREFIX}_PKGCONF_INCLUDE_DIRS})
endif()
if (libraryargs)
find_library(${PREFIX}_LIBRARY NAMES ${libraryargs} HINTS ${${PREFIX}_PKGCONF_LIBRARY_DIRS})
endif()
endfunction()
# Extracts a version #define from a version.h file, output stored to <PREFIX>_VERSION.
# Usage: libfind_version_header(Foobar foobar/version.h FOOBAR_VERSION_STR)
# Fourth argument "QUIET" may be used for silently testing different define names.
# This function does nothing if the version variable is already defined.
function (libfind_version_header PREFIX VERSION_H DEFINE_NAME)
# Skip processing if we already have a version or if the include dir was not found
if (${PREFIX}_VERSION OR NOT ${PREFIX}_INCLUDE_DIR)
return()
endif()
set(quiet ${${PREFIX}_FIND_QUIETLY})
# Process optional arguments
foreach(arg ${ARGN})
if (arg STREQUAL "QUIET")
set(quiet TRUE)
else()
message(AUTHOR_WARNING "Unknown argument ${arg} to libfind_version_header ignored.")
endif()
endforeach()
# Read the header and parse for version number
set(filename "${${PREFIX}_INCLUDE_DIR}/${VERSION_H}")
if (NOT EXISTS ${filename})
if (NOT quiet)
message(AUTHOR_WARNING "Unable to find ${${PREFIX}_INCLUDE_DIR}/${VERSION_H}")
endif()
return()
endif()
file(READ "${filename}" header)
string(REGEX REPLACE ".*#[ \t]*define[ \t]*${DEFINE_NAME}[ \t]*\"([^\n]*)\".*" "\\1" match "${header}")
# No regex match?
if (match STREQUAL header)
if (NOT quiet)
message(AUTHOR_WARNING "Unable to find \#define ${DEFINE_NAME} \"<version>\" from ${${PREFIX}_INCLUDE_DIR}/${VERSION_H}")
endif()
return()
endif()
# Export the version string
set(${PREFIX}_VERSION "${match}" PARENT_SCOPE)
endfunction()
# Do the final processing once the paths have been detected.
# If include dirs are needed, ${PREFIX}_PROCESS_INCLUDES should be set to contain
# all the variables, each of which contain one include directory.
# Ditto for ${PREFIX}_PROCESS_LIBS and library files.
# Will set ${PREFIX}_FOUND, ${PREFIX}_INCLUDE_DIRS and ${PREFIX}_LIBRARIES.
# Also handles errors in case library detection was required, etc.
function (libfind_process PREFIX)
# Skip processing if already processed during this configuration run
if (${PREFIX}_FOUND)
return()
endif()
set(found TRUE) # Start with the assumption that the package was found
# Did we find any files? Did we miss includes? These are for formatting better error messages.
set(some_files FALSE)
set(missing_headers FALSE)
# Shorthands for some variables that we need often
set(quiet ${${PREFIX}_FIND_QUIETLY})
set(required ${${PREFIX}_FIND_REQUIRED})
set(exactver ${${PREFIX}_FIND_VERSION_EXACT})
set(findver "${${PREFIX}_FIND_VERSION}")
set(version "${${PREFIX}_VERSION}")
# Lists of config option names (all, includes, libs)
unset(configopts)
set(includeopts ${${PREFIX}_PROCESS_INCLUDES})
set(libraryopts ${${PREFIX}_PROCESS_LIBS})
# Process deps to add to
foreach (i ${PREFIX} ${${PREFIX}_DEPENDENCIES})
if (DEFINED ${i}_INCLUDE_OPTS OR DEFINED ${i}_LIBRARY_OPTS)
# The package seems to export option lists that we can use, woohoo!
list(APPEND includeopts ${${i}_INCLUDE_OPTS})
list(APPEND libraryopts ${${i}_LIBRARY_OPTS})
else()
# If plural forms don't exist or they equal singular forms
if ((NOT DEFINED ${i}_INCLUDE_DIRS AND NOT DEFINED ${i}_LIBRARIES) OR
({i}_INCLUDE_DIR STREQUAL ${i}_INCLUDE_DIRS AND ${i}_LIBRARY STREQUAL ${i}_LIBRARIES))
# Singular forms can be used
if (DEFINED ${i}_INCLUDE_DIR)
list(APPEND includeopts ${i}_INCLUDE_DIR)
endif()
if (DEFINED ${i}_LIBRARY)
list(APPEND libraryopts ${i}_LIBRARY)
endif()
else()
# Oh no, we don't know the option names
message(FATAL_ERROR "We couldn't determine config variable names for ${i} includes and libs. Aieeh!")
endif()
endif()
endforeach()
if (includeopts)
list(REMOVE_DUPLICATES includeopts)
endif()
if (libraryopts)
list(REMOVE_DUPLICATES libraryopts)
endif()
string(REGEX REPLACE ".*[ ;]([^ ;]*(_INCLUDE_DIRS|_LIBRARIES))" "\\1" tmp "${includeopts} ${libraryopts}")
if (NOT tmp STREQUAL "${includeopts} ${libraryopts}")
message(AUTHOR_WARNING "Plural form ${tmp} found in config options of ${PREFIX}. This works as before but is now deprecated. Please only use singular forms INCLUDE_DIR and LIBRARY, and update your find scripts for LibFindMacros > 2.0 automatic dependency system (most often you can simply remove the PROCESS variables entirely).")
endif()
# Include/library names separated by spaces (notice: not CMake lists)
unset(includes)
unset(libs)
# Process all includes and set found false if any are missing
foreach (i ${includeopts})
list(APPEND configopts ${i})
if (NOT "${${i}}" STREQUAL "${i}-NOTFOUND")
list(APPEND includes "${${i}}")
else()
set(found FALSE)
set(missing_headers TRUE)
endif()
endforeach()
# Process all libraries and set found false if any are missing
foreach (i ${libraryopts})
list(APPEND configopts ${i})
if (NOT "${${i}}" STREQUAL "${i}-NOTFOUND")
list(APPEND libs "${${i}}")
else()
set (found FALSE)
endif()
endforeach()
# Version checks
if (found AND findver)
if (NOT version)
message(WARNING "The find module for ${PREFIX} does not provide version information, so we'll just assume that it is OK. Please fix the module or remove package version requirements to get rid of this warning.")
elseif (version VERSION_LESS findver OR (exactver AND NOT version VERSION_EQUAL findver))
set(found FALSE)
set(version_unsuitable TRUE)
endif()
endif()
# If all-OK, hide all config options, export variables, print status and exit
if (found)
foreach (i ${configopts})
mark_as_advanced(${i})
endforeach()
if (NOT quiet)
message(STATUS "Found ${PREFIX} ${${PREFIX}_VERSION}")
if (LIBFIND_DEBUG)
message(STATUS " ${PREFIX}_DEPENDENCIES=${${PREFIX}_DEPENDENCIES}")
message(STATUS " ${PREFIX}_INCLUDE_OPTS=${includeopts}")
message(STATUS " ${PREFIX}_INCLUDE_DIRS=${includes}")
message(STATUS " ${PREFIX}_LIBRARY_OPTS=${libraryopts}")
message(STATUS " ${PREFIX}_LIBRARIES=${libs}")
endif()
set (${PREFIX}_INCLUDE_OPTS ${includeopts} PARENT_SCOPE)
set (${PREFIX}_LIBRARY_OPTS ${libraryopts} PARENT_SCOPE)
set (${PREFIX}_INCLUDE_DIRS ${includes} PARENT_SCOPE)
set (${PREFIX}_LIBRARIES ${libs} PARENT_SCOPE)
set (${PREFIX}_FOUND TRUE PARENT_SCOPE)
endif()
return()
endif()
# Format messages for debug info and the type of error
set(vars "Relevant CMake configuration variables:\n")
foreach (i ${configopts})
mark_as_advanced(CLEAR ${i})
set(val ${${i}})
if ("${val}" STREQUAL "${i}-NOTFOUND")
set (val "<not found>")
elseif (val AND NOT EXISTS ${val})
set (val "${val} (does not exist)")
else()
set(some_files TRUE)
endif()
set(vars "${vars} ${i}=${val}\n")
endforeach()
set(vars "${vars}You may use CMake GUI, cmake -D or ccmake to modify the values. Delete CMakeCache.txt to discard all values and force full re-detection if necessary.\n")
if (version_unsuitable)
set(msg "${PREFIX} ${${PREFIX}_VERSION} was found but")
if (exactver)
set(msg "${msg} only version ${findver} is acceptable.")
else()
set(msg "${msg} version ${findver} is the minimum requirement.")
endif()
else()
if (missing_headers)
set(msg "We could not find development headers for ${PREFIX}. Do you have the necessary dev package installed?")
elseif (some_files)
set(msg "We only found some files of ${PREFIX}, not all of them. Perhaps your installation is incomplete or maybe we just didn't look in the right place?")
if(findver)
set(msg "${msg} This could also be caused by incompatible version (if it helps, at least ${PREFIX} ${findver} should work).")
endif()
else()
set(msg "We were unable to find package ${PREFIX}.")
endif()
endif()
# Fatal error out if REQUIRED
if (required)
set(msg "REQUIRED PACKAGE NOT FOUND\n${msg} This package is REQUIRED and you need to install it or adjust CMake configuration in order to continue building ${CMAKE_PROJECT_NAME}.")
message(FATAL_ERROR "${msg}\n${vars}")
endif()
# Otherwise just print a nasty warning
if (NOT quiet)
message(WARNING "WARNING: MISSING PACKAGE\n${msg} This package is NOT REQUIRED and you may ignore this warning but by doing so you may miss some functionality of ${CMAKE_PROJECT_NAME}. \n${vars}")
endif()
endfunction()
-89
View File
@@ -1,89 +0,0 @@
# Source: https://gist.github.com/sivachandran/3a0de157dccef822a230#file-bin2h-cmake
# Added modifications to suit prusaslicer
include(CMakeParseArguments)
# Function to wrap a given string into multiple lines at the given column position.
# Parameters:
# VARIABLE - The name of the CMake variable holding the string.
# AT_COLUMN - The column position at which string will be wrapped.
function(WRAP_STRING)
set(oneValueArgs VARIABLE AT_COLUMN)
cmake_parse_arguments(WRAP_STRING "${options}" "${oneValueArgs}" "" ${ARGN})
string(LENGTH ${${WRAP_STRING_VARIABLE}} stringLength)
math(EXPR offset "0")
while(stringLength GREATER 0)
if(stringLength GREATER ${WRAP_STRING_AT_COLUMN})
math(EXPR length "${WRAP_STRING_AT_COLUMN}")
else()
math(EXPR length "${stringLength}")
endif()
string(SUBSTRING ${${WRAP_STRING_VARIABLE}} ${offset} ${length} line)
set(lines "${lines}\n${line}")
math(EXPR stringLength "${stringLength} - ${length}")
math(EXPR offset "${offset} + ${length}")
endwhile()
set(${WRAP_STRING_VARIABLE} "${lines}" PARENT_SCOPE)
endfunction()
# Function to embed contents of a file as byte array in C/C++ header file(.h). The header file
# will contain a byte array and integer variable holding the size of the array.
# Parameters
# SOURCE_FILE - The path of source file whose contents will be embedded in the header file.
# VARIABLE_NAME - The name of the variable for the byte array. The string "_SIZE" will be append
# to this name and will be used a variable name for size variable.
# HEADER_FILE - The path of header file.
# APPEND - If specified appends to the header file instead of overwriting it
# NULL_TERMINATE - If specified a null byte(zero) will be append to the byte array. This will be
# useful if the source file is a text file and we want to use the file contents
# as string. But the size variable holds size of the byte array without this
# null byte.
# Usage:
# bin2h(SOURCE_FILE "Logo.png" HEADER_FILE "Logo.h" VARIABLE_NAME "LOGO_PNG")
function(BIN2H)
set(options APPEND NULL_TERMINATE ADD_WARNING_TEXT)
set(oneValueArgs SOURCE_FILE VARIABLE_NAME HEADER_FILE)
cmake_parse_arguments(BIN2H "${options}" "${oneValueArgs}" "" ${ARGN})
# reads source file contents as hex string
file(READ ${BIN2H_SOURCE_FILE} hexString HEX)
string(LENGTH ${hexString} hexStringLength)
# appends null byte if asked
if(BIN2H_NULL_TERMINATE)
set(hexString "${hexString}00")
endif()
# wraps the hex string into multiple lines at column 32(i.e. 16 bytes per line)
wrap_string(VARIABLE hexString AT_COLUMN 32)
math(EXPR arraySize "${hexStringLength} / 2")
# adds '0x' prefix and comma suffix before and after every byte respectively
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1, " arrayValues ${hexString})
# removes trailing comma
string(REGEX REPLACE ", $" "" arrayValues ${arrayValues})
# converts the variable name into proper C identifier
string(MAKE_C_IDENTIFIER "${BIN2H_VARIABLE_NAME}" BIN2H_VARIABLE_NAME)
# string(TOUPPER "${BIN2H_VARIABLE_NAME}" BIN2H_VARIABLE_NAME)
# declares byte array and the length variables
set(arrayDefinition "const unsigned char ${BIN2H_VARIABLE_NAME}[] = { ${arrayValues} };")
set(arraySizeDefinition "const size_t ${BIN2H_VARIABLE_NAME}_SIZE = ${arraySize};")
set(warnTxt "")
if (BIN2H_ADD_WARNING_TEXT)
set(warnTxt "/* WARN: This file is auto-generated from ${BIN2H_SOURCE_FILE} */\n")
endif ()
set(declarations "${warnTxt}${arrayDefinition}\n\n${arraySizeDefinition}\n\n")
if(BIN2H_APPEND)
file(APPEND ${BIN2H_HEADER_FILE} "${declarations}")
else()
file(WRITE ${BIN2H_HEADER_FILE} "${declarations}")
endif()
endfunction()
-71
View File
@@ -1,71 +0,0 @@
From b32e63f75046d186d93ee7d627c978f2f7c892f3 Mon Sep 17 00:00:00 2001
From: Steve Dower <steve.dower@python.org>
Date: Fri, 10 Jul 2026 14:04:00 +0100
Subject: [PATCH] [3.13] gh-153438: Update NuGet download URL (GH-153482)
(GH-153515)
gh-153438: Update NuGet download URL (GH-153482)
(cherry picked from commit 106eb532ea3b243423e62a702719e9d3c0e40c16)
(cherry picked from commit 62eb50d65d538dda5164f9c4a097a896454a0d1c)
Co-authored-by: Steve Dower <steve.dower@python.org>
Co-authored-by: Harjoth Khara <harjoth.khara@gmail.com>
---
Doc/using/windows.rst | 6 +++---
.../Build/2026-07-09-22-45-00.gh-issue-153438.Qr7N2p.rst | 2 ++
PCbuild/find_python.bat | 2 +-
Tools/msi/get_externals.bat | 2 +-
4 files changed, 7 insertions(+), 5 deletions(-)
create mode 100644 Misc/NEWS.d/next/Build/2026-07-09-22-45-00.gh-issue-153438.Qr7N2p.rst
diff --git a/Doc/using/windows.rst b/Doc/using/windows.rst
index 2a507675044666..4800789ca2d6cc 100644
--- a/Doc/using/windows.rst
+++ b/Doc/using/windows.rst
@@ -414,9 +414,9 @@ on using nuget. What follows is a summary that is sufficient for Python
developers.
The ``nuget.exe`` command line tool may be downloaded directly from
-``https://aka.ms/nugetclidl``, for example, using curl or PowerShell. With the
-tool, the latest version of Python for 64-bit or 32-bit machines is installed
-using::
+``https://dist.nuget.org/win-x86-commandline/latest/nuget.exe``, for example,
+using curl or PowerShell. With the tool, the latest version of Python for
+64-bit or 32-bit machines is installed using::
nuget.exe install python -ExcludeVersion -OutputDirectory .
nuget.exe install pythonx86 -ExcludeVersion -OutputDirectory .
diff --git a/Misc/NEWS.d/next/Build/2026-07-09-22-45-00.gh-issue-153438.Qr7N2p.rst b/Misc/NEWS.d/next/Build/2026-07-09-22-45-00.gh-issue-153438.Qr7N2p.rst
new file mode 100644
index 00000000000000..edab8e6ba7b259
--- /dev/null
+++ b/Misc/NEWS.d/next/Build/2026-07-09-22-45-00.gh-issue-153438.Qr7N2p.rst
@@ -0,0 +1,2 @@
+Update Windows build and installer tooling and documentation to use the
+current download URL for ``nuget.exe``.
diff --git a/PCbuild/find_python.bat b/PCbuild/find_python.bat
index 0af367a3efafad..9612a2860ef9c3 100644
--- a/PCbuild/find_python.bat
+++ b/PCbuild/find_python.bat
@@ -52,7 +52,7 @@
@set _Py_HOST_PYTHON=%HOST_PYTHON%
@if "%_Py_HOST_PYTHON%"=="" set _Py_HOST_PYTHON=py
@if "%_Py_NUGET%"=="" (set _Py_NUGET=%_Py_EXTERNALS_DIR%\nuget.exe)
-@if "%_Py_NUGET_URL%"=="" (set _Py_NUGET_URL=https://aka.ms/nugetclidl)
+@if "%_Py_NUGET_URL%"=="" (set _Py_NUGET_URL=https://dist.nuget.org/win-x86-commandline/latest/nuget.exe)
@if NOT exist "%_Py_NUGET%" (
@if not "%_Py_Quiet%"=="1" @echo Downloading nuget...
@rem NB: Must use single quotes around NUGET here, NOT double!
diff --git a/Tools/msi/get_externals.bat b/Tools/msi/get_externals.bat
index f6602ce9588ff4..c7c7e9f470bc25 100644
--- a/Tools/msi/get_externals.bat
+++ b/Tools/msi/get_externals.bat
@@ -6,7 +6,7 @@ set HERE=%~dp0
if "%PCBUILD%"=="" (set PCBUILD=%HERE%..\..\PCbuild\)
if "%EXTERNALS_DIR%"=="" (set EXTERNALS_DIR=%HERE%..\..\externals\windows-installer)
if "%NUGET%"=="" (set NUGET=%EXTERNALS_DIR%\..\nuget.exe)
-if "%NUGET_URL%"=="" (set NUGET_URL=https://aka.ms/nugetclidl)
+if "%NUGET_URL%"=="" (set NUGET_URL=https://dist.nuget.org/win-x86-commandline/latest/nuget.exe)
set DO_FETCH=true
set DO_CLEAN=false
+80 -15
View File
@@ -8,20 +8,45 @@ set(_python_url "https://www.python.org/ftp/python/${_python_version}/Python-${_
set(_python_sha256 "c08bc65a81971c1dd5783182826503369466c7e67374d1646519adf05207b684")
set(_patch_cmd "")
if(WIN32)
# Fix python build failure on Windows if python is not available, due to wrong nuget download URL
# See https://github.com/python/cpython/issues/153438
# Patch from https://github.com/python/cpython/pull/153608
# This patch has not been merged to 3.12 yet so we need to apply it manually
#
# Without core.autocrlf=false the patched find_python.bat comes out LF and
# cmd.exe cannot find its goto labels.
set(_patch_cmd git init
&& ${GIT_EXECUTABLE} -c core.autocrlf=false apply --verbose
--ignore-space-change --whitespace=fix
${CMAKE_CURRENT_LIST_DIR}/01-windows-nuget.patch)
# Build-time Python for PCbuild's scripts, pinned because 3.15 cannot run the 3.12 scripts.
set(DEP_PYTHON_HOST "" CACHE FILEPATH
"Python 3.10-3.13 that runs CPython's build scripts; empty downloads a pinned one from nuget.org")
if(DEP_PYTHON_HOST)
execute_process(
COMMAND "${DEP_PYTHON_HOST}" -E -c "import sys; print('%d.%d' % sys.version_info[:2])"
OUTPUT_VARIABLE _python_host_minor
OUTPUT_STRIP_TRAILING_WHITESPACE
RESULT_VARIABLE _python_host_result
)
if(NOT _python_host_result EQUAL 0 OR _python_host_minor VERSION_LESS 3.10 OR _python_host_minor VERSION_GREATER 3.13)
message(FATAL_ERROR "DEP_PYTHON_HOST must be Python 3.10 to 3.13, got '${_python_host_minor}' from ${DEP_PYTHON_HOST}")
endif()
file(TO_NATIVE_PATH "${DEP_PYTHON_HOST}" _python_host)
else()
set(_python_host_version "3.13.16")
if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "ARM64|aarch64")
set(_python_host_package pythonarm64)
set(_python_host_sha256 bfd77aa5b0edff4a7936a3220e8b79e7c78f864ce13e8fa2c835d9946e11694a)
else()
set(_python_host_package pythonx86)
set(_python_host_sha256 c65cf47664b33286dfc5f2a17303668dd59e057869815ed9b270fb3866c1dd15)
endif()
set(_python_host_dir "${CMAKE_CURRENT_BINARY_DIR}/dep_python3_host-prefix/src/dep_python3_host")
ExternalProject_Add(dep_python3_host
URL "https://api.nuget.org/v3-flatcontainer/${_python_host_package}/${_python_host_version}/${_python_host_package}.${_python_host_version}.nupkg"
URL_HASH SHA256=${_python_host_sha256}
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/python3
# Renames the .nupkg to .zip so ExternalProject extracts it.
DOWNLOAD_NAME ${_python_host_package}.${_python_host_version}.zip
SOURCE_DIR "${_python_host_dir}"
CONFIGURE_COMMAND ""
BUILD_COMMAND ""
INSTALL_COMMAND ""
)
file(TO_NATIVE_PATH "${_python_host_dir}/tools/python.exe" _python_host)
set(_python_depends dep_python3_host)
endif()
if(MSVC_VERSION EQUAL 1800)
set(_python_platform_toolset v120)
@@ -63,6 +88,47 @@ if(WIN32)
# release python3xx.lib; Py_DEBUG could not load release plugin modules.
set(_python_pcbuild_config Release)
# CPython's own libraries, fetched here instead of by get_externals.bat, which ignores failed
# downloads. Must match PCbuild/get_externals.bat for _python_version.
set(_python_externals
# repo folder commit sha256 of the archive
source-deps bzip2-1.0.8 05301997b2f9590f49c672cf3dfd3d3dfa7ad521 44b15bede52b32f8673a2976621a847258b041243c003e30f4d9a05214785fe5
source-deps sqlite-3.49.1.0 cb7a3997059739fe9c63b2819aeda7c6907510ef 586fe469463f5b27b749f9eef1938baa8e8a12030341e96160a371356c2d6759
source-deps xz-5.2.5 c6bc0c612605622aaef101a33a751f9de2ecc193 d99882a8f9fd48be0e605e27e3c077e5eaab370f6f52ebcc8ff754480ffdf0bc
source-deps zlib-1.3.1 4dc98e1909830e2bdc2a9cc2236e3c5d5037335b 39d867c083ebc37c77cf03a442f87c0f83db006eb64343bd4a19f1c84c26f50c
bin-deps libffi-3.4.4 94cb9a1c7feb608adf2b9f8fe2dbd6925ffbf90d 6b0f53490e0dd958708bff32df689cf2163c214147fcb8edb76a403fa6a877ca
bin-deps openssl-bin-3.0.16.2 1174fd43d858425c345b16d631716fb98ba492ea b741fd1411a36cb899fee2c39b70a4492f4bc1c6e2301d67df32e9754118a63b
)
set(_python_externals_dir "${CMAKE_CURRENT_BINARY_DIR}/dep_python3_externals")
list(LENGTH _python_externals _python_externals_len)
math(EXPR _python_externals_last "${_python_externals_len} - 1")
foreach(_i RANGE 0 ${_python_externals_last} 4)
math(EXPR _j "${_i} + 1")
math(EXPR _k "${_i} + 2")
math(EXPR _l "${_i} + 3")
list(GET _python_externals ${_i} _ext_repo)
list(GET _python_externals ${_j} _ext_name)
list(GET _python_externals ${_k} _ext_commit)
list(GET _python_externals ${_l} _ext_sha256)
ExternalProject_Add(dep_python3_ext_${_ext_name}
URL "https://github.com/python/cpython-${_ext_repo}/archive/${_ext_commit}.zip"
URL_HASH SHA256=${_ext_sha256}
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/python3
DOWNLOAD_NAME cpython-${_ext_repo}-${_ext_name}.zip
SOURCE_DIR "${_python_externals_dir}/${_ext_name}"
CONFIGURE_COMMAND ""
BUILD_COMMAND ""
INSTALL_COMMAND ""
)
list(APPEND _python_depends dep_python3_ext_${_ext_name})
endforeach()
file(TO_NATIVE_PATH "${_python_externals_dir}" _python_externals_native)
# PCbuild runs PYTHON without -E, so PYTHONHOME and PYTHONPATH are cleared.
set(_python_env_args --unset=PYTHONHOME --unset=PYTHONPATH "PYTHON=${_python_host}"
"EXTERNALS_DIR=${_python_externals_native}"
"GIT_CEILING_DIRECTORIES=<SOURCE_DIR>/..")
# CPython's PCbuild needs a 64-bit-hosted toolchain: find_msbuild.bat picks the
# 32-bit Bin\MSBuild.exe, whose x86 cl.exe/link.exe run out of address space
# (fatal C1002 "out of heap space") building the LTCG-optimized pythoncore.
@@ -71,7 +137,6 @@ if(WIN32)
# then hosts arm64 tools on its own; the amd64 MSBuild still defaults to x86, so
# PreferredToolArchitecture pins it to x64. Scoped to this build step, so
# CPython's own sources stay untouched.
set(_python_env_args "GIT_CEILING_DIRECTORIES=<SOURCE_DIR>/..")
if(CMAKE_GENERATOR_INSTANCE) # empty for non-VS generators (e.g. Ninja)
set(_python_msbuild "${CMAKE_GENERATOR_INSTANCE}/MSBuild/Current/Bin/${_python_msbuild_host}/MSBuild.exe")
if(EXISTS "${_python_msbuild}")
@@ -284,12 +349,12 @@ endif()
ExternalProject_Add(dep_python3
URL "${_python_url}"
URL_HASH SHA256=${_python_sha256}
PATCH_COMMAND ${_patch_cmd}
DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/python3
BUILD_IN_SOURCE ON
CONFIGURE_COMMAND ${_conf_cmd}
BUILD_COMMAND ${_build_cmd}
INSTALL_COMMAND ${_install_cmd}
DEPENDS ${_python_depends}
)
# Python depends on OpenSSL and ZLIB
+66
View File
@@ -0,0 +1,66 @@
# Focus mode (hiding inactive plates) — High Level Design
## Purpose and scope
"Focus mode" is an entry of the canvas menu in the bottom left corner of Prepare. Turned on (eye
lit), the 3D view hides the inactive plates and shows the current plate alone, so a project with
many plates can be worked on one plate at a time. Turned off, every plate is shown. It is a view
setting: it changes nothing in the model, the slice or the project file, and it does not reach
plate thumbnails. The choice is kept in the app config as `focus_mode`, off by default.
Preview needs no such option, as it already draws the current plate alone and has its own plate
toolbar. The assembly view and the Design tab do not lay objects out on plates, so the entry is
disabled there.
## What is shown
The current plate is drawn with its bed, label and icons; the other plates are not drawn at all. The
world axes, which Prepare draws at the corner of the first plate, move to the corner of the current
plate, where Preview draws them.
An object instance is shown when its footprint, the XY bounding box of the convex hulls of its
volumes, overlaps the current plate, or when it is selected. The test is made per instance, so the
parts of an object are shown or hidden together, and a wipe tower follows its plate the same way.
It is geometric rather than the plate membership `PartPlateList` keeps, because an instance stays on
the plate it was first placed on: an object of another plate that reaches over this one, or one too
large for any plate, would otherwise be invisible while it takes room on the current plate. A
selected instance stays visible so that what a selection or a gizmo acts on is never hidden, for
instance an object picked in the object list on another plate.
## Hiding
A hidden object is neither drawn nor picked. `GLCanvas3D::_update_inactive_plates_visibility()`
runs at the start of each frame of Prepare. It clears `GLVolume::is_active` on the volumes of the
hidden instances and deactivates their raycasters, which every render pass, the hover and rectangle
selection, the shadow and center of mass markers already respect. It also deactivates the raycasters
of the other plates, whose icons would otherwise still take clicks where nothing is drawn. The state
persists between frames, so the raycasts made while handling the mouse, such as the anchor of a pan,
see the same scene as the frame.
Other features clear `is_active` too: gizmos hide the objects they do not edit and show them all
again when they close. `GLVolume::on_hidden_plate` records which volumes this update hid, so it only
shows again what it hid itself, and it waits for a gizmo that hides objects to close before doing
so. A volume another feature made active again is hidden again on the next frame. While Focus mode
is off and nothing is left hidden, the update returns at once.
## Switching plates
The other plates cannot be clicked while Focus mode hides them, so a compact counterpart of the Preview
plate toolbar takes their place: a column of plate thumbnails in the top left corner, below the top
toolbar, shown when the project has more than one plate and no gizmo is open. The thumbnails are the
ones the Preview toolbar shows (`PartPlate::thumbnail_data`); the canvas rebuilds its textures when
`Plater::plate_toolbar_image_version()` changes, a counter each consumer tracks on its own so that the
Preview toolbar's dirty flag is left alone. That counter moves after almost every edit, so the
thumbnail of the current plate, the only one edited while the others are hidden, is drawn again each
time, and the other plates' only once an edit to them has invalidated theirs. The current plate has an
accent border, a hovered one the gray border of the Preview toolbar, empty plates are dimmed and the
plate number sits in the corner; the tooltip gives the plate name. A click selects the plate and
clears the selection, as a click on a plate row of the object list does. The column scrolls when it
does not fit above the 3D navigator or the canvas toolbar. Its scrollbar works as the Preview
toolbar's, with which it shares its style: it shows only while the mouse is over the column or drags
the bar, and the mouse wheel scrolls the column either way.
Whatever selects a plate while the others are hidden, the camera moves its target over the new plate
and keeps its angle, distance and height (`PartPlateList::select_plate()`), so the plate comes into
view where the previous one was. A zoom to all plates, as after loading a project or adding a plate,
frames the current plate instead. Turning Focus mode on does the same, so the current plate fills the view.
+1
View File
@@ -215,6 +215,7 @@ src/libslic3r/GCode.cpp
src/libslic3r/GCodeWriter.cpp
src/libslic3r/GCode/ToolOrdering.cpp
src/libslic3r/GCode/SeamPlacer.cpp
src/libslic3r/GCode/GCodeStatistics.cpp
src/libslic3r/ExtrusionEntity.cpp
src/libslic3r/Flow.cpp
src/libslic3r/Format/AMF.cpp
@@ -18,7 +18,7 @@
<script src="../js/plugin-config-ui.js"></script>
<script src="./index.js"></script>
<script src="./plugin-sort.js"></script>
<script src="../js/fuzzy-search.js"></script>
<script src="../../js/fuzzy-search.js"></script>
<script src="./plugin-search.js"></script>
</head>
<body onLoad="OnInit()">
+14 -24
View File
@@ -506,11 +506,6 @@ body
height: 20px;
}
.BtnShortcut
{
padding-right: 18px;
}
.ShortcutBrandIcon
{
width: 20px;
@@ -519,25 +514,6 @@ body
display: block;
}
.ShortcutTextRow
{
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
}
.ShortcutMark
{
width: 14px;
height: 14px;
margin-left: auto;
color: currentColor;
opacity: 0.72;
flex: 0 0 auto;
}
/*--------------------*/
#RightBoard
@@ -548,6 +524,20 @@ body
overflow-y:auto;
}
/*---- Cloud mode: OrcaCloud is a native pane to the right of the app panel, so this page
is reduced to its left nav column (the class is toggled by SetCloudMode in js/home.js). ----*/
body.cloud-mode #RightBoard
{
display: none;
}
body.cloud-mode #LeftBoard
{
/*---- 262px of column plus its 1px separator is the width the app gives the nav. ----*/
height: 100%;
overflow-y: auto;
}
#ContentBoard
{
overflow-y:auto;
+2 -9
View File
@@ -90,16 +90,9 @@
<div class="BtnIcon "><img class="LeftIcon" src="img/i2.png" /></div>
<div data-i18n="recent">Recent</div>
</div>
<div class="BtnItem BtnShortcut" onClick="OpenUrlInLocalBrowser('https://cloud.orcaslicer.com')">
<div class="BtnItem BtnShortcut" onClick="GotoCloud()">
<div class="BtnIcon "><img class="ShortcutBrandIcon" src="../image/logo.png" /></div>
<div class="ShortcutTextRow">
<div>OrcaCloud</div>
<svg class="ShortcutMark" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 3.5H3.5C2.95 3.5 2.5 3.95 2.5 4.5V12.5C2.5 13.05 2.95 13.5 3.5 13.5H11.5C12.05 13.5 12.5 13.05 12.5 12.5V10" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.5 3.5H13.5V8.5" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.5 3.5L7 10" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div>OrcaCloud</div>
</div>
</div>
+32
View File
@@ -145,8 +145,40 @@ function HandleStudio( pVal )
}
}
/*---- OrcaCloud opens as a native pane beside this page, so the shell only switches its
own cloud-mode state and asks the app to show or hide that pane (WebViewPanel::set_cloud_mode). ----*/
var m_CloudMode=false;
function SetCloudMode( bOn )
{
if( m_CloudMode==bOn )
return;
m_CloudMode=bOn;
$("body").toggleClass("cloud-mode", bOn);
if( bOn )
{
$(".BtnItem").removeClass("BtnItemSelected");
$(".BtnShortcut").addClass("BtnItemSelected");
}
}
function GotoCloud()
{
SetCloudMode(true);
SendSimpleCommand("homepage_open_cloud");
}
function GotoMenu( strMenu )
{
/*---- Any board in the right region means leaving the cloud pane. ----*/
if( m_CloudMode )
{
SetCloudMode(false);
SendSimpleCommand("homepage_close_cloud");
}
let MenuList=$(".BtnItem");
let nAll=MenuList.length;
+2 -2
View File
@@ -22,12 +22,12 @@ REQUIRED_DEV_PACKAGES=(
libtool
m4
Mesa-libGL-devel
ninja-build
ninja
openssl-devel
perl-FindBin-Real
texinfo
wayland-protocols-devel
webkit2gtk4-devel
webkitgtk3-devel
wget
libcurl-devel
pkgconf
+16 -1
View File
@@ -144,6 +144,7 @@
#include "libslic3r/ExPolygon.hpp"
#include "libslic3r/Exception.hpp"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/GCode/GCodeStatistics.hpp"
#include "libslic3r/GCode/ToolOrdering.hpp"
#include "libslic3r/ObjectID.hpp"
#include "libslic3r/PresetBundle.hpp"
@@ -207,6 +208,7 @@ std::map<int, std::string> cli_errors = {
{CLI_OBJECT_ORIENT_FAILED, "An error occurred when auto-orienting object(s)."},
{CLI_MODIFIED_PARAMS_TO_PRINTER, "Found modified parameter in printer preset in the 3mf file, which should not be changed."},
{CLI_FILE_VERSION_NOT_SUPPORTED, "Unsupported 3MF version. Please make sure the 3MF file was created with the official version of Bambu Studio, not a beta version."},
{CLI_EXPORT_STATISTICS_ERROR, "Failed exporting the G-code statistics."},
{CLI_NO_SUITABLE_OBJECTS, "The plate has nothing to slice: it is empty, or no object on it is both printable and fully inside the print volume."},
{CLI_VALIDATE_ERROR, "There are some incorrect slicing parameters in the 3mf. Please verify the slicing of all plates in Orca Slicer before uploading."},
{CLI_OBJECTS_PARTLY_INSIDE, "Some objects are located over the boundary of the heated bed."},
@@ -1539,6 +1541,9 @@ int CLI::run(int argc, char **argv)
if (allow_mix_temp_option)
allow_mix_temp = allow_mix_temp_option->value;
const ConfigOptionBool* export_statistics_option = m_config.option<ConfigOptionBool>("export_statistics");
const bool export_statistics = export_statistics_option != nullptr && export_statistics_option->value;
ConfigOptionBool* avoid_extrusion_cali_region_option = m_config.option<ConfigOptionBool>("avoid_extrusion_cali_region");
if (avoid_extrusion_cali_region_option)
avoid_extrusion_cali_region = avoid_extrusion_cali_region_option->value;
@@ -7081,6 +7086,16 @@ int CLI::run(int argc, char **argv)
record_exit_reson(outfile_dir, CLI_GCODE_PATH_IN_UNPRINTABLE_AREA, index + 1, cli_errors[CLI_GCODE_PATH_IN_UNPRINTABLE_AREA], sliced_info);
flush_and_exit(CLI_GCODE_PATH_IN_UNPRINTABLE_AREA);
}
// Without an output directory the G-code is a temporary file, kept only in an exported 3MF.
if (export_statistics && !outfile_dir.empty()) {
try {
export_gcode_statistics(*gcode_result, gcode_statistics_path(outfile));
} catch (const std::exception &ex) {
BOOST_LOG_TRIVIAL(error) << "plate " << index + 1 << ": " << ex.what();
record_exit_reson(outfile_dir, CLI_EXPORT_STATISTICS_ERROR, index + 1, cli_errors[CLI_EXPORT_STATISTICS_ERROR], sliced_info);
flush_and_exit(CLI_EXPORT_STATISTICS_ERROR);
}
}
//outfile_final = (dynamic_cast<Print*>(print))->print_statistics().finalize_output_path(outfile);
@@ -7209,7 +7224,7 @@ int CLI::run(int argc, char **argv)
std::vector<ThumbnailData *> thumbnails, no_light_thumbnails, top_thumbnails, pick_thumbnails;
std::vector<PlateBBoxData*> plate_bboxes;
PlateDataPtrs plate_data_list;
partplate_list.store_to_3mf_structure(plate_data_list);
partplate_list.store_to_3mf_structure(plate_data_list, true, -1, export_statistics);
if (sliced_plate == -1) {
for (int i = 0; i < plate_data_list.size(); i++) {
+3
View File
@@ -399,6 +399,9 @@ void AppConfig::set_defaults()
if (get("show_plate_gridlines").empty())
set_bool("show_plate_gridlines", true);
if (get("focus_mode").empty())
set_bool("focus_mode", false);
if (get("show_outline").empty())
set_bool("show_outline", false);
+2
View File
@@ -265,6 +265,8 @@ set(lisbslic3r_sources
GCode/FanMover.hpp
GCode/GCodeProcessor.cpp
GCode/GCodeProcessor.hpp
GCode/GCodeStatistics.cpp
GCode/GCodeStatistics.hpp
GCode/ElegooGCodeProcessorHelper.cpp
GCode.hpp
GCode/PchipInterpolatorHelper.cpp
+10
View File
@@ -9053,6 +9053,16 @@ bool _BBS_3MF_Exporter::_add_gcode_file_to_archive(mz_zip_archive& archive, cons
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ":" <<__LINE__ << boost::format(", store %1% to 3mf %2%\n") % src_gcode_file % gcode_in_3mf;
}
});
for (const PlateData *plate_data : plate_data_list2) {
if (plate_data->statistics.empty())
continue;
const std::string statistics_in_3mf = (boost::format(STATISTICS_FILE_FORMAT) % (plate_data->plate_index + 1)).str();
if (!mz_zip_writer_add_mem(&archive, statistics_in_3mf.c_str(), plate_data->statistics.data(), plate_data->statistics.size(),
MZ_DEFAULT_COMPRESSION)) {
add_error("Unable to add statistics file to archive");
result = false;
}
}
return result;
}
+5
View File
@@ -32,6 +32,7 @@ struct ThumbnailData;
#define PLATE_THUMBNAIL_SMALL_HEIGHT 128
#define GCODE_FILE_FORMAT "Metadata/plate_%1%.gcode"
#define STATISTICS_FILE_FORMAT "Metadata/plate_%1%.statistics.json"
#define THUMBNAIL_FILE_FORMAT "Metadata/plate_%1%.png"
#define NO_LIGHT_THUMBNAIL_FILE_FORMAT "Metadata/plate_no_light_%1%.png"
#define TOP_FILE_FORMAT "Metadata/top_%1%.png"
@@ -100,6 +101,8 @@ struct PlateData
std::string nozzle_volume_types;
std::string gcode_file;
std::string gcode_file_md5;
// The G-code's statistics as JSON, stored with the G-code when not empty.
std::string statistics;
std::string thumbnail_file;
std::string no_light_thumbnail_file;
ThumbnailData plate_thumbnail;
@@ -174,6 +177,8 @@ enum class SaveStrategy
// Keep this separate from SplitModel, which uses the 0x1000 bit as part of its
// production-extension value.
MinimalPublished = 1 << 13,
// With WithGcode, also the G-code's statistics.
WithStatistics = 1 << 14,
SplitModel = 0x1000 | ProductionExt,
Encrypted = SecureContentExt | SplitModel,
+20 -11
View File
@@ -2625,8 +2625,15 @@ static void set_mass_locator(GCodeProcessor &processor, const Print &print)
}
Object &o = objects.emplace_back(Object{ object, int(object_masses.size()), count, bodies_total, {}, std::move(bodies), {}, {} });
object_masses.resize(object_masses.size() + object->instances().size());
for (size_t instance = 0; instance < object->instances().size(); ++instance)
object_masses[o.first_instance + instance].assembly = assembly;
const ModelInstancePtrs &model_instances = object->model_object()->instances;
for (size_t instance = 0; instance < object->instances().size(); ++instance) {
GCodeProcessorResult::ObjectMass &mass = object_masses[o.first_instance + instance];
mass.assembly = assembly;
mass.name = object->model_object()->name;
// Copies of another rotation or scale slice as objects of their own, so number them in the model object.
mass.instance = int(std::find(model_instances.begin(), model_instances.end(), object->instances()[instance].model_instance) -
model_instances.begin());
}
bodies_total += int(count * object->instances().size());
for (const Layer *layer : layers) {
o.print_zs.emplace_back(layer->print_z);
@@ -3563,6 +3570,13 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato
print.throw_if_canceled();
// Taken before the object loop, which reassigns initial_extruder_id when printing by object.
const int compat_first_layer_bed_temp = m_config.bed_temperature_formula == BedTempFormula::btfHighestTemp ?
get_highest_bed_temperature(true, print) :
get_bed_temperature(initial_extruder_id, true, print.config().curr_bed_type);
const int compat_first_layer_nozzle_temp =
print.config().nozzle_temperature_initial_layer.get_at(get_filament_config_index((int) initial_extruder_id));
int extruder_id = get_extruder_id(initial_extruder_id);
m_cooling_buffer = make_unique<CoolingBuffer>(*this);
@@ -4153,12 +4167,8 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato
file.write(full_config);
// SoftFever: write compatiple image
int first_layer_bed_temperature = get_bed_temperature(0, true, print.config().curr_bed_type);
file.write_format("; first_layer_bed_temperature = %d\n",
first_layer_bed_temperature);
file.write_format(
"; first_layer_temperature = %d\n",
print.config().nozzle_temperature_initial_layer.get_at(0));
file.write_format("; first_layer_bed_temperature = %d\n", compat_first_layer_bed_temp);
file.write_format("; first_layer_temperature = %d\n", compat_first_layer_nozzle_temp);
file.write("; CONFIG_BLOCK_END\n\n");
} else if (thumbnail_cb != nullptr) {
// generate the thumbnails
@@ -4743,10 +4753,9 @@ void GCode::_do_export(Print& print, GCodeOutputStream &file, ThumbnailsGenerato
file.write(full_config);
// SoftFever: write compatiple info
int first_layer_bed_temperature = get_bed_temperature(0, true, print.config().curr_bed_type);
file.write_format("; first_layer_bed_temperature = %d\n", first_layer_bed_temperature);
file.write_format("; first_layer_bed_temperature = %d\n", compat_first_layer_bed_temp);
file.write_format("; bed_shape = %s\n", print.full_print_config().opt_serialize("printable_area").c_str());
file.write_format("; first_layer_temperature = %d\n", print.config().nozzle_temperature_initial_layer.get_at(0));
file.write_format("; first_layer_temperature = %d\n", compat_first_layer_nozzle_temp);
file.write_format("; first_layer_height = %.3f\n", print.config().initial_layer_print_height.value);
//SF TODO
+3
View File
@@ -298,6 +298,9 @@ inline constexpr float DEFAULT_FILAMENT_DENSITY = 1.245f;
BoundingBoxf3 box;
// Of an object, whether it is an assembly.
bool assembly{ false };
// Of an object instance, its model object's name and its index among that object's instances.
std::string name;
int instance{ -1 };
Sum total() const { return printed_up_to_layer.empty() ? Sum{} : printed_up_to_layer.back(); }
void add(const Sum &sum, const BoundingBoxf3 &extent, size_t layer);
+200
View File
@@ -0,0 +1,200 @@
#include "GCodeStatistics.hpp"
#include "GCodeProcessor.hpp"
#include "libslic3r/Exception.hpp"
#include "libslic3r/ExtrusionEntity.hpp"
#include "libslic3r/I18N.hpp"
#include "libslic3r/format.hpp"
#include "libslic3r/libslic3r.h"
#include "libslic3r_version.h"
#include <boost/filesystem/path.hpp>
#include <boost/nowide/fstream.hpp>
#include <nlohmann/json.hpp>
#include <cmath>
#include <cstddef>
#include <ios>
#include <map>
#include <math.h>
#include <string>
#include <vector>
namespace Slic3r {
namespace {
using json = nlohmann::ordered_json;
double rounded(double value, int decimals)
{
const double scale = std::pow(10., decimals);
return std::round(value * scale) / scale;
}
// As the object masses weigh it, at the default density for a filament without one.
double weight_of(const GCodeProcessorResult &result, size_t filament, double volume)
{
const bool has_density = filament < result.filament_densities.size() && result.filament_densities[filament] > 0.f;
// mm³ at g/cm³ weigh mg.
return 0.001 * volume * (has_density ? result.filament_densities[filament] : DEFAULT_FILAMENT_DENSITY);
}
struct Usage
{
double time{ 0. };
double volume{ 0. };
double weight{ 0. };
void add(const Usage &other)
{
time += other.time;
volume += other.volume;
weight += other.weight;
}
json to_json() const { return json{ { "time", rounded(time, 3) }, { "volume", rounded(volume, 3) }, { "weight", rounded(weight, 4) } }; }
};
json features_json(const std::map<ExtrusionRole, Usage> &features)
{
json out = json::object();
for (const auto &[role, usage] : features)
out[ExtrusionEntity::role_to_string(role)] = usage.to_json();
return out;
}
} // namespace
std::string gcode_statistics_json(const GCodeProcessorResult &result)
{
constexpr size_t normal = static_cast<size_t>(PrintEstimatedStatistics::ETimeMode::Normal);
struct Layer
{
float z{ 0.f };
double travel_time{ 0. };
Usage total;
std::map<ExtrusionRole, Usage> features;
};
std::vector<Layer> layers;
std::map<ExtrusionRole, Usage> features;
double travel_time = 0.;
// As the preview sums them: a layer takes the time of all its moves, a feature that of its extrusions.
for (const GCodeProcessorResult::MoveVertex &move : result.moves) {
if (layers.size() <= move.layer_id)
layers.resize(move.layer_id + 1);
Layer &layer = layers[move.layer_id];
const double time = move.time[normal];
layer.total.time += time;
if (move.type == EMoveType::Travel) {
layer.travel_time += time;
travel_time += time;
}
// A vertex added inside a move to show its acceleration repeats the move's extrusion.
if (move.type != EMoveType::Extrude || move.internal_only)
continue;
if (move.extrusion_role != erCustom)
layer.z = move.position.z();
Usage usage;
usage.time = time;
usage.volume = double(move.delta_extruder) * M_PI * sqr(0.5 * double(result.filament_diameters[move.extruder_id]));
usage.weight = weight_of(result, move.extruder_id, usage.volume);
layer.total.volume += usage.volume;
layer.total.weight += usage.weight;
layer.features[move.extrusion_role].add(usage);
features[move.extrusion_role].add(usage);
}
// As the preview's filament table, which counts the flush too.
const PrintEstimatedStatistics &stats = result.print_statistics;
double volume = 0., weight = 0., cost = 0.;
json filaments_json = json::array();
for (const auto &entry : stats.total_volumes_per_extruder) {
const size_t filament = entry.first;
const auto use = [&result, filament](const std::map<size_t, double> &volumes) {
const auto it = volumes.find(filament);
const double used = it == volumes.end() ? 0. : it->second;
return json{ { "volume", rounded(used, 3) }, { "weight", rounded(weight_of(result, filament, used), 4) } };
};
const double filament_weight = weight_of(result, filament, entry.second);
// Priced per kilogram.
const double filament_cost = filament < result.filament_costs.size() ? 0.001 * filament_weight * result.filament_costs[filament] : 0.;
volume += entry.second;
weight += filament_weight;
cost += filament_cost;
filaments_json.push_back(json{ { "id", filament + 1 },
{ "length", rounded(entry.second / (M_PI * sqr(0.5 * double(result.filament_diameters[filament]))), 2) },
{ "volume", rounded(entry.second, 3) },
{ "weight", rounded(filament_weight, 4) },
{ "cost", rounded(filament_cost, 2) },
{ "model", use(stats.model_volumes_per_extruder) },
{ "support", use(stats.support_volumes_per_extruder) },
{ "prime_tower", use(stats.wipe_tower_volumes_per_extruder) },
{ "flush", use(stats.flush_per_filament) } });
}
json summary = json{ { "time", rounded(stats.modes[normal].time, 3) },
{ "prepare_time", rounded(stats.modes[normal].prepare_time, 3) },
{ "travel_time", rounded(travel_time, 3) },
{ "travel_distance", rounded(stats.total_travel_distance, 2) },
{ "layer_count", layers.size() },
{ "volume", rounded(volume, 3) },
{ "weight", rounded(weight, 4) },
{ "filament_cost", rounded(cost, 2) },
{ "filament_changes", stats.total_filament_changes },
{ "filaments", filaments_json },
{ "features", features_json(features) } };
json layers_json = json::array();
for (const Layer &layer : layers)
layers_json.push_back(json{ { "z", rounded(layer.z, 3) },
{ "time", rounded(layer.total.time, 3) },
{ "travel_time", rounded(layer.travel_time, 3) },
{ "volume", rounded(layer.total.volume, 3) },
{ "weight", rounded(layer.total.weight, 4) },
{ "features", features_json(layer.features) } });
// The supports of an instance are numbered as the instance.
json objects_json = json::array();
for (size_t i = 0; i < result.object_masses.size(); ++i) {
const GCodeProcessorResult::ObjectMass &object = result.object_masses[i];
const GCodeProcessorResult::ObjectMass::Sum part = object.total();
const GCodeProcessorResult::ObjectMass::Sum support = i < result.support_masses.size() ? result.support_masses[i].total() :
GCodeProcessorResult::ObjectMass::Sum{};
// In mg, as volumes in mm³ at g/cm³.
objects_json.push_back(json{ { "name", object.name },
{ "instance", object.instance },
{ "volume", rounded(part.volume, 3) },
{ "weight", rounded(0.001 * part.mass, 4) },
{ "support_volume", rounded(support.volume, 3) },
{ "support_weight", rounded(0.001 * support.mass, 4) } });
}
// The version goes up only when a key is removed, renamed or changes meaning.
const json out = json{ { "version", 1 },
{ "generator", std::string(SLIC3R_APP_NAME) + " " + SoftFever_VERSION },
{ "units", json{ { "time", "s" }, { "length", "mm" }, { "volume", "mm3" }, { "weight", "g" } } },
{ "summary", summary },
{ "layers", layers_json },
{ "objects", objects_json } };
return out.dump(2);
}
std::string gcode_statistics_path(const std::string &gcode_path)
{
return boost::filesystem::path(gcode_path).replace_extension(".statistics.json").string();
}
void export_gcode_statistics(const GCodeProcessorResult &result, const std::string &path)
{
// Binary, so the file reads the same on every platform and inside a 3MF.
boost::nowide::ofstream file(path, std::ios::out | std::ios::binary | std::ios::trunc);
file << gcode_statistics_json(result);
file.close();
if (file.fail())
throw ExportError(format(_u8L("Failed to write the G-code statistics to %1%."), path));
}
} // namespace Slic3r
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include <string>
namespace Slic3r {
struct GCodeProcessorResult;
// The statistics of a plate's G-code as JSON, in seconds, millimeters and grams: totals, per layer and per object instance.
std::string gcode_statistics_json(const GCodeProcessorResult &result);
// Where the statistics of the G-code at gcode_path are written.
std::string gcode_statistics_path(const std::string &gcode_path);
// Throws ExportError when the file cannot be written.
void export_gcode_statistics(const GCodeProcessorResult &result, const std::string &path);
} // namespace Slic3r
+7
View File
@@ -13040,6 +13040,13 @@ CLIMiscConfigDef::CLIMiscConfigDef()
def->cli_params = "dir";
def->set_default_value(new ConfigOptionString());
def = this->add("export_statistics", coBool);
def->label = L("Export statistics");
def->tooltip = L("Write the statistics of each sliced plate as JSON, as plate_N.statistics.json next to its G-code in the output "
"directory and inside the 3MF of --export-3mf: print time, filament and feature totals, and the same per layer "
"and per object.");
def->set_default_value(new ConfigOptionBool(false));
def = this->add("debug", coInt);
def->label = L("Debug level");
def->tooltip = L("Sets debug logging level. 0:fatal, 1:error, 2:warning, 3:info, 4:debug, 5:trace\n");
+1
View File
@@ -66,6 +66,7 @@
#define CLI_OBJECT_ORIENT_FAILED -22
#define CLI_MODIFIED_PARAMS_TO_PRINTER -23
#define CLI_FILE_VERSION_NOT_SUPPORTED -24
#define CLI_EXPORT_STATISTICS_ERROR -25
#define CLI_NO_SUITABLE_OBJECTS -50
+5
View File
@@ -687,6 +687,8 @@ set(SLIC3R_GUI_SOURCES
plugin/host/PluginHostUi.hpp
plugin/host/PluginPages.cpp
plugin/host/PluginPages.hpp
plugin/host/PluginResourceTextures.cpp
plugin/host/PluginResourceTextures.hpp
plugin/CloudPluginService.cpp
plugin/CloudPluginService.hpp
plugin/PluginFsUtils.cpp
@@ -710,6 +712,9 @@ set(SLIC3R_GUI_SOURCES
plugin/pluginTypes/pages/PagesPluginCapability.hpp
plugin/pluginTypes/pages/PagesPluginCapability.cpp
plugin/pluginTypes/pages/PagesPluginCapabilityTrampoline.hpp
plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp
plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.cpp
plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapabilityTrampoline.hpp
plugin/pluginTypes/script/ScriptPluginCapability.hpp
plugin/pluginTypes/script/ScriptPluginCapability.cpp
plugin/pluginTypes/script/ScriptPluginCapabilityTrampoline.hpp
+3
View File
@@ -297,6 +297,7 @@ GLVolume::GLVolume(float r, float g, float b, float a)
, printable(true)
, visible(true)
, is_active(true)
, on_hidden_plate(false)
, zoom_to_volumes(true)
, shader_outside_printer_detection_enabled(false)
, is_outside(false)
@@ -824,6 +825,8 @@ bool GLVolume::is_below_printbed() const
void GLVolume::render_sinking_contours()
{
if (!is_active)
return;
m_sinking_contours.render();
}
+2
View File
@@ -211,6 +211,8 @@ public:
bool visible : 1;
// Whether or not this volume is active for rendering
bool is_active : 1;
// Whether or not is_active was cleared because the other plates are hidden
bool on_hidden_plate : 1;
// Whether or not to use this volume when applying zoom_to_volumes()
bool zoom_to_volumes : 1;
// Wheter or not this volume is enabled for outside print volume detection in shader.
@@ -6,6 +6,7 @@
#include <cstddef>
#include <exception>
#include "libslic3r/AppConfig.hpp"
#include "libslic3r/Config.hpp"
#include <cstdint>
#include <functional>
@@ -57,6 +58,7 @@
#include "libslic3r_version.h"
#include "slic3r/GUI/Plater.hpp"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/GCode/GCodeStatistics.hpp"
#include "libslic3r/PresetBundle.hpp"
#include "slic3r/GUI/PartPlate.hpp"
#include "slic3r/Utils/PrintHost.hpp"
@@ -781,6 +783,7 @@ void BackgroundSlicingProcess::schedule_export(const std::string& path, bool exp
this->invalidate_step(bspsGCodeFinalize);
m_export_path = path;
m_export_path_on_removable_media = export_path_on_removable_media;
m_export_statistics = GUI::wxGetApp().app_config->get_bool("export_statistics");
}
void BackgroundSlicingProcess::schedule_upload(Slic3r::PrintHostJob upload_job)
@@ -906,6 +909,8 @@ void BackgroundSlicingProcess::finalize_gcode()
BOOST_LOG_TRIVIAL(error) << "Unexpected fail code(" << (int) copy_ret_val << ") durring copy_file() to " << export_path << ".";
break;
}
if (m_export_statistics)
export_gcode_statistics(*m_gcode_result, gcode_statistics_path(export_path));
m_print->set_status(100, GUI::format(_L("G-code file exported to %1%"), export_path));
}
@@ -953,6 +958,8 @@ void BackgroundSlicingProcess::export_gcode()
// throw Slic3r::ExportError(_utf8(L("Unknown error when exporting G-code.")));
break;
}
if (m_export_statistics)
export_gcode_statistics(*m_gcode_result, gcode_statistics_path(export_path));
// BBS
auto evt = new wxCommandEvent(m_event_export_finished_id, GUI::wxGetApp().mainframe->m_plater->GetId());
@@ -258,6 +258,8 @@ private:
// but once set, it cannot be re-set.
std::string m_export_path;
bool m_export_path_on_removable_media = false;
// Read from the preferences when the export is scheduled, as the background thread may not read them.
bool m_export_statistics = false;
// Print host upload job to schedule after slicing is complete, used by schedule_upload(),
// empty by default (ie. no upload to schedule)
PrintHostJob m_upload_job;
+231 -31
View File
@@ -1679,6 +1679,7 @@ GLCanvas3D::~GLCanvas3D()
m_sel_plate_toolbar.del_all_item();
m_sel_plate_toolbar.del_stats_item();
m_plate_switcher_items.clear();
}
void GLCanvas3D::post_event(wxEvent &&event)
@@ -2360,6 +2361,8 @@ void GLCanvas3D::zoom_to_gcode()
void GLCanvas3D::zoom_to_plate(int plate_idx)
{
BoundingBoxf3 box;
if (plate_idx == REQUIRES_ZOOM_TO_ALL_PLATE && _hides_inactive_plates())
plate_idx = REQUIRES_ZOOM_TO_CUR_PLATE;
if (plate_idx == REQUIRES_ZOOM_TO_ALL_PLATE) {
box = wxGetApp().plater()->get_partplate_list().get_bounding_box();
box.min.z() = 0.0;
@@ -2416,12 +2419,22 @@ float GLCanvas3D::get_collapse_toolbar_height() const
}
// The bottom-left corner: the 3D navigator, then the column of round canvas buttons beside it, in
// units of the toolbar scale. Shared by _render_3d_navigator, _render_canvas_toolbar and
// get_canvas_toolbar_right, so an overlay kept clear of the corner follows its layout.
// units of the toolbar scale. Shared by _render_3d_navigator, _render_canvas_toolbar,
// get_canvas_toolbar_right and canvas_corner_height, so an overlay kept clear of the corner follows its layout.
static constexpr float NAVIGATOR_SIZE = 128.f;
static constexpr float CANVAS_TOOLBAR_MARGIN = 10.f; // off the canvas edge, when there is no navigator
static constexpr float CANVAS_TOOLBAR_PADDING = 2.f;
static constexpr float CANVAS_TOOLBAR_BUTTON = 36.f;
static constexpr float CANVAS_TOOLBAR_SPACING = 6.f;
// What the corner takes at the left edge of the canvas: the 3D navigator, or else the canvas toolbar.
static float canvas_corner_height()
{
if (wxGetApp().show_3d_navigator())
return NAVIGATOR_SIZE;
const int buttons = wxGetApp().show_canvas_zoom_button() ? 3 : 2;
return CANVAS_TOOLBAR_MARGIN + 2.f * CANVAS_TOOLBAR_PADDING + buttons * CANVAS_TOOLBAR_BUTTON + (buttons - 1) * CANVAS_TOOLBAR_SPACING;
}
float GLCanvas3D::get_canvas_toolbar_right() const
{
@@ -2546,6 +2559,9 @@ void GLCanvas3D::_render_frame(bool scene_dirty, bool only_init)
wxGetApp().imgui()->new_frame();
if (_update_inactive_plates_visibility())
scene_dirty = true;
if (m_picking_enabled && !m_benchmarking) {
if (m_rectangle_selection.is_dragging())
// picking pass using rectangle selection
@@ -7669,7 +7685,6 @@ void GLCanvas3D::_update_select_plate_toolbar_stats_item(bool force_selected) {
bool GLCanvas3D::_update_imgui_select_plate_toolbar()
{
bool result = true;
if (!m_sel_plate_toolbar.is_enabled()) {
return false;
}
@@ -7694,19 +7709,13 @@ bool GLCanvas3D::_update_imgui_select_plate_toolbar()
PartPlateList& plate_list = p_plater->get_partplate_list();
for (int i = 0; i < plate_list.get_plate_count(); i++) {
IMToolbarItem* item = new IMToolbarItem();
PartPlate* plate = plate_list.get_plate(i);
if (plate && plate->thumbnail_data.is_valid()) {
PartPlate* plate = plate_list.get_plate(i);
item->image_data = plate->thumbnail_data.pixels;
item->image_width = plate->thumbnail_data.width;
item->image_height = plate->thumbnail_data.height;
result = item->generate_texture();
}
if (PartPlate* plate = plate_list.get_plate(i))
item->set_thumbnail(plate->thumbnail_data);
m_sel_plate_toolbar.m_items.push_back(item);
}
p_plater->clear_plate_toolbar_image_dirty();
m_sel_plate_toolbar.is_display_scrollbar = false;
return result;
return true;
}
//BBS: GUI refactor
@@ -8901,11 +8910,75 @@ void GLCanvas3D::_render_bed(const Transform3d& view_matrix, const Transform3d&
const Vec2d bc = m_bed.build_volume().bed_center();
m_bed.set_axes_origin(Vec3d(bc.x(), bc.y(), 0.0));
} else {
m_bed.set_axes_mode(m_main_toolbar.is_enabled());
// With the other plates hidden, the axes go to the current plate as in Preview.
m_bed.set_axes_mode(m_main_toolbar.is_enabled() && !_hides_inactive_plates());
}
m_bed.render(*this, view_matrix, projection_matrix, bottom, scale_factor, show_axes);
}
bool GLCanvas3D::_hides_inactive_plates() const
{
return m_canvas_type == ECanvasType::CanvasView3D && !m_design_canvas && wxGetApp().focus_mode();
}
bool GLCanvas3D::_update_inactive_plates_visibility()
{
const bool hide = _hides_inactive_plates();
if (!hide && !m_inactive_plates_hidden)
return false;
PartPlateList& plate_list = wxGetApp().plater()->get_partplate_list();
PartPlate* plate = hide ? plate_list.get_curr_plate() : nullptr;
struct Instance
{
BoundingBoxf3 box;
bool selected{ false };
};
std::map<std::pair<int, int>, Instance> instances;
if (plate != nullptr)
for (const GLVolume* volume : m_volumes.volumes) {
Instance& instance = instances[{ volume->object_idx(), volume->instance_idx() }];
instance.box.merge(volume->transformed_convex_hull_bounding_box());
instance.selected |= volume->selected;
}
// A gizmo that hides the other objects shows them all again when it closes.
const bool keep_hidden = m_gizmos.is_hiding_instances();
bool changed = false;
bool any_hidden = false;
std::vector<bool> shown_again(m_volumes.volumes.size(), false);
for (size_t i = 0; i < m_volumes.volumes.size(); ++i) {
GLVolume* volume = m_volumes.volumes[i];
const Instance* instance = plate != nullptr ? &instances[{ volume->object_idx(), volume->instance_idx() }] : nullptr;
if (instance != nullptr && !instance->selected && instance->box.defined && !plate->get_bounding_box().overlap(instance->box)) {
changed |= volume->is_active;
volume->is_active = false;
volume->on_hidden_plate = true;
} else if (volume->on_hidden_plate && !keep_hidden) {
volume->is_active = true;
volume->on_hidden_plate = false;
shown_again[i] = true;
changed = true;
}
any_hidden |= volume->on_hidden_plate;
}
if (auto* raycasters = get_raycasters_for_picking(SceneRaycaster::EType::Volume))
for (const std::shared_ptr<SceneRaycasterItem>& item : *raycasters) {
const int i = SceneRaycaster::decode_id(SceneRaycaster::EType::Volume, item->get_id());
if (0 <= i && i < int(m_volumes.volumes.size()) && (m_volumes.volumes[i]->on_hidden_plate || shown_again[i]))
item->set_active(shown_again[i]);
}
if (auto* raycasters = get_raycasters_for_picking(SceneRaycaster::EType::Bed))
for (const std::shared_ptr<SceneRaycasterItem>& item : *raycasters)
item->set_active(!hide || SceneRaycaster::decode_id(SceneRaycaster::EType::Bed, item->get_id()) / PartPlate::GRABBER_COUNT ==
plate_list.get_curr_plate_index());
m_inactive_plates_hidden = hide || any_hidden;
return changed;
}
void GLCanvas3D::_render_platelist(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_current, bool only_body, int hover_id, bool render_cali, bool show_grid)
{
PartPlateList& plate_list = wxGetApp().plater()->get_partplate_list();
@@ -8914,7 +8987,7 @@ void GLCanvas3D::_render_platelist(const Transform3d& view_matrix, const Transfo
PartPlate* curr_plate = plate_list.get_curr_plate();
const Transform3d plate_view_matrix = m_design_canvas && curr_plate != nullptr ?
Transform3d(view_matrix * Geometry::translation_transform(-curr_plate->get_origin())) : view_matrix;
plate_list.render(plate_view_matrix, projection_matrix, bottom, only_current, only_body, hover_id, render_cali, show_grid, !m_plate_chrome_enabled);
plate_list.render(plate_view_matrix, projection_matrix, bottom, only_current, only_body, hover_id, render_cali, show_grid, !m_plate_chrome_enabled, _hides_inactive_plates());
}
BoundingBoxf3 GLCanvas3D::_current_plate_box() const
@@ -9932,6 +10005,7 @@ void GLCanvas3D::_render_overlays()
//BBS: GUI refactor: GLToolbar
_render_imgui_select_plate_toolbar();
_render_plate_switcher();
_render_return_toolbar();
// BBS
//_render_view_toolbar();
@@ -10200,6 +10274,35 @@ void GLCanvas3D::_render_main_toolbar()
m_main_toolbar.render_arrow(*this, m_toolbar_highlighter.m_toolbar_item);
}
// The scrollbar of the plate toolbars of Preview and Prepare, in units of the toolbar scale.
static constexpr float PLATE_TOOLBAR_SCROLLBAR = 14.f;
// Pushes 4 colors and 1 style var.
static void push_plate_toolbar_scrollbar_style(bool is_dark, float scrollbar_size)
{
const ImVec4 grab(0.77f, 0.77f, 0.77f, is_dark ? .6f : 1.f);
// A background with opacity over the window's would make a second color.
ImGui::PushStyleColor(ImGuiCol_ScrollbarBg, ImVec4(0.f, 0.f, 0.f, 0.f));
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrab, grab);
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabHovered, grab);
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabActive, grab);
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, scrollbar_size);
}
// The border of a hovered plate in the plate toolbars, when it is not the current one.
static ImVec4 plate_toolbar_hover_color(bool is_dark)
{
return ImVec4(0.67f, 0.67f, 0.67f, is_dark ? .6f : 1.f);
}
// Not IsWindowHovered(), which is false while a button is held down and would drop the bar.
static bool plate_toolbar_keeps_scrollbar(bool shown, float scrollbar_size)
{
const ImVec2 pos = ImGui::GetWindowPos();
return ImGui::IsMouseHoveringRect(pos, pos + ImGui::GetWindowSize() + ImVec2(shown ? scrollbar_size : 0.f, 0.f), !shown) ||
(ImGui::GetIO().MouseDown[0] && GImGui->ActiveId != 0 && GImGui->ActiveIdWindow == ImGui::GetCurrentWindow());
}
//BBS: GUI refactor: GLToolbar adjust
//when rendering, {0, 0} is at the center, {-0.5, 0.5} at the left-up
void GLCanvas3D::_render_imgui_select_plate_toolbar()
@@ -10330,16 +10433,15 @@ void GLCanvas3D::_render_imgui_select_plate_toolbar()
int item_count = m_sel_plate_toolbar.m_items.size() + (m_sel_plate_toolbar.show_stats_item ? 1 : 0);
float window_height_calc = (item_count * (button_height + (margin_size + button_margin) * 2.0f) + (item_count - 1) * ImGui::GetStyle().ItemSpacing.y + ImGui::GetStyle().WindowPadding.y * 2.0f);
bool show_scroll = m_sel_plate_toolbar.is_display_scrollbar && (window_height_calc > window_height_max);
float scrollbar_size = 14.0f * f_scale;
float scrollbar_size = PLATE_TOOLBAR_SCROLLBAR * f_scale;
float window_height = std::min(window_height_calc, window_height_max);
float window_width = button_width + (margin_size + button_margin + ImGui::GetStyle().WindowPadding.x) * 2 + (show_scroll ? scrollbar_size : 0);
ImVec4 window_bg = m_is_dark ? ImVec4(.13f, .13f, .15f, .5f) : ImVec4(1.f, 1.f, 1.f, .7f);
ImVec4 button_active = ImGuiWrapper::COL_ORCA; // ORCA: Use orca color for selected sliced plate border
ImVec4 button_hover = ImVec4(0.67f, 0.67f, 0.67, m_is_dark ? .6f : 1.0f);
ImVec4 button_hover = plate_toolbar_hover_color(m_is_dark);
ImVec4 orca_active = m_is_dark ? ImGuiWrapper::COL_ORCA_DARK : ImGuiWrapper::COL_ORCA;
ImVec4 orca_hover = m_is_dark ? ImGuiWrapper::COL_ORCA_HOVER_DARK : ImGuiWrapper::COL_ORCA_HOVER;
ImVec4 scroll_col = ImVec4(0.77f, 0.77f, 0.77f, m_is_dark ? .6f : 1.0f);
ImU32 plate_bg = m_is_dark ? IM_COL32(255, 255, 255, 10) : IM_COL32(0, 0, 0, 10);
ImU32 plate_dim = m_is_dark ? IM_COL32(30, 30, 30, 100) : IM_COL32(0, 0, 0, 50);
float button_radius = 1.0f * f_scale;
@@ -10347,14 +10449,10 @@ void GLCanvas3D::_render_imgui_select_plate_toolbar()
//use white text as the background switch to black
ImGui::PushStyleColor(ImGuiCol_Text, m_is_dark ? ImVec4(.9f, .9f, .9f, 1) : ImVec4(.3f, .3f, .3f, 1)); // ORCA Plate number text > Add support for dark mode
ImGui::PushStyleColor(ImGuiCol_WindowBg, window_bg);
ImGui::PushStyleColor(ImGuiCol_ScrollbarBg, ImVec4(0.f, 0.f, 0.f, 0.f)); // ORCA using background color with opacity creates a second color. This prevents secondary color
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabActive, scroll_col);
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabHovered, scroll_col);
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrab, scroll_col);
ImGui::PushStyleColor(ImGuiCol_ButtonActive, button_active);
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, button_hover);
push_plate_toolbar_scrollbar_style(m_is_dark, scrollbar_size);
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, scrollbar_size);
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, show_scroll ? (6.f * f_scale) : (button_radius + margin_size + frame_padding + ImGui::GetStyle().WindowPadding.x));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, button_radius + margin_size);
@@ -10625,18 +10723,109 @@ void GLCanvas3D::_render_imgui_select_plate_toolbar()
ImGui::PopStyleColor(8);
ImGui::PopStyleVar(7);
//ORCA ImGui::IsWindowHovered() returns false when left_down events on buttons that causes scrollbar disappears for a short time
auto win_pos = ImGui::GetWindowPos();
bool is_win_hovered = ImGui::IsMouseHoveringRect(win_pos, win_pos + ImVec2(window_width + (show_scroll ? scrollbar_size : 0), window_height), !show_scroll);
// Also show scrollbar visible and continue to capture mouse position
const bool is_scrollbar_active_drag = GImGui != nullptr && ImGui::GetIO().MouseDown[0] && GImGui->ActiveId != 0 && GImGui->ActiveIdWindow == ImGui::GetCurrentWindow();
m_sel_plate_toolbar.is_display_scrollbar = is_win_hovered || is_scrollbar_active_drag;
m_sel_plate_toolbar.is_display_scrollbar = plate_toolbar_keeps_scrollbar(show_scroll, scrollbar_size);
imgui.end();
}
void GLCanvas3D::_render_plate_switcher()
{
Plater* plater = wxGetApp().plater();
PartPlateList& plate_list = plater->get_partplate_list();
const int plate_count = plate_list.get_plate_count();
if (!_hides_inactive_plates() || plate_count < 2 || m_gizmos.get_current_type() != GLGizmosManager::Undefined)
return;
// The thumbnails are the ones of the Preview plate toolbar.
const unsigned version = plater->plate_toolbar_image_version();
if (m_plate_switcher_version != version || m_plate_switcher_items.size() != size_t(plate_count)) {
// Only the current plate is edited here; the others are redrawn when their thumbnails go invalid.
for (int i = 0; i < plate_count && !plater->is_gcode_3mf(); ++i)
plater->update_plate_thumbnails(i, i == plate_list.get_curr_plate_index());
m_plate_switcher_items.clear();
for (int i = 0; i < plate_count; ++i) {
m_plate_switcher_items.push_back(std::make_unique<IMToolbarItem>());
m_plate_switcher_items.back()->set_thumbnail(plate_list.get_plate(i)->thumbnail_data);
}
m_plate_switcher_version = version;
}
float sc = get_scale();
#ifdef WIN32
sc *= (float) get_dpi_for_window(wxGetApp().GetTopWindow()) / (float) DPI_DEFAULT;
#endif // WIN32
ImGuiWrapper& imgui = *wxGetApp().imgui();
const ImVec2 btn_size = ImVec2(72.f, 72.f) * sc;
const ImVec2 padding = ImVec2(8.f, 8.f) * sc;
const float spacing = 8.f * sc;
const float rounding = 4.f * sc;
const float scrollbar = PLATE_TOOLBAR_SCROLLBAR * sc;
const float top = std::max(m_main_toolbar.get_height(), m_gizmos.get_scaled_total_height()) + CANVAS_TOOLBAR_MARGIN * sc;
const float content_h = plate_count * (btn_size.y + spacing) - spacing + 2.f * padding.y;
const float max_h = std::max(btn_size.y + 2.f * padding.y, float(get_canvas_size().get_height()) - top - (canvas_corner_height() + CANVAS_TOOLBAR_MARGIN) * sc);
// As in the Preview plate toolbar, the scrollbar shows only while the mouse is over the column.
const bool scroll = m_plate_switcher_scrollbar && content_h > max_h;
const ImVec4 text_color = m_is_dark ? ImVec4(.9f, .9f, .9f, 1.f) : ImVec4(.3f, .3f, .3f, 1.f);
const ImVec4 empty_color = ImVec4(text_color.x, text_color.y, text_color.z, .45f);
const ImVec4 hover = plate_toolbar_hover_color(m_is_dark);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, padding);
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(spacing, spacing));
// Concentric with the thumbnails; tighter around the scrollbar, as in the Preview plate toolbar.
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, scroll ? 6.f * sc : rounding + padding.x);
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, m_is_dark ? ImVec4(.13f, .13f, .15f, .5f) : ImVec4(1.f, 1.f, 1.f, .7f));
push_plate_toolbar_scrollbar_style(m_is_dark, scrollbar);
imgui.set_next_window_pos(CANVAS_TOOLBAR_MARGIN * sc, top, ImGuiCond_Always, 0.f, 0.f);
imgui.set_next_window_size(btn_size.x + 2.f * padding.x + (scroll ? scrollbar : 0.f), std::min(content_h, max_h), ImGuiCond_Always);
imgui.begin(std::string("##PlateSwitcher"), ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_NoCollapse | (scroll ? 0 : ImGuiWindowFlags_NoScrollbar));
const int curr_plate = plate_list.get_curr_plate_index();
ImDrawList* draw_list = ImGui::GetWindowDrawList();
const ImU32 plate_bg = m_is_dark ? IM_COL32(255, 255, 255, 10) : IM_COL32(0, 0, 0, 10);
const ImU32 plate_dim = m_is_dark ? IM_COL32(30, 30, 30, 100) : IM_COL32(0, 0, 0, 50);
for (int i = 0; i < plate_count; ++i) {
PartPlate* plate = plate_list.get_plate(i);
const bool current = i == curr_plate;
const ImVec2 pos = ImGui::GetCursorScreenPos();
ImGui::PushID(i);
const bool clicked = ImGui::InvisibleButton("##plate", btn_size);
ImGui::PopID();
const bool hovered = ImGui::IsItemHovered();
draw_list->AddRectFilled(pos, pos + btn_size, plate_bg, rounding);
if (ImTextureID texture = m_plate_switcher_items[i]->texture_id)
draw_list->AddImageRounded(texture, pos, pos + btn_size, ImVec2(0.f, 1.f), ImVec2(1.f, 0.f), IM_COL32_WHITE, rounding);
if (plate->empty())
draw_list->AddRectFilled(pos, pos + btn_size, plate_dim, rounding);
if (current || hovered) {
const float border = 2.f * sc;
draw_list->AddRect(pos - ImVec2(border, border) * .5f, pos + btn_size + ImVec2(border, border) * .5f,
ImGui::GetColorU32(current ? (hovered ? ImGuiWrapper::COL_ORCA_HOVER : ImGuiWrapper::COL_ORCA) : hover),
rounding + border * .5f, 0, border);
}
draw_list->AddText(pos + ImVec2(4.f, 2.f) * sc, ImGui::GetColorU32(plate->empty() ? empty_color : text_color), std::to_string(i + 1).c_str());
if (clicked && !current)
plater->CallAfter([i]() {
wxGetApp().plater()->select_plate(i);
wxGetApp().plater()->deselect_all();
});
if (hovered) {
const std::string name = plate->get_plate_name();
imgui.tooltip(_u8L("Plate") + " " + std::to_string(i + 1) + (name.empty() ? "" : " (" + name + ")"), ImGui::GetFontSize() * 20.f);
}
}
m_plate_switcher_scrollbar = plate_toolbar_keeps_scrollbar(scroll, scrollbar);
imgui.end();
ImGui::PopStyleColor(5);
ImGui::PopStyleVar(5);
}
//BBS: GUI refactor: GLToolbar adjust
//when rendering, {0, 0} is at the center, {-0.5, 0.5} at the left-up
void GLCanvas3D::_render_assemble_view_toolbar() const
@@ -10743,7 +10932,7 @@ void GLCanvas3D::_render_canvas_toolbar()
ImVec2 btn_size = ImVec2(CANVAS_TOOLBAR_BUTTON, CANVAS_TOOLBAR_BUTTON) * sc;
ImVec2 margin = ImVec2(m_canvas_toolbar_pos[0] > 0 ? 0.f : (CANVAS_TOOLBAR_MARGIN * sc), CANVAS_TOOLBAR_MARGIN * sc);
ImVec2 spacing = ImVec2(6.f, 6.f) * sc;
ImVec2 spacing = ImVec2(CANVAS_TOOLBAR_SPACING, CANVAS_TOOLBAR_SPACING) * sc;
ImVec2 padding = ImVec2(CANVAS_TOOLBAR_PADDING, CANVAS_TOOLBAR_PADDING) * sc;
Vec2i32 pos = {
m_canvas_toolbar_pos[0] + margin.x,
@@ -10946,6 +11135,17 @@ void GLCanvas3D::_render_canvas_toolbar()
[]{wxGetApp().toggle_show_plate_gridlines();}
);
create_menu_item( _utf8(L("Focus mode")),
m_canvas_type == ECanvasType::CanvasView3D && !m_design_canvas, // work only on prepare
wxGetApp().focus_mode(),
[this]{
wxGetApp().toggle_focus_mode();
if (wxGetApp().focus_mode())
zoom_to_plate(REQUIRES_ZOOM_TO_CUR_PLATE);
m_dirty = true;
}
);
ImGui::Separator();
create_menu_item( _utf8(L("Labels")),
+11
View File
@@ -660,6 +660,10 @@ private:
mutable GLToolbar m_main_toolbar;
mutable GLToolbar m_separator_toolbar;
mutable IMToolbar m_sel_plate_toolbar;
// Thumbnails of the plate switcher (Focus mode), refreshed when the plater's version changes.
std::vector<std::unique_ptr<IMToolbarItem>> m_plate_switcher_items;
unsigned m_plate_switcher_version{ unsigned(-1) };
bool m_plate_switcher_scrollbar{ false };
mutable GLToolbar m_assemble_view_toolbar;
mutable IMReturnToolbar m_return_toolbar;
mutable Vec2i32 m_canvas_toolbar_pos = {140, 5};
@@ -778,6 +782,8 @@ private:
//BBS:add plate related logic
mutable std::vector<int> m_hover_volume_idxs;
std::vector<int> m_hover_plate_idxs;
// Some volume or plate raycaster may still be hidden with the inactive plates.
bool m_inactive_plates_hidden{ false };
// IMEX ghost hover state (plate-owned transparent ghost volumes).
int m_hover_ghost_head { -1 }; // physical head index, -1 when not hovering a ghost
int m_hover_ghost_plate { -1 }; // plate index for the hovered ghost, -1 when none
@@ -1527,6 +1533,9 @@ private:
void _render_shadows(const Transform3d& view_matrix, const Transform3d& projection_matrix);
//BBS: add part plate related logic
void _render_platelist(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_current, bool only_body = false, int hover_id = -1, bool render_cali = false, bool show_grid = true);
bool _hides_inactive_plates() const;
// Returns whether what the scene shows changed.
bool _update_inactive_plates_visibility();
// The current plate's box (XY, at z = 0); in the Design tab, its own bed's.
BoundingBoxf3 _current_plate_box() const;
// Design tab: draw the CAD grid (minor 10 mm + major 50 mm) in place of the plate's
@@ -1565,6 +1574,8 @@ private:
void _render_gizmos_overlay();
void _render_main_toolbar();
void _render_imgui_select_plate_toolbar();
// Prepare: picks the plate to show while the inactive ones are hidden.
void _render_plate_switcher();
void _render_assemble_view_toolbar() const;
void _render_return_toolbar() const;
void _render_canvas_toolbar();
+210 -8
View File
@@ -41,6 +41,7 @@
#include <boost/filesystem/exception.hpp>
#include <boost/locale/encoding_utf.hpp>
#include <boost/log/detail/native_typeof.hpp>
#include <cstdint>
#include <iomanip>
#include <iostream>
#include <cassert>
@@ -251,9 +252,11 @@
#include "slic3r/GUI/ParamsDialog.hpp"
#include "slic3r/GUI/ParamsPanel.hpp"
#include "slic3r/GUI/PartPlate.hpp"
#include "slic3r/GUI/TextureLibrary.hpp"
#include "slic3r/GUI/Widgets/Button.hpp"
#include "slic3r/GUI/wxExtensions.hpp"
#include "slic3r/plugin/host/PluginPages.hpp"
#include "slic3r/plugin/host/PluginResourceTextures.hpp"
#include <wx/defs.h>
#include "slic3r/GUI/Widgets/WebView.hpp"
#include <cwchar>
@@ -325,6 +328,30 @@ namespace GUI {
class MainFrame;
// The OrcaCloud agent behind the shared NetworkAgent, or null when networking/OrcaCloud is not
// available (e.g. stealth mode or the network plugin still loading).
static std::shared_ptr<OrcaCloudServiceAgent> get_orca_cloud_agent(NetworkAgent* agent)
{
return agent ? std::dynamic_pointer_cast<OrcaCloudServiceAgent>(agent->get_cloud_agent()) : nullptr;
}
// Full {web} page URL for a same-site OrcaCloud path, or empty when it cannot be built. An already
// open cloud pane is navigated straight to it, without re-running the session handoff.
static wxString cloud_page_url(NetworkAgent* agent, const std::string& path)
{
if (path.empty())
return {};
auto orca = get_orca_cloud_agent(agent);
if (!orca)
return {};
std::string base = orca->get_cloud_base_url();
while (!base.empty() && base.back() == '/')
base.pop_back();
if (base.empty())
return {};
return from_u8(base + path);
}
void start_ping_test()
{
return;
@@ -2262,7 +2289,9 @@ void GUI_App::init_networking_callbacks()
if (return_code == 5) {
GUI::wxGetApp().CallAfter([this, provider = event.provider] {
BOOST_LOG_TRIVIAL(info) << "logout: login expired";
this->request_user_logout(provider);
// Involuntary: the desktop lost its session on its own. Leave the webview's
// own session alone (guide §8).
this->request_user_logout(provider, /*user_initiated=*/false);
MessageDialog msg_dlg(nullptr, _L("Login information expired. Please login again."), "", wxAPPLY | wxOK);
if (msg_dlg.ShowModal() == wxOK) {
return;
@@ -3053,6 +3082,37 @@ void GUI_App::init_plugin_gui_wiring()
refresh_plugins_dialog();
switch_printer_agent_after_unload(capability.plugin_key);
});
plugin_mgr.subscribe_on_load_callback([](const std::string& plugin_key) {
if (wxTheApp == nullptr || wxGetApp().is_closing())
return;
PluginResourceTextures::instance().on_plugin_register(plugin_key);
Slic3r::GUI::bump_texture_library_generation();
});
plugin_mgr.subscribe_on_unload_callback([](const std::string& plugin_key) {
if (wxTheApp == nullptr || wxGetApp().is_closing())
return;
PluginResourceTextures::instance().on_plugin_deregister(plugin_key);
Slic3r::GUI::bump_texture_library_generation();
});
plugin_mgr.subscribe_on_capability_load_callback([](const PluginCapabilityId& capability) {
if (capability.type != PluginCapabilityType::ResourceTexture)
return;
if (wxTheApp == nullptr || wxGetApp().is_closing())
return;
PluginResourceTextures::instance().on_cap_register(capability);
Slic3r::GUI::bump_texture_library_generation();
});
plugin_mgr.subscribe_on_capability_unload_callback([](const PluginCapabilityId& capability) {
if (capability.type != PluginCapabilityType::ResourceTexture)
return;
if (wxTheApp == nullptr || wxGetApp().is_closing())
return;
PluginResourceTextures::instance().on_cap_deregister(capability);
Slic3r::GUI::bump_texture_library_generation();
});
// Packs already loaded before this wiring, or in tests where no callback fires.
PluginResourceTextures::instance().initial_sweep();
Slic3r::GUI::bump_texture_library_generation();
}
bool GUI_App::on_init_inner()
@@ -5238,7 +5298,7 @@ void GUI_App::post_logout_to_webview(const std::string& provider)
}
}
void GUI_App::request_user_logout(const std::string& provider/* = ORCA_CLOUD_PROVIDER*/)
void GUI_App::request_user_logout(const std::string& provider/* = ORCA_CLOUD_PROVIDER*/, bool user_initiated)
{
if (m_agent && m_agent->is_user_login(provider)) {
m_agent->user_logout(true, provider);
@@ -5251,6 +5311,21 @@ void GUI_App::request_user_logout(const std::string& provider/* = ORCA_CLOUD_PRO
}
if (provider == ORCA_CLOUD_PROVIDER) {
// Invalidate any handoff still in flight, so a token minted for the signed-out
// account is never loaded into the pane.
++m_cloud_handoff_gen;
m_cloud_handoff_state.clear();
// An explicit user sign-out also ends the webview's own cloud session (guide §8).
// A session the desktop lost on its own must NOT do this: a 2FA user would otherwise
// loop through the code prompt on every desktop re-login.
if (user_initiated) {
if (auto orca = get_orca_cloud_agent(m_agent)) {
if (WebViewPanel* home = WebViewPanel::if_built())
home->cloud_signed_out(from_u8(orca->build_web_session_signout_url()));
}
}
/* delete old user settings */
bool transfer_preset_changes = false;
wxString header = _L("Some presets are modified.") + "\n" +
@@ -5372,7 +5447,7 @@ std::string GUI_App::handle_web_request(std::string cmd)
else if (command_str.compare("homepage_logout") == 0) {
CallAfter([this] {
BOOST_LOG_TRIVIAL(info) << "logout: homepage_logout";
request_user_logout();
request_user_logout(ORCA_CLOUD_PROVIDER, /*user_initiated=*/true);
});
}
else if (command_str.compare("get_orca_login_info") == 0) {
@@ -5387,7 +5462,7 @@ std::string GUI_App::handle_web_request(std::string cmd)
else if (command_str.compare("homepage_bambu_logout") == 0) {
CallAfter([this] {
BOOST_LOG_TRIVIAL(info) << "logout: homepage_bambu_logout";
request_user_logout(BBL_CLOUD_PROVIDER);
request_user_logout(BBL_CLOUD_PROVIDER, /*user_initiated=*/true);
});
}
else if (command_str.compare("homepage_orca_login_or_register") == 0) {
@@ -5396,7 +5471,7 @@ std::string GUI_App::handle_web_request(std::string cmd)
else if (command_str.compare("homepage_orca_logout") == 0) {
CallAfter([this] {
BOOST_LOG_TRIVIAL(info) << "logout: homepage_orca_logout";
request_user_logout(ORCA_CLOUD_PROVIDER);
request_user_logout(ORCA_CLOUD_PROVIDER, /*user_initiated=*/true);
});
}
else if (command_str.compare("homepage_modeldepot") == 0) {
@@ -5408,6 +5483,15 @@ std::string GUI_App::handle_web_request(std::string cmd)
else if (command_str.compare("homepage_openproject") == 0) {
this->request_open_project({});
}
else if (command_str.compare("homepage_open_cloud") == 0) {
this->open_orca_cloud();
}
else if (command_str.compare("homepage_close_cloud") == 0) {
CallAfter([]() {
if (WebViewPanel* home = WebViewPanel::if_built())
home->set_cloud_mode(false);
});
}
else if (command_str.compare("get_recent_projects") == 0) {
if (mainframe) {
if (WebViewPanel* home = WebViewPanel::if_built()) {
@@ -5549,6 +5633,22 @@ std::string GUI_App::handle_web_request(std::string cmd)
return "";
}
void GUI_App::open_orca_cloud(const std::string& next)
{
// Remembered so a login while the pane is open re-seeds the same page rather than the default.
m_cloud_open_path = next;
// Deferred: set_cloud_mode() may create the second native webview, so this must not run inside
// the caller's frame (a webview script-message callback or an ImGui frame).
CallAfter([this]() {
if (mainframe != nullptr)
mainframe->select_tab(TAB_ID_HOME);
// The Home page is built lazily, so the tab switch may not have created it yet: ensure() builds
// it if needed, matching what showing the tab does, rather than dropping the open request.
if (WebViewPanel* home = WebViewPanel::ensure())
home->set_cloud_mode(true, cloud_webview_handoff(m_cloud_open_path), cloud_page_url(m_agent, m_cloud_open_path));
});
}
void GUI_App::handle_script_message(std::string msg, const std::string& provider)
{
try {
@@ -5680,7 +5780,9 @@ void GUI_App::on_http_error(wxCommandEvent &evt)
if (!provider.empty() && m_agent->is_user_login(provider)) {
if (std::chrono::steady_clock::now() - m_last_401_error_time > 30s) {
BOOST_LOG_TRIVIAL(warning) << "logout: http error 401.";
this->request_user_logout(provider);
// Involuntary: a 401 means the desktop session was rejected. Leave the
// webview's own session alone (guide §8).
this->request_user_logout(provider, /*user_initiated=*/false);
if (!m_show_http_error_msgdlg) {
MessageDialog msg_dlg(nullptr, _L("Login information expired. Please login again."), "", wxAPPLY | wxOK);
@@ -5824,6 +5926,93 @@ void GUI_App::on_update_machine_list(wxCommandEvent &evt)
}*/
}
wxString GUI_App::cloud_webview_handoff(const std::string& next)
{
// The cloud pane loads the Start URL, not a token: the page checks the webview's own session
// and asks for a handoff itself (guide §4). Signed out, user_id is omitted and the page will
// post request_web_session, which is what opens the desktop login dialog.
auto orca = get_orca_cloud_agent(m_agent);
if (!orca)
return {};
return from_u8(orca->build_web_session_start_url(next));
}
void GUI_App::request_cloud_web_session(const std::string& state)
{
if (state.empty())
return;
// Only the newest request counts: a newer state supersedes any mint or login still in flight.
m_cloud_handoff_state = state;
const uint64_t gen = ++m_cloud_handoff_gen;
auto orca = get_orca_cloud_agent(m_agent);
if (!orca || !orca->is_user_login()) {
// Desktop signed out: open the desktop login dialog and stop here. On success
// on_user_login_handle reloads the Start URL, which makes the page request again; on
// cancel nothing happens (the guide's "on cancel, do nothing"). The newest state is
// already stored, so a repeat request while the dialog is open is dropped rather than
// stacking another dialog.
if (!login_dlg || !login_dlg->IsShown())
request_login(true, ORCA_CLOUD_PROVIDER);
return;
}
mint_cloud_web_session(state, gen);
}
void GUI_App::mint_cloud_web_session(const std::string& state, uint64_t gen)
{
auto orca = get_orca_cloud_agent(m_agent);
if (!orca)
return;
// The endpoint call blocks on network I/O (and possibly a cross-process token refresh), so
// it must not run on the UI thread. Hand off to a worker and marshal the token back.
boost::thread([this, orca, state, gen]() {
std::string token_hash;
std::string error_code;
unsigned int http_code = 0;
orca->request_web_session_handoff(token_hash, error_code, http_code);
if (is_closing())
return;
if (token_hash.empty()) {
// No token: 401 after retry, 409/429/503 or a transport error. The page owns its own
// "Sign-in link expired"/"Try again" UI; the desktop stays silent. Never log the body
// (it may echo account data), only the status/error code.
BOOST_LOG_TRIVIAL(warning) << "cloud web session handoff not minted - http_code=" << http_code
<< " error_code=" << error_code;
return;
}
GUI::wxGetApp().CallAfter([this, state, gen, token_hash]() {
on_cloud_web_session_minted(state, gen, token_hash);
});
}).detach();
}
void GUI_App::on_cloud_web_session_minted(const std::string& state, uint64_t gen, const std::string& token_hash)
{
if (is_closing())
return;
// Drop a result for a superseded request (a newer request_web_session, login or sign-out).
if (gen != m_cloud_handoff_gen || state != m_cloud_handoff_state)
return;
if (token_hash.empty())
return;
auto orca = get_orca_cloud_agent(m_agent);
if (!orca)
return;
// Load the Complete URL in the SAME webview that posted request_web_session, because the
// state lives in that webview's session storage (guide §3/§7).
if (WebViewPanel* home = WebViewPanel::if_built())
home->load_cloud_url(from_u8(orca->build_web_session_complete_url(token_hash, state)));
}
void GUI_App::on_user_login_handle(wxCommandEvent &evt)
{
if (!m_agent) { return; }
@@ -5879,6 +6068,16 @@ void GUI_App::on_user_login_handle(wxCommandEvent &evt)
if (app_config->get("sync_user_preset") == "true") {
start_sync_user_preset();
}
// Re-seed the embedded OrcaCloud pane if it is already open, so it picks up the new session
// without waiting for the next open. On a fresh login or account switch this invalidates any
// in-flight handoff for the previous account and loads the Start URL with the new user_id, so
// the page drops the old account and re-requests (guide §8).
++m_cloud_handoff_gen;
m_cloud_handoff_state.clear();
if (WebViewPanel* home = WebViewPanel::if_built())
if (home->cloud_mode_active())
home->set_cloud_mode(true, cloud_webview_handoff(m_cloud_open_path));
}
@@ -8012,8 +8211,11 @@ void GUI_App::on_stealth_mode_enter()
{
stop_sync_user_preset();
BOOST_LOG_TRIVIAL(info) << "logout: on_stealth_mode_enter";
request_user_logout(ORCA_CLOUD_PROVIDER);
request_user_logout(BBL_CLOUD_PROVIDER);
// Stealth mode forces a desktop logout, but the user did not choose "Sign out": guide §8 ties
// ?signout=1 to the explicit desktop sign-out only. Leave the cloud webview's own session
// alone, so it is re-checked (and re-seeded with the right account) on the next open.
request_user_logout(ORCA_CLOUD_PROVIDER, /*user_initiated=*/false);
request_user_logout(BBL_CLOUD_PROVIDER, /*user_initiated=*/false);
if (WebViewPanel* home = WebViewPanel::if_built()) {
home->SendCloudProvidersInfo();
}
+26 -1
View File
@@ -361,6 +361,13 @@ private:
bool m_side_popup_status{false};
bool m_show_http_error_msgdlg{false};
std::chrono::steady_clock::time_point m_last_401_error_time;
// OrcaCloud webview session handoff: the newest request_web_session state from the cloud pane
// and a generation counter so a superseded mint/login result is dropped (guide §5, "only the
// newest request counts").
std::string m_cloud_handoff_state;
// The OrcaCloud path the open requests target, remembered so a login re-seed keeps the same page.
std::string m_cloud_open_path;
uint64_t m_cloud_handoff_gen { 0 };
bool m_show_error_msgdlg{false};
wxString m_info_dialog_content;
HttpServer m_http_server;
@@ -431,6 +438,8 @@ public:
bool show_plate_gridlines() const { return app_config->get_bool("show_plate_gridlines"); }
void toggle_show_plate_gridlines() const { app_config->set_bool("show_plate_gridlines", !show_plate_gridlines()); }
bool focus_mode() const { return app_config->get_bool("focus_mode"); }
void toggle_focus_mode() const { app_config->set_bool("focus_mode", !focus_mode()); }
bool show_canvas_zoom_button() const { return app_config->get_bool("show_canvas_zoom_button"); }
void toggle_canvas_zoom_button() const { app_config->set_bool("show_canvas_zoom_button", !show_canvas_zoom_button()); }
@@ -459,6 +468,9 @@ public:
#if wxUSE_WEBVIEW_EDGE
void init_webview_runtime();
#endif
// Switches to the Home tab and opens the OrcaCloud pane beside the homepage on `next`, a
// same-site OrcaCloud path (empty = the page's default). Work is deferred internally.
void open_orca_cloud(const std::string& next = std::string());
static unsigned get_colour_approx_luma(const wxColour& colour);
static bool dark_mode();
const wxColour get_label_default_clr_system();
@@ -540,7 +552,11 @@ public:
void request_user_login(int online_login = 0, const std::string& provider = ORCA_CLOUD_PROVIDER);
void request_user_handle(int online_login = 0, const std::string& provider = ORCA_CLOUD_PROVIDER);
void request_user_logout(const std::string& provider = ORCA_CLOUD_PROVIDER);
// `user_initiated` distinguishes a user sign-out from the desktop losing its session on its
// own (refresh failed, 401). Only an explicit sign-out also ends the cloud webview's own
// session; an involuntary one leaves the webview alone (guide §8). Defaults to the safe
// direction: a new caller that forgets the flag will not silently sign the webview out.
void request_user_logout(const std::string& provider = ORCA_CLOUD_PROVIDER, bool user_initiated = false);
void post_logout_to_webview(const std::string& provider);
int request_user_unbind(std::string dev_id, const std::string& provider = ORCA_CLOUD_PROVIDER);
std::string handle_web_request(std::string cmd);
@@ -556,6 +572,15 @@ public:
void on_update_machine_list(wxCommandEvent& evt);
void on_user_login(wxCommandEvent &evt);
void on_user_login_handle(wxCommandEvent& evt);
// OrcaCloud webview session handoff (guide §5/§7). cloud_webview_handoff builds the cloud
// pane's Start URL (with the desktop user_id while signed in). request_cloud_web_session is
// called from the pane when the page asks for a session; it opens the desktop login dialog if
// signed out, otherwise mints a one-time token on a worker thread and loads the Complete URL
// in the same webview.
wxString cloud_webview_handoff(const std::string& next = std::string());
void request_cloud_web_session(const std::string& state);
void mint_cloud_web_session(const std::string& state, uint64_t gen);
void on_cloud_web_session_minted(const std::string& state, uint64_t gen, const std::string& token_hash);
void enable_user_preset_folder(bool enable);
// BBS
+1 -1
View File
@@ -2400,7 +2400,7 @@ void MenuFactory::append_menu_item_drop(wxMenu* menu)
if (plater()->canvas3D()->get_canvas_type() != GLCanvas3D::ECanvasType::CanvasView3D)
return false;
else {
return (std::abs(plater()->get_view3D_canvas3D()->get_selection().get_bounding_box().min.z()) > -SINKING_Z_THRESHOLD);
return plater()->get_view3D_canvas3D()->get_selection().can_drop();
} //disable if model is on the bed / not in View3D
}, m_parent);
}
@@ -15,6 +15,7 @@
#include "libslic3r/Utils.hpp"
#include "libslic3r/format.hpp"
#include <slic3r/GUI/AnimatedTexture.hpp>
#include "slic3r/GUI/Camera.hpp"
#include "slic3r/GUI/CameraUtils.hpp"
#include "slic3r/GUI/GLCanvas3D.hpp"
@@ -39,6 +40,7 @@
#include "slic3r/Utils/UndoRedo.hpp"
#include "GLGizmoUtils.hpp"
#include <cctype>
#include <cstddef>
#include <cstdint>
#include <functional>
@@ -71,6 +73,7 @@
#include <array>
#include <cmath>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
@@ -96,6 +99,48 @@ namespace {
// bigger jump than the whole useful region.
constexpr float ImGuiLogSlider = float(ImGuiSliderFlags_Logarithmic);
// The scrollbar of the panel's scrolling areas (the panel body and the texture library). ImGui's stock
// scrollbar is a wide, square-cornered slab in a tinted track - against the flat panel it reads as a raw
// widget bolted onto the edge - so it is slimmed to a rounded thumb over an invisible track. The child
// background goes transparent with it, so the area reads as part of the panel.
void push_slim_scrollbar_style(float width, bool dark)
{
const ImVec4 grab = dark ? ImVec4(1.f, 1.f, 1.f, 0.26f) : ImVec4(0.f, 0.f, 0.f, 0.26f);
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, width);
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 0.5f * width);
ImGui::PushStyleColor(ImGuiCol_ScrollbarBg, ImVec4(0.f, 0.f, 0.f, 0.f));
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrab, grab);
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabHovered, ImVec4(grab.x, grab.y, grab.z, 0.45f));
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabActive, ImVec4(grab.x, grab.y, grab.z, 0.65f));
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.f, 0.f, 0.f, 0.f));
}
void pop_slim_scrollbar_style()
{
ImGui::PopStyleColor(5);
ImGui::PopStyleVar(2);
}
// Cuts a UTF-8 string down to `max_w` pixels, ending it in "..." when it had to be cut.
std::string ellipsize(std::string s, float max_w, float font_size = 0.f)
{
const auto text_width = [&](const std::string &text) {
return font_size > 0.f
? ImGui::GetFont()->CalcTextSizeA(font_size, std::numeric_limits<float>::max(), 0.f, text.c_str()).x
: ImGui::CalcTextSize(text.c_str()).x;
};
if (text_width(s) <= max_w)
return s;
while (!s.empty() && text_width(s + "...") > max_w) {
unsigned char c;
do { // a whole code point at a time
c = static_cast<unsigned char>(s.back());
s.pop_back();
} while (!s.empty() && (c & 0xC0) == 0x80);
}
return s + "...";
}
// Uploads decoded 8-bit grayscale height data as an RGBA GPU texture for display in the panel.
// Shared by the per-layer thumbnail and the picker's library entries, which differ only in where
// their pixels came from.
@@ -2918,8 +2963,9 @@ void GLGizmoTextureDisplacement::run_uv_command(int cmd, float value)
}
else if (cmd == int(Command::PickTexture)) {
// The same texture library the layer card opens; the panel renders it this frame.
m_picker_slot = m_active_layer_slot;
m_picker_open_request = true;
m_picker_slot = m_active_layer_slot;
m_picker_open_request = true;
m_picker_reveal_selected = true;
} else if (cmd == int(Command::Unwrap)) {
// The solve runs only on this - painting, the seam angle and seam marking never trigger it.
m_uv_unwrap_pending = true;
@@ -4101,7 +4147,7 @@ void GLGizmoTextureDisplacement::set_layer_texture(TextureDisplacementLayer &lay
// the PNG the first time the layer is previewed or baked.
layer.image_data = tex->image_data;
// The user's own textures are listed most recently used first.
if (entry.is_user)
if (entry.source == TextureSource::User)
touch_user_texture(entry.path);
rebuild_preview();
@@ -4128,6 +4174,40 @@ void GLGizmoTextureDisplacement::import_custom_texture(TextureDisplacementLayer
set_layer_texture(layer, *entry);
}
void GLGizmoTextureDisplacement::ease_wheel_scroll(float &target, float &applied)
{
ImGuiIO &io = ImGui::GetIO();
const float scroll_now = ImGui::GetScrollY();
const float scroll_max = ImGui::GetScrollMaxY();
// Anything that moved the scroll without us - dragging the grab, a keyboard/gamepad nav step, the
// content shrinking under a clamped offset, a SetScroll*() call - has to re-seed the target, or the
// easing below would immediately drag the view back to where it last animated to.
if (applied < 0.f || std::abs(scroll_now - applied) > 0.5f)
target = scroll_now;
if (io.MouseWheel != 0.f && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows))
target -= io.MouseWheel * ImGui::GetFontSize() * 4.f;
// Whole pixels: ImGui floors whatever SetScrollY() is given, so a fractional target could never be
// reached and the "still gliding" test below would stay true forever, repainting the canvas for good.
target = std::floor(std::clamp(target, 0.f, scroll_max));
const float delta = target - scroll_now;
if (std::abs(delta) >= 1.f) {
// Exponential ease, formulated against the frame time so the glide takes the same wall time
// whether the canvas is running at 30 or 144 fps. The last sub-pixel step would be floored
// away, so land on the target outright once the remainder is that small.
const float t = 1.f - std::exp(-20.f * std::clamp(io.DeltaTime, 1.f / 240.f, 1.f / 15.f));
const float step = (std::abs(delta * t) < 1.f) ? delta : delta * t;
const float next = std::floor(scroll_now + step);
ImGui::SetScrollY(next);
applied = next;
m_parent.set_as_dirty(); // nothing else would redraw mid-glide once the mouse stops
} else {
applied = scroll_now;
}
}
void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &panel_min, const ImVec2 &panel_max)
{
ModelVolume *mv = texture_volume();
@@ -4144,17 +4224,55 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
ImGui::OpenPopup(popup_id);
}
const float tile = std::round(m_imgui->scaled(3.2f));
const float spacing = std::round(m_imgui->scaled(0.4f));
const float grid_w = 4.f * tile + 3.f * spacing;
const float gap = m_imgui->scaled(0.5f);
const float tile = std::round(m_imgui->scaled(3.2f));
const float spacing = std::round(m_imgui->scaled(0.4f));
const float grid_w = 4.f * tile + 3.f * spacing;
const float gap = m_imgui->scaled(0.5f);
const float scrollbar_w = m_imgui->scaled(0.5f);
const float ring_w = std::max(2.f, std::round(m_imgui->scaled(0.1f))); // the current texture's ring
const float frame_h = ImGui::GetFrameHeight();
const ImVec4 orca = ImGuiWrapper::COL_ORCA;
const ImGuiStyle &style = ImGui::GetStyle();
// A fixed size rather than a fit-to-content one. The width is the four-column grid, a gutter and the
// slim scrollbar beside it, plus the window padding, so the columns always fit and no horizontal
// scrollbar is needed; it derives from scaled values, so it tracks the DPI like the rest.
const float fixed_w = grid_w + spacing + scrollbar_w + 2.f * style.WindowPadding.x;
// BeginPopup() ORs in ImGuiWindowFlags_AlwaysAutoResize (deps_src/imgui/imgui.cpp:8371) and no flag
// turns it back off. A SetNextWindowSize() overrides it for each axis it sets (imgui.cpp:5983-5996),
// so an explicit size is the only way to fix the popup's size; the grid child below then scrolls.
//
// Top-aligned with the panel, which keeps it clear of the gizmo toolbar above a docked panel, and as
// tall as the panel or 70% of the window, whichever is taller, while keeping a gap inside the window.
// The height follows the window and the panel, never the content, so it does not jump as packs load or
// sections collapse.
const ImGuiViewport *viewport = ImGui::GetMainViewport();
const float vp_top = viewport->Pos.y + gap;
const float vp_bottom = viewport->Pos.y + viewport->Size.y - gap;
const float height = std::max(std::min(std::max(0.70f * viewport->Size.y, panel_max.y - panel_min.y), vp_bottom - vp_top),
2.f * frame_h);
const float popup_y = std::max(vp_top, std::min(panel_min.y, vp_bottom - height));
// Beside the panel rather than over it, so the layer being retextured stays in view: to its left when
// there is room (the docked panel sits against the right edge of the canvas), otherwise to its right.
const float popup_w = grid_w + 2.f * ImGui::GetStyle().WindowPadding.x;
const bool left = panel_min.x - gap - popup_w >= 0.f;
ImGui::SetNextWindowPos(ImVec2(left ? panel_min.x - gap : panel_max.x + gap, panel_min.y + m_imgui->scaled(4.f)),
ImGuiCond_Appearing, ImVec2(left ? 1.f : 0.f, 0.f));
if (!ImGui::BeginPopup(popup_id))
const float width = viewport->Size.x > 0.f ? std::min(fixed_w, viewport->Size.x) : fixed_w;
const bool left = panel_min.x - gap - width >= 0.f;
const float popup_x = left ? panel_min.x - gap - width : panel_max.x + gap;
// Cond_Always, not Appearing: a viewport resize while the popup is open must move and resize it too.
ImGui::SetNextWindowPos(ImVec2(popup_x, popup_y), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(width, height), ImGuiCond_Always);
// Drawn as the panel's own sheet: its opaque background and rounding, no border. A popup is filled with
// PopupBg, which the panel's toolbar style leaves at ImGui's translucent near-black - a dark window
// beside a light panel, with whatever it covers showing through. Only Begin reads these, so they are
// popped at once and the tooltips shown from inside keep their own look.
ImGui::PushStyleColor(ImGuiCol_PopupBg, ImGui::GetStyleColorVec4(ImGuiCol_WindowBg));
ImGui::PushStyleVar(ImGuiStyleVar_PopupRounding, style.WindowRounding);
ImGui::PushStyleVar(ImGuiStyleVar_PopupBorderSize, 0.f);
// The window itself never scrolls; the grid child below does.
const bool open = ImGui::BeginPopup(popup_id, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
ImGui::PopStyleVar(2);
ImGui::PopStyleColor();
if (!open)
return;
if (layer == nullptr) {
ImGui::CloseCurrentPopup();
@@ -4170,16 +4288,49 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
m_tooltip_anchor_max = ImVec2(m_tooltip_anchor_min.x + ImGui::GetWindowSize().x,
m_tooltip_anchor_min.y + ImGui::GetWindowSize().y);
m_imgui->push_bold_font();
m_imgui->text(_L("Choose a texture"));
m_imgui->pop_bold_font();
// The header stays put while the grid scrolls under it: the title says what the popup is for, and the
// close button is there for anyone who does not know that a click outside closes it.
bool close_clicked = false;
{
ImDrawList *hdl = ImGui::GetWindowDrawList();
const ImVec2 row = ImGui::GetCursorScreenPos();
ImGui::SetCursorScreenPos(ImVec2(row.x, row.y + 0.5f * (frame_h - ImGui::GetTextLineHeight())));
m_imgui->bold_text(_u8L("Choose a texture"));
const ImVec2 btn(ImGui::GetWindowPos().x + ImGui::GetWindowContentRegionMax().x - frame_h, row.y);
ImGui::SetCursorScreenPos(btn);
close_clicked = ImGui::InvisibleButton("##close", ImVec2(frame_h, frame_h));
const bool hot = ImGui::IsItemHovered();
const ImVec2 c(btn.x + 0.5f * frame_h, btn.y + 0.5f * frame_h);
const float k = std::round(0.18f * frame_h);
const float line_w = std::max(1.f, std::round(m_imgui->scaled(0.08f)));
const ImU32 x_col = ImGui::GetColorU32(ImGuiCol_Text, hot ? 1.f : 0.6f);
if (hot)
hdl->AddCircleFilled(c, 0.5f * frame_h, ImGui::GetColorU32(ImGuiCol_Text, 0.08f));
hdl->AddLine(ImVec2(c.x - k, c.y - k), ImVec2(c.x + k, c.y + k), x_col, line_w);
hdl->AddLine(ImVec2(c.x - k, c.y + k), ImVec2(c.x + k, c.y - k), x_col, line_w);
ImGui::SetCursorScreenPos(ImVec2(row.x, row.y + frame_h + style.ItemSpacing.y));
ImGui::Separator();
}
const std::vector<TextureLibraryEntry> &library = texture_library();
ImDrawList *dl = ImGui::GetWindowDrawList();
const ImU32 selected_col = ImGui::GetColorU32(ImGuiWrapper::COL_ORCA);
std::optional<TextureLibraryEntry> chosen;
std::optional<TextureLibraryEntry> to_remove;
bool import_clicked = false;
// A reinstalled pack can drop new bytes at a path already cached on this gizmo, and get_library_texture()
// caches a failure as well as a success, so a moved generation makes every cached entry suspect.
if (const uint64_t generation = texture_library_generation(); generation != m_library_generation) {
m_library_textures.clear();
m_library_generation = generation;
}
// The grid scrolls as the panel body does: the same slim scrollbar, and the wheel eased by
// ease_wheel_scroll() instead of ImGui's five-line jumps (hence NoScrollWithMouse).
push_slim_scrollbar_style(scrollbar_w, wxGetApp().dark_mode());
ImGui::BeginChild("##texture_grid", ImVec2(0.f, 0.f), false, ImGuiWindowFlags_NoScrollWithMouse);
ease_wheel_scroll(m_picker_scroll_target, m_picker_scroll_applied);
ImDrawList *dl = ImGui::GetWindowDrawList(); // the child's, so tiles clip to the grid
std::optional<TextureLibraryEntry> chosen;
std::optional<TextureLibraryEntry> to_remove;
bool import_clicked = false;
bool download_clicked = false;
// Tiles are placed on the four-column grid explicitly rather than flowed with SameLine(): a user tile
// overlays a remove button on its corner, and that button would otherwise be "the previous item" the
@@ -4195,16 +4346,15 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
ImGui::Dummy(ImVec2(grid_w, float(rows) * tile + float(rows - 1) * spacing));
}
};
// One group's tiles: shipped textures alphabetically, or the user's own most recently used first (the
// order texture_library() keeps), those with a remove button on hover. Returns how many it drew.
const auto grid = [&](const ImVec2 &origin, bool user) {
// One section's tiles, in the order the section holds them (shipped alphabetically, pack textures by
// name, the user's own most recently used first - the order texture_library() produced); a user tile
// carries a remove button on hover. Returns how many it drew.
const auto grid = [&](const ImVec2 &origin, const std::vector<const TextureLibraryEntry *> &entries, bool removable) {
int n = 0;
for (const TextureLibraryEntry &entry : library) {
if (entry.is_user != user)
continue;
for (const TextureLibraryEntry *entry : entries) {
ImGui::SetCursorScreenPos(tile_pos(origin, n++));
ImGui::PushID(entry.path.c_str());
const LibraryTexture *tex = get_library_texture(entry.path);
ImGui::PushID(entry->path.c_str());
const LibraryTexture *tex = get_library_texture(entry->path);
GLTexture *thumb = tex != nullptr ? tex->thumbnail.get() : nullptr;
bool clicked = false;
if (thumb != nullptr) {
@@ -4222,15 +4372,27 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
} else {
clicked = ImGui::Button("?", ImVec2(tile, tile));
}
if (user)
if (removable)
ImGui::SetItemAllowOverlap(); // the remove button drawn over its corner takes its own clicks
const ImVec2 a = ImGui::GetItemRectMin(), b = ImGui::GetItemRectMax();
if (entry.path == layer->path)
dl->AddRect(ImVec2(a.x - 1.f, a.y - 1.f), ImVec2(b.x + 1.f, b.y + 1.f), selected_col, 0.f, 0, 2.f);
if (ImGui::IsItemHovered())
panel_tooltip(entry.name);
const ImVec2 a = ImGui::GetItemRectMin(), b = ImGui::GetItemRectMax();
const bool current = entry->path == layer->path;
const bool hovered = ImGui::IsItemHovered();
// The layer's current texture carries an accent ring, and the tile under the pointer a thinner,
// fainter one. Both are drawn inside the tile, where the grid's clip rect never cuts them.
if (current || hovered) {
const float w = current ? ring_w : std::round(0.5f * ring_w);
dl->AddRect(ImVec2(a.x + 0.5f * w, a.y + 0.5f * w), ImVec2(b.x - 0.5f * w, b.y - 0.5f * w),
ImGui::GetColorU32(current ? orca : ImVec4(orca.x, orca.y, orca.z, 0.6f)), 0.f, 0, w);
}
// Centred, as far as the scroll range allows, so the textures around it are in view too.
if (current && m_picker_reveal_selected)
ImGui::SetScrollFromPosY(0.5f * (a.y + b.y) - ImGui::GetWindowPos().y, 0.5f);
if (hovered)
panel_tooltip(entry->name);
if (user && ImGui::IsMouseHoveringRect(a, b)) {
// Clipped by the grid's clip rect, so a tile scrolled out of the grid or under its scrollbar
// does not offer its remove button. IsMouseHoveringRect() clips by default.
if (removable && ImGui::IsMouseHoveringRect(a, b)) {
const float r = std::round(tile * 0.17f);
const ImVec2 c(b.x - r - 2.f, a.y + r + 2.f);
ImGui::SetCursorScreenPos(ImVec2(c.x - r, c.y - r));
@@ -4243,27 +4405,162 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
if (hot)
panel_tooltip(_u8L("Remove from My textures"));
if (remove_clicked)
to_remove = entry;
to_remove = *entry;
}
ImGui::PopID();
if (clicked)
chosen = entry;
chosen = *entry;
}
return n;
};
ImGui::TextDisabled("%s", _u8L("Built-in").c_str());
// Every section collapses; the state is remembered for the app run. Pack keys use pack_name, a
// capability name unique per type across loaded packages and stable across reinstalls. This function-local
// static survives popup closes, never touches app_config, and resets on restart.
static std::map<std::string, bool> s_section_collapsed; // true = collapsed; absent = expanded
const auto split_pack_title = [](const std::string &title) -> std::pair<std::string, std::string> {
size_t split = std::string::npos;
for (size_t i = 0; i + 3 < title.size(); ++i)
if (title[i] == ' ' && (title[i + 1] == 'b' || title[i + 1] == 'B') &&
(title[i + 2] == 'y' || title[i + 2] == 'Y') && title[i + 3] == ' ')
split = i;
if (split == std::string::npos)
return { title, {} };
std::string name = title.substr(0, split);
while (!name.empty() && std::isspace(static_cast<unsigned char>(name.back())))
name.pop_back();
std::string attribution = title.substr(split);
size_t first = 0;
while (first < attribution.size() && std::isspace(static_cast<unsigned char>(attribution[first])))
++first;
attribution.erase(0, first);
if (name.empty() || attribution.empty())
return { title, {} };
return { name, attribution };
};
// A section heading, one row as wide as the grid: a click anywhere on it collapses or expands the
// section. Child headings indent their contents while keeping their counts right-aligned. Attribution
// lines use a smaller disabled style; the grid follows closer than the next heading does.
// Returns true when the section is open, so the caller knows whether to draw its contents.
const auto section_header = [&](const std::string &key, const std::string &title, size_t count, float indent,
const std::string &attribution, const std::string &full_title) {
bool &collapsed = s_section_collapsed[key]; // default-inserts false -> expanded
const ImVec2 row = ImGui::GetCursorScreenPos();
const bool two_lines = !attribution.empty();
const float line_h = ImGui::GetTextLineHeight();
const float small_font_size = ImGui::GetFontSize() * 0.8f;
const float small_line_h = line_h * 0.8f;
const float row_h = two_lines ? std::max(frame_h, line_h + small_line_h + spacing) : frame_h;
const ImVec2 row_max(row.x + grid_w, row.y + row_h);
if (ImGui::InvisibleButton(key.c_str(), ImVec2(grid_w, row_h)))
collapsed = !collapsed;
const bool hovered = ImGui::IsItemHovered();
if (hovered)
dl->AddRectFilled(row, row_max, ImGui::GetColorU32(ImGuiCol_Text, 0.05f), style.FrameRounding);
const float inset = std::round(0.25f * frame_h);
const float arrow = std::round(ImGui::GetFontSize() * 0.55f);
const float cx = row.x + inset + indent + 0.5f * arrow;
const float cy = row.y + 0.5f * row_h;
const float hs = 0.3f * arrow;
const ImU32 accent = ImGui::GetColorU32(orca);
if (collapsed)
dl->AddTriangleFilled(ImVec2(cx - 0.5f * hs, cy - hs), ImVec2(cx + 0.5f * hs, cy), ImVec2(cx - 0.5f * hs, cy + hs), accent);
else
dl->AddTriangleFilled(ImVec2(cx - hs, cy - 0.5f * hs), ImVec2(cx + hs, cy - 0.5f * hs), ImVec2(cx, cy + 0.5f * hs), accent);
const std::string n = std::to_string(count);
const float n_w = ImGui::CalcTextSize(n.c_str()).x;
const float tx = row.x + inset + indent + arrow + spacing;
const float max_text_w = std::max(0.f, row_max.x - inset - n_w - spacing - tx);
const std::string shown = ellipsize(title, max_text_w);
const std::string shown_attribution = two_lines ? ellipsize(attribution, max_text_w, small_font_size) : std::string{};
const float title_y = two_lines ? row.y + 0.5f * (row_h - line_h - small_line_h)
: row.y + 0.5f * (frame_h - line_h);
const float count_y = row.y + 0.5f * (row_h - line_h);
dl->AddText(ImVec2(tx, title_y), ImGui::GetColorU32(ImGuiCol_Text), shown.c_str());
if (two_lines)
dl->AddText(ImGui::GetFont(), small_font_size, ImVec2(tx, title_y + line_h),
ImGui::GetColorU32(ImGuiCol_TextDisabled), shown_attribution.c_str());
dl->AddText(ImVec2(row_max.x - inset - n_w, count_y), ImGui::GetColorU32(ImGuiCol_TextDisabled), n.c_str());
if (hovered && (shown != title || shown_attribution != attribution))
panel_tooltip(full_title); // a long plugin-supplied name, cut by the fixed width
ImGui::SetCursorScreenPos(ImVec2(row.x, row_max.y + spacing));
return !collapsed;
};
// Built-in is first, plugin packs are grouped under Texture libraries, and My
// textures stays last so its import tile and help line describe that group. Pack names identify both
// their child section and state.
struct Section
{
std::string key;
std::string title;
std::vector<const TextureLibraryEntry *> entries;
};
std::vector<Section> sections;
std::map<std::string, size_t> pack_section; // pack_name -> index in `sections`
sections.push_back({ "builtin:", _u8L("Built-in"), {} });
for (const TextureLibraryEntry &entry : library) {
if (entry.source == TextureSource::Shipped) {
sections.front().entries.push_back(&entry); // the section exists even when the library is empty
continue;
}
if (entry.source == TextureSource::User)
continue; // added below, so My textures stays last
if (entry.source != TextureSource::Plugin)
continue;
const auto [it, inserted] = pack_section.emplace(entry.pack_name, sections.size());
if (inserted)
sections.push_back({ "pack:" + entry.pack_name, entry.pack_name, {} });
sections[it->second].entries.push_back(&entry);
}
sections.push_back({ "user:", _u8L("My textures"), {} });
for (const TextureLibraryEntry &entry : library)
if (entry.source == TextureSource::User)
sections.back().entries.push_back(&entry);
const float child_indent = std::round(m_imgui->scaled(0.8f));
const Section &built_in = sections.front();
if (section_header(built_in.key, built_in.title, built_in.entries.size(), 0.f, {}, built_in.title)) {
const ImVec2 origin = ImGui::GetCursorScreenPos();
const int count = grid(origin, false);
const int count = grid(origin, built_in.entries, false);
end_grid(origin, count);
if (count == 0)
ImGui::TextDisabled("%s", _u8L("No textures found.").c_str());
}
ImGui::TextDisabled("%s", _u8L("My textures").c_str());
{
size_t subscribed_count = 0;
for (size_t i = 1; i + 1 < sections.size(); ++i)
subscribed_count += sections[i].entries.size();
const std::string subscribed_title = _u8L("Texture libraries");
if (section_header("subscribed:", subscribed_title, subscribed_count, 0.f, {}, subscribed_title)) {
for (size_t i = 1; i + 1 < sections.size(); ++i) {
const Section &pack = sections[i];
const auto [name, attribution] = split_pack_title(pack.title);
if (!section_header(pack.key, name, pack.entries.size(), child_indent, attribution, pack.title))
continue;
const ImVec2 origin = ImGui::GetCursorScreenPos();
const int count = grid(origin, pack.entries, false);
end_grid(origin, count);
}
ImGui::Dummy(ImVec2(0.f, spacing));
const std::string download_label = _u8L("Explore more texture libraries");
if (ImGui::Button(download_label.c_str(), ImVec2(grid_w, frame_h)))
download_clicked = true;
if (ImGui::IsItemHovered())
panel_tooltip(_u8L("Opens OrcaCloud in the Home page to browse for plugins."));
}
const Section &user = sections.back();
if (section_header(user.key, user.title, user.entries.size(), 0.f, {}, user.title)) {
const ImVec2 origin = ImGui::GetCursorScreenPos();
const int count = grid(origin, true);
const int count = grid(origin, user.entries, true);
ImGui::SetCursorScreenPos(tile_pos(origin, count));
if (ImGui::Button("+##import_texture", ImVec2(tile, tile)))
import_clicked = true;
@@ -4272,17 +4569,25 @@ void GLGizmoTextureDisplacement::render_texture_library_popup(const ImVec2 &pane
"bakes from and saved to your personal texture folder, kept separate from the "
"textures shipped with OrcaSlicer."));
end_grid(origin, count + 1);
// Belongs to this section: it describes the import tile and the remove-on-hover tiles, both of
// which live here, so it is hidden with them rather than left describing controls off-screen.
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + grid_w);
ImGui::TextDisabled("%s", _u8L("PNG, JPG or BMP - converted to a height map on import. Hover one of yours to remove it.").c_str());
ImGui::PopTextWrapPos();
}
// Done whether or not the current texture was drawn: one in a collapsed section stays where it is.
m_picker_reveal_selected = false;
ImGui::EndChild();
pop_slim_scrollbar_style();
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + grid_w);
ImGui::TextDisabled("%s", _u8L("PNG, JPG or BMP - converted to a height map on import. Hover one of yours to remove it.").c_str());
ImGui::PopTextWrapPos();
if (chosen || import_clicked)
if (chosen || import_clicked || close_clicked || download_clicked)
ImGui::CloseCurrentPopup();
m_tooltip_anchor_min = prev_anchor_min;
m_tooltip_anchor_max = prev_anchor_max;
ImGui::EndPopup();
// The OrcaCloud plugin hub filtered to resource textures; the pane's session handoff redirects here.
if (download_clicked)
wxGetApp().open_orca_cloud("/app/plugins/plugin-hub?types=resource-textures");
// Only once the loop over the library is done: importing, picking (which reorders the recently used)
// and removing all change the library the loop was iterating.
@@ -6585,19 +6890,6 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
m_imgui->text(text);
m_imgui->pop_bold_font();
};
// Cuts a UTF-8 string down to `max_w` pixels, ending it in "..." when it had to be cut.
const auto ellipsize = [](std::string s, float max_w) {
if (ImGui::CalcTextSize(s.c_str()).x <= max_w)
return s;
while (!s.empty() && ImGui::CalcTextSize((s + "...").c_str()).x > max_w) {
unsigned char c;
do { // a whole code point at a time
c = static_cast<unsigned char>(s.back());
s.pop_back();
} while (!s.empty() && (c & 0xC0) == 0x80);
}
return s + "...";
};
// Label column + a slider filling the rest of the row, value printed inside the track.
// `right_inset` keeps a layer card's inner padding.
const auto slider_label = [&](const wxString &label) {
@@ -6790,52 +7082,11 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
style.WindowPadding.y - style.ItemSpacing.y);
const float body_h = m_panel_body_h > 0.f ? std::min(m_panel_body_h + 1.f, max_body_h) : max_body_h;
// ImGui's stock scrollbar is a wide, square-cornered slab in a tinted track - against this flat panel
// it reads as a raw widget bolted onto the edge. Slim it to a rounded thumb over an invisible track.
const float scrollbar_w = m_imgui->scaled(0.5f);
const ImVec4 grab = dark ? ImVec4(1.f, 1.f, 1.f, 0.26f) : ImVec4(0.f, 0.f, 0.f, 0.26f);
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, scrollbar_w);
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 0.5f * scrollbar_w);
ImGui::PushStyleColor(ImGuiCol_ScrollbarBg, ImVec4(0.f, 0.f, 0.f, 0.f));
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrab, grab);
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabHovered, ImVec4(grab.x, grab.y, grab.z, 0.45f));
ImGui::PushStyleColor(ImGuiCol_ScrollbarGrabActive, ImVec4(grab.x, grab.y, grab.z, 0.65f));
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.f, 0.f, 0.f, 0.f));
// NoScrollWithMouse: the wheel is handled below so the scroll can be eased instead of teleporting
// five text lines per notch, which on blocks this tall lost the reader's place.
push_slim_scrollbar_style(m_imgui->scaled(0.5f), dark);
// NoScrollWithMouse: ease_wheel_scroll() eases the scroll instead of teleporting five text lines per
// notch, which on blocks this tall lost the reader's place.
ImGui::BeginChild("##td_body", ImVec2(panel_w, body_h), false, ImGuiWindowFlags_NoScrollWithMouse);
{
ImGuiIO &io = ImGui::GetIO();
const float scroll_now = ImGui::GetScrollY();
const float scroll_max = ImGui::GetScrollMaxY();
// Anything that moved the scroll without us - dragging the grab, a keyboard/gamepad nav step, the
// content shrinking under a clamped offset - has to re-seed the target, or the easing below would
// immediately drag the view back to where it last animated to.
if (m_panel_scroll_applied < 0.f || std::abs(scroll_now - m_panel_scroll_applied) > 0.5f)
m_panel_scroll_target = scroll_now;
if (io.MouseWheel != 0.f && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows))
m_panel_scroll_target -= io.MouseWheel * ImGui::GetFontSize() * 4.f;
// Whole pixels: ImGui floors whatever SetScrollY() is given, so a fractional target could never be
// reached and the "still gliding" test below would stay true forever, repainting the canvas for good.
m_panel_scroll_target = std::floor(std::clamp(m_panel_scroll_target, 0.f, scroll_max));
const float delta = m_panel_scroll_target - scroll_now;
if (std::abs(delta) >= 1.f) {
// Exponential ease, formulated against the frame time so the glide takes the same wall time
// whether the canvas is running at 30 or 144 fps. The last sub-pixel step would be floored
// away, so land on the target outright once the remainder is that small.
const float t = 1.f - std::exp(-20.f * std::clamp(io.DeltaTime, 1.f / 240.f, 1.f / 15.f));
const float step = (std::abs(delta * t) < 1.f) ? delta : delta * t;
const float next = std::floor(scroll_now + step);
ImGui::SetScrollY(next);
m_panel_scroll_applied = next;
m_parent.set_as_dirty(); // nothing else would redraw mid-glide once the mouse stops
} else {
m_panel_scroll_applied = scroll_now;
}
}
ease_wheel_scroll(m_panel_scroll_target, m_panel_scroll_applied);
// Everything that changes the layer list is deferred until the list is no longer being drawn:
// removing or reordering shifts mv->texture_displacement_layers under the pointers the loop holds,
@@ -7080,8 +7331,9 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
// Opens the texture library for a layer, making it the active one too (after the loop).
const auto open_picker = [&](int slot) {
m_picker_slot = slot;
m_picker_open_request = true;
m_picker_slot = slot;
m_picker_open_request = true;
m_picker_reveal_selected = true;
};
// The six-dot drag icon, drawn into `list` over [x, x + w] x [y, y + h].
const auto draw_drag_icon = [&](ImDrawList *list, float x, float y, float w, float h, ImU32 tint) {
@@ -8076,8 +8328,7 @@ void GLGizmoTextureDisplacement::on_render_input_window(float x, float y, float
// height of everything above, whether or not it is scrolled.
m_panel_body_h = ImGui::GetCursorPosY() - style.ItemSpacing.y;
ImGui::EndChild();
ImGui::PopStyleColor(5);
ImGui::PopStyleVar(2);
pop_slim_scrollbar_style();
// The layer-list changes deferred from the loop above.
if (slot_to_remove >= 0)
@@ -428,6 +428,10 @@ private:
void render_texture_library_popup(const ImVec2 &panel_min, const ImVec2 &panel_max);
int m_picker_slot = -1;
bool m_picker_open_request = false;
// Set when the popup opens, so the next layout scrolls the layer's current texture into view.
bool m_picker_reveal_selected = false;
float m_picker_scroll_target = 0.f; // see ease_wheel_scroll()
float m_picker_scroll_applied = -1.f;
void set_layer_texture(TextureDisplacementLayer &layer, const TextureLibraryEntry &entry);
void import_custom_texture(TextureDisplacementLayer &layer);
@@ -1062,6 +1066,8 @@ private:
// Library textures the picker has shown at least once, keyed by file path (see LibraryTexture).
std::map<std::string, LibraryTexture> m_library_textures;
// Generation of the library the above were cached against; a change clears them.
uint64_t m_library_generation = 0;
// Everything the *unwrap* depends on. update_uv_editor() runs from rebuild_preview(), i.e. on
// every stroke end and every slider release - but depth/tiling/rotation/offset/blend change
@@ -1103,12 +1109,15 @@ private:
// The config key behind m_undocked.
static constexpr const char *UNDOCKED_CONFIG_KEY = "texture_displacement_panel_undocked";
// Smooth scrolling for the panel body (everything between the header and the pinned Bake footer).
// ImGui jumps a fixed number of lines per wheel notch, which on tall layer cards reads as a hard
// jolt rather than a scroll. The wheel is intercepted (ImGuiWindowFlags_NoScrollWithMouse) and
// moves a *target* offset instead; the real scroll is eased toward it over the following frames.
// Smooth scrolling for a child window, called inside it: the panel body (everything between the header
// and the pinned Bake footer) and the texture library's grid. ImGui jumps a fixed number of lines per
// wheel notch, which on tall layer cards reads as a hard jolt rather than a scroll. The child sets
// ImGuiWindowFlags_NoScrollWithMouse, and the wheel moves `target` instead; the real scroll is eased
// toward it over the following frames. `applied` is what the easing wrote last frame, <0 until the
// first one.
void ease_wheel_scroll(float &target, float &applied);
float m_panel_scroll_target = 0.f;
float m_panel_scroll_applied = -1.f; // what the easing wrote last frame; <0 until the first one
float m_panel_scroll_applied = -1.f;
// Last frame's body content height and footer height. The body is a child window that has to be
// given its height before its content is laid out, so it is sized from the previous frame: as tall
// as its content, capped so the footer still fits above the bottom of the canvas.
@@ -57,24 +57,6 @@ const double GizmoObjectManipulation::mm_to_in = 0.0393700787;
const double GizmoObjectManipulation::oz_to_g = 28.34952;
const double GizmoObjectManipulation::g_to_oz = 0.035274;
// Helper function to be used by drop to bed button. Returns lowest point of this
// volume in world coordinate system.
static double get_volume_min_z(const GLVolume* volume)
{
const Transform3f& world_matrix = volume->world_matrix().cast<float>();
// need to get the ModelVolume pointer
const ModelObject* mo = wxGetApp().model().objects[volume->composite_id.object_id];
const ModelVolume* mv = mo->volumes[volume->composite_id.volume_id];
const TriangleMesh& hull = mv->get_convex_hull();
float min_z = std::numeric_limits<float>::max();
for (const stl_vertex& vert : hull.its.vertices) {
min_z = std::min(min_z, Vec3f::UnitZ().dot(world_matrix * vert));
}
return min_z;
}
GizmoObjectManipulation::GizmoObjectManipulation(GLCanvas3D& glcanvas)
: m_glcanvas(glcanvas), m_alignment_helper(glcanvas)
{
@@ -295,12 +277,14 @@ void GizmoObjectManipulation::update_reset_buttons_visibility()
{
const Selection& selection = m_glcanvas.get_selection();
// Assembly view has its own selection and transformations, which drop_selection() doesn't update
m_enable_drop_to_bed = m_glcanvas.get_canvas_type() == GLCanvas3D::ECanvasType::CanvasView3D && selection.can_drop();
if (selection.is_single_full_instance() || selection.is_single_volume_or_modifier()) {
const GLVolume * volume = selection.get_first_volume();
Vec3d rotation;
Vec3d scale;
double min_z = 0.;
if (selection.is_single_full_instance()) {
rotation = volume->get_instance_rotation();
@@ -309,12 +293,10 @@ void GizmoObjectManipulation::update_reset_buttons_visibility()
else {
rotation = volume->get_volume_rotation();
scale = volume->get_volume_scaling_factor();
min_z = get_volume_min_z(volume);
}
m_show_clear_rotation = !rotation.isApprox(m_init_rotation);
m_show_reset_0_rotation = !rotation.isApprox(Vec3d::Zero());
m_show_clear_scale = (m_cache.scale / 100.0f - Vec3d::Ones()).norm() > 0.001;
m_show_drop_to_bed = (std::abs(min_z) > EPSILON);
}
}
@@ -771,6 +753,11 @@ void GizmoObjectManipulation::reset_align_state()
void GizmoObjectManipulation::do_render_move_window(ImGuiWrapper *imgui_wrapper, std::string window_name, float x, float y, float bottom_limit)
{
float scale = m_glcanvas.get_scale();
#ifdef WIN32
scale *= (float)get_dpi_for_window(wxGetApp().GetTopWindow()) / (float)DPI_DEFAULT;
#endif // WIN32
// BBS: GUI refactor: move gizmo to the right
if (abs(last_move_input_window_width) > 0.01f) {
if (x + last_move_input_window_width > m_glcanvas.get_canvas_size().get_width()) {
@@ -842,7 +829,7 @@ void GizmoObjectManipulation::do_render_move_window(ImGuiWrapper *imgui_wrapper,
// Rotation
float unit_size = imgui_wrapper->calc_text_size(MAX_SIZE).x + space_size;
// Four align buttons sit under each axis' input, a space before the next axis, so the inputs widen to hold them.
const float align_icon_size = 16.f * m_glcanvas.get_scale(); // ORCA use 16px icons
const float align_icon_size = 16.f * scale;
const float align_button_size = align_icon_size + 2.f * style.FramePadding.x; // ORCA
if (show_align) {
unit_size = std::max(unit_size, 3.f * align_button_size);
@@ -1072,6 +1059,17 @@ void GizmoObjectManipulation::do_render_move_window(ImGuiWrapper *imgui_wrapper,
GLGizmoUtils::render_tooltip_button(imgui_wrapper, m_glcanvas, m_shortcuts_move, x, y);
// ORCA use existing function for drop and show button on bottom bar to keep gizmo compact
imgui_wrapper->disabled_begin(!m_enable_drop_to_bed);
ImGui::SameLine(0, space_size * 1.5f);
if (imgui_wrapper->button(_L("Drop"))){
wxGetApp().plater()->drop_selection();
UpdateAndShow(true);
}
imgui_wrapper->disabled_end();
if(ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
imgui_wrapper->tooltip(_L("Move the selection along Z so that its lowest point touches the bed."), ImGui::GetFontSize() * 20.0f);
ImGui::SameLine();
GLGizmoUtils::begin_right_aligned_buttons({ _L("Done") });
if (imgui_wrapper->button(_L("Done"))) {
@@ -103,7 +103,7 @@ public:
bool m_show_reset_0_rotation{false};
bool m_show_clear_rotation { false };
bool m_show_clear_scale { false };
bool m_show_drop_to_bed { false };
bool m_enable_drop_to_bed { false };
enum class RotateType { None, Relative, Absolute
};
RotateType m_last_rotate_type{RotateType::None}; // 0:no input 1:relative 2:absolute
+10 -14
View File
@@ -23,27 +23,23 @@ IMToolbarItem::~IMToolbarItem()
}
bool IMToolbarItem::generate_texture()
void IMToolbarItem::set_thumbnail(const ThumbnailData& thumbnail)
{
GLint last_texture;
unsigned m_image_texture{ 0 };
unsigned char* pixels = (unsigned char*)(&image_data[0]);
if (!thumbnail.is_valid())
return;
GLint last_texture;
GLuint id = (GLuint)(intptr_t)texture_id;
glsafe(::glGetIntegerv(GL_TEXTURE_BINDING_2D, &last_texture));
glsafe(::glGenTextures(1, &m_image_texture));
glsafe(::glBindTexture(GL_TEXTURE_2D, m_image_texture));
if (id == 0)
glsafe(::glGenTextures(1, &id));
glsafe(::glBindTexture(GL_TEXTURE_2D, id));
glsafe(::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR));
glsafe(::glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR));
glsafe(::glPixelStorei(GL_UNPACK_ROW_LENGTH, 0));
glsafe(::glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, image_width, image_height, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels));
// Store our identifier
texture_id = (ImTextureID)(intptr_t)m_image_texture;
// Restore state
glsafe(::glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, thumbnail.width, thumbnail.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, thumbnail.pixels.data()));
texture_id = (ImTextureID)(intptr_t)id;
glsafe(::glBindTexture(GL_TEXTURE_2D, last_texture));
return true;
}
void IMToolbar::del_all_item()
+4 -4
View File
@@ -12,6 +12,8 @@
#define DEFAULT_TOOLBAR_BUTTON_HEIGHT 80.0f
namespace Slic3r {
struct ThumbnailData;
namespace GUI {
class IMToolbarItem
@@ -33,11 +35,9 @@ public:
SliceState slice_state;
ImTextureID texture_id { 0 };
std::vector<unsigned char> image_data;
unsigned int image_width;
unsigned int image_height;
bool generate_texture();
// Uploads a valid thumbnail as the texture; the item keeps no copy of its pixels.
void set_thumbnail(const ThumbnailData& thumbnail);
~IMToolbarItem();
};
+5 -3
View File
@@ -505,10 +505,12 @@ bool ImGuiWrapper::update_mouse_data(wxMouseEvent& evt)
io.MouseDown[0] = evt.LeftIsDown();
io.MouseDown[1] = evt.RightIsDown();
io.MouseDown[2] = evt.MiddleIsDown();
float wheel_delta = static_cast<float>(evt.GetWheelDelta());
if (wheel_delta != 0.0f) {
// Only a vertical wheel event with a direction is a wheel step. A macOS trackpad reports sideways motion
// as separate horizontal events, and its wheel events often carry a rotation of 0: wx truncates the
// sub-pixel deltas to whole ones and passes on events with no delta at all. Read as vertical steps,
// either kind scrolls against the gesture, and scrolling up bounces back and forth.
if (evt.GetWheelAxis() == wxMOUSE_WHEEL_VERTICAL && evt.GetWheelRotation() != 0)
io.MouseWheel = evt.GetWheelRotation() > 0 ? 1.f : -1.f;
}
unsigned buttons = (evt.LeftIsDown() ? 1 : 0) | (evt.RightIsDown() ? 2 : 0) | (evt.MiddleIsDown() ? 4 : 0);
m_mouse_buttons = buttons;
+1 -1
View File
@@ -365,7 +365,7 @@ std::vector<NativeCommand> build_command_catalog()
return object_op(wxGetApp().plater(), [](Plater* p) { return !p->is_selection_empty(); }, [](Plater* p) { p->center_selection(); });
});
add_with_icon("obj_drop", L("Drop to Bed"), L("Object"), "toolbar_flatten", [](const std::string&) {
return object_op(wxGetApp().plater(), [](Plater* p) { return !p->is_selection_empty(); }, [](Plater* p) { p->drop_selection(); });
return object_op(wxGetApp().plater(), [](Plater* p) { return p->get_selection().can_drop(); }, [](Plater* p) { p->drop_selection(); });
});
add("obj_fit_volume", L("Scale to Fit Print Volume"), L("Object"), [](const std::string&) {
return object_op(
+14 -3
View File
@@ -19,6 +19,7 @@
#include "libslic3r/GCode/WipeTower.hpp"
#include "libslic3r/CustomGCode.hpp"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/GCode/GCodeStatistics.hpp"
#include <ios>
#include <iomanip>
#include "libslic3r/Arrange.hpp"
@@ -6154,6 +6155,14 @@ int PartPlateList::select_plate(int index)
//wxQueueEvent(m_plater, new SimpleEvent(EVT_GLCANVAS_PLATE_SELECT));
}
// With the other plates hidden, the view follows the selected plate.
if (previous_index != index && m_intialized && m_plater != nullptr && m_plater->is_view3D_shown() && wxGetApp().focus_mode()) {
Camera& camera = m_plater->get_camera();
Vec3d target = camera.get_target();
target.head<2>() = m_plate_list[index]->get_bounding_box().center().head<2>();
camera.set_target(target);
}
if (previous_index != index && m_intialized && m_plater != nullptr && !m_plater->is_loading_project()) {
LifecycleEventContext ctx;
ctx.name = m_plate_list[index]->get_plate_name();
@@ -7063,7 +7072,7 @@ void PartPlateList::postprocess_arrange_polygon(arrangement::ArrangePolygon& arr
/*rendering related functions*/
//render
void PartPlateList::render(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_current, bool only_body, int hover_id, bool render_cali, bool show_grid, bool hide_chrome)
void PartPlateList::render(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_current, bool only_body, int hover_id, bool render_cali, bool show_grid, bool hide_chrome, bool hide_inactive)
{
const std::lock_guard<std::mutex> local_lock(m_plates_mutex);
std::vector<PartPlate*>::iterator it = m_plate_list.begin();
@@ -7078,7 +7087,7 @@ void PartPlateList::render(const Transform3d& view_matrix, const Transform3d& pr
load_icon_textures();
for (it = m_plate_list.begin(); it != m_plate_list.end(); it++) {
int current_index = (*it)->get_index();
if (only_current && (current_index != m_current_plate))
if ((only_current || hide_inactive) && (current_index != m_current_plate))
continue;
if (current_index == m_current_plate) {
PartPlate::HeightLimitMode height_mode = (only_current)?PartPlate::HEIGHT_LIMIT_NONE:m_height_limit_mode;
@@ -7539,7 +7548,7 @@ int PartPlateList::rebuild_plates_after_arrangement(bool recycle_plates, bool ex
return ret;
}
int PartPlateList::store_to_3mf_structure(PlateDataPtrs& plate_data_list, bool with_slice_info, int plate_idx)
int PartPlateList::store_to_3mf_structure(PlateDataPtrs& plate_data_list, bool with_slice_info, int plate_idx, bool with_statistics)
{
int ret = 0;
@@ -7613,6 +7622,8 @@ int PartPlateList::store_to_3mf_structure(PlateDataPtrs& plate_data_list, bool w
}
//parse filament info
plate_data_item->parse_filament_info(m_plate_list[i]->get_slice_result());
if (with_statistics)
plate_data_item->statistics = gcode_statistics_json(*m_plate_list[i]->get_slice_result());
// Record mixed (virtual) filaments actually used on this plate.
// Source is ToolOrdering::used_mixed_filaments (slots that appeared in
+2 -2
View File
@@ -1008,7 +1008,7 @@ public:
/*rendering related functions*/
void on_change_color_mode(bool is_dark) { m_is_dark = is_dark; }
void render(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_current = false, bool only_body = false, int hover_id = -1, bool render_cali = false, bool show_grid = true, bool hide_chrome = false);
void render(const Transform3d& view_matrix, const Transform3d& projection_matrix, bool bottom, bool only_current = false, bool only_body = false, int hover_id = -1, bool render_cali = false, bool show_grid = true, bool hide_chrome = false, bool hide_inactive = false);
void set_render_option(bool bedtype_texture, bool plate_settings);
void set_render_cali(bool value = true) { render_cali_logo = value; }
void render_hover_tooltip() const;
@@ -1070,7 +1070,7 @@ public:
/* load/store releted functions, with_gcode = true and plate_idx = -1, export all gcode
* if with_gcode = true and specify plate_idx, export plate_idx gcode only
*/
int store_to_3mf_structure(PlateDataPtrs& plate_data_list, bool with_slice_info = true, int plate_idx = -1);
int store_to_3mf_structure(PlateDataPtrs& plate_data_list, bool with_slice_info = true, int plate_idx = -1, bool with_statistics = false);
int load_from_3mf_structure(PlateDataPtrs& plate_data_list, int filament_count = 1);
//load gcode files
int load_gcode_files();
+21 -12
View File
@@ -436,6 +436,7 @@ void Plater::show_illegal_characters_warning(wxWindow* parent)
void Plater::mark_plate_toolbar_image_dirty()
{
m_b_plate_toolbar_image_dirty = true;
++m_plate_toolbar_image_version;
}
bool Plater::is_plate_toolbar_image_dirty() const
@@ -18268,16 +18269,20 @@ wxString Plater::get_project_name()
void Plater::update_all_plate_thumbnails(bool force_update)
{
for (int i = 0; i < get_partplate_list().get_plate_count(); i++) {
PartPlate* plate = get_partplate_list().get_plate(i);
ThumbnailsParams thumbnail_params = { {}, false, true, true, true, i};
if (force_update || !plate->thumbnail_data.is_valid()) {
get_view3D_canvas3D()->render_thumbnail(plate->thumbnail_data, plate->plate_thumbnail_width, plate->plate_thumbnail_height, thumbnail_params, Camera::EType::Ortho);
}
if (force_update || !plate->no_light_thumbnail_data.is_valid()) {
get_view3D_canvas3D()->render_thumbnail(plate->no_light_thumbnail_data, plate->plate_thumbnail_width, plate->plate_thumbnail_height, thumbnail_params,
Camera::EType::Ortho, Camera::ViewAngleType::Iso, false, true);
}
for (int i = 0; i < get_partplate_list().get_plate_count(); i++)
update_plate_thumbnails(i, force_update);
}
void Plater::update_plate_thumbnails(int plate_idx, bool force_update)
{
PartPlate* plate = get_partplate_list().get_plate(plate_idx);
ThumbnailsParams thumbnail_params = { {}, false, true, true, true, plate_idx};
if (force_update || !plate->thumbnail_data.is_valid()) {
get_view3D_canvas3D()->render_thumbnail(plate->thumbnail_data, plate->plate_thumbnail_width, plate->plate_thumbnail_height, thumbnail_params, Camera::EType::Ortho);
}
if (force_update || !plate->no_light_thumbnail_data.is_valid()) {
get_view3D_canvas3D()->render_thumbnail(plate->no_light_thumbnail_data, plate->plate_thumbnail_width, plate->plate_thumbnail_height, thumbnail_params,
Camera::EType::Ortho, Camera::ViewAngleType::Iso, false, true);
}
}
@@ -19564,7 +19569,10 @@ void Plater::export_gcode_3mf(bool export_all)
int plate_idx = get_partplate_list().get_curr_plate_index();
if (export_all)
plate_idx = PLATE_ALL_IDX;
export_3mf(output_path, SaveStrategy::Silence | SaveStrategy::SplitModel | SaveStrategy::WithGcode | SaveStrategy::SkipModel, plate_idx); // BBS: silence
SaveStrategy strategy = SaveStrategy::Silence | SaveStrategy::SplitModel | SaveStrategy::WithGcode | SaveStrategy::SkipModel; // BBS: silence
if (wxGetApp().app_config->get_bool("export_statistics"))
strategy = strategy | SaveStrategy::WithStatistics;
export_3mf(output_path, strategy, plate_idx);
RemovableDriveManager& removable_drive_manager = *wxGetApp().removable_drive_manager();
@@ -20418,7 +20426,8 @@ int Plater::export_3mf(const boost::filesystem::path& output_path, SaveStrategy
//BBS: add bbs 3mf logic
PlateDataPtrs plate_data_list;
p->partplate_list.store_to_3mf_structure(plate_data_list, (strategy & SaveStrategy::WithGcode || strategy & SaveStrategy::WithSliceInfo), export_plate_idx);
p->partplate_list.store_to_3mf_structure(plate_data_list, (strategy & SaveStrategy::WithGcode || strategy & SaveStrategy::WithSliceInfo), export_plate_idx,
strategy & SaveStrategy::WithStatistics);
// BBS: backup
PresetBundle& preset_bundle = *wxGetApp().preset_bundle;
+4
View File
@@ -434,6 +434,7 @@ public:
bool is_new_project_and_check_state() { return m_new_project_and_check_state; }
wxString get_project_name();
void update_all_plate_thumbnails(bool force_update = false);
void update_plate_thumbnails(int plate_idx, bool force_update);
void update_obj_preview_thumbnail(ModelObject *, int obj_idx, int vol_idx, std::vector<Slic3r::ColorRGBA> colors, int camera_view_angle_type);
void invalid_all_plate_thumbnails();
void force_update_all_plate_thumbnails();
@@ -1071,6 +1072,8 @@ public:
void mark_plate_toolbar_image_dirty();
bool is_plate_toolbar_image_dirty() const;
void clear_plate_toolbar_image_dirty();
// Bumped each time the plate thumbnails go stale, so each consumer can track it on its own.
unsigned plate_toolbar_image_version() const { return m_plate_toolbar_image_version; }
private:
struct priv;
@@ -1095,6 +1098,7 @@ private:
int m_valid_plates_count { 0 };
int m_check_status = 0; // 0 not check, 1 check success, 2 check failed
bool m_b_plate_toolbar_image_dirty{ true };
unsigned m_plate_toolbar_image_version{ 0 };
void suppress_snapshots();
void allow_snapshots();
+9 -2
View File
@@ -645,7 +645,7 @@ wxBoxSizer *PreferencesDialog::create_item_region_combobox(wxString title, wxStr
combobox->SetSelection(current_region);
return;
} else {
wxGetApp().request_user_logout();
wxGetApp().request_user_logout(ORCA_CLOUD_PROVIDER, /*user_initiated=*/true);
config->set("region", region.ToStdString());
auto area = config->get_country_code();
if (agent) {
@@ -2312,6 +2312,13 @@ void PreferencesDialog::create_items()
auto item_show_unsupported = create_item_checkbox(_L("Show unsupported presets"), _L("Show incompatible/unsupported presets in the printer and filament dropdown lists. These presets cannot be selected."), "show_unsupported_presets");
g_sizer->Add(item_show_unsupported);
auto item_export_statistics = create_item_checkbox(_L("Export statistics with G-code"),
_L("If enabled, exporting a G-code file also writes its statistics as JSON next to it, as <name>.statistics.json, "
"and an exported sliced 3MF file holds them inside: print time, filament and feature totals, "
"and the same per layer and per object."),
"export_statistics");
g_sizer->Add(item_export_statistics);
//// DEVELOPER > Experimental Features
g_sizer->Add(create_item_title(_L("Experimental Features")), 1, wxEXPAND);
@@ -2481,7 +2488,7 @@ wxBoxSizer* PreferencesDialog::create_debug_page()
AppConfig* config = GUI::wxGetApp().app_config;
std::string country_code = config->get_country_code();
if (agent) {
wxGetApp().request_user_logout();
wxGetApp().request_user_logout(ORCA_CLOUD_PROVIDER, /*user_initiated=*/true);
agent->set_country_code(country_code);
}
ConfirmBeforeSendDialog confirm_dlg(this, wxID_ANY, _L("Warning"), ConfirmBeforeSendDialog::VisibleButtons::ONLY_CONFIRM); // ORCA VisibleButtons instead ButtonStyle
+26 -4
View File
@@ -558,15 +558,37 @@ void Selection::center()
return;
}
double Selection::get_drop_min_z() const
{
if (!m_valid)
return 0.;
if (m_mode == Volume)
return get_bounding_box().min.z();
double min_z = DBL_MAX;
for (const auto& [obj_idx, inst_idxs] : m_cache.content) {
if (obj_idx >= (int)m_model->objects.size())
continue; // wipe tower
for (int inst_idx : inst_idxs)
min_z = std::min(min_z, m_model->objects[obj_idx]->get_instance_min_z(inst_idx));
}
return min_z == DBL_MAX ? 0. : min_z;
}
bool Selection::can_drop() const
{
return std::abs(get_drop_min_z()) > -SINKING_Z_THRESHOLD;
}
void Selection::drop()
{
if (std::abs(this->get_bounding_box().min.z()) < -SINKING_Z_THRESHOLD) {
return; // shouldnt happen, but better check anyways, already checked in append_menu_item_drop()
}
const double min_z = get_drop_min_z();
if (std::abs(min_z) < -SINKING_Z_THRESHOLD)
return; // already checked in can_drop()
wxGetApp().plater()->take_snapshot(L("Move object"));
this->move_to_center(Vec3d(0, 0, -this->get_bounding_box().min.z()));
this->move_to_center(Vec3d(0, 0, -min_z));
for (unsigned int i : m_list) {
GLVolume& volume = *(*m_volumes)[i];
+3
View File
@@ -248,6 +248,9 @@ public:
void clone(int numbers = 1);
void center();
void drop();
bool can_drop() const;
// Lowest world Z that drop() moves to the bed; whole objects count model parts only, as auto drop does.
double get_drop_min_z() const;
void center_plate(const int plate_idx);
void set_printable(bool printable);
bool get_auto_drop() const;
+72 -10
View File
@@ -1,6 +1,7 @@
#include "TextureLibrary.hpp"
#include <algorithm>
#include <atomic>
#include <boost/filesystem/operations.hpp>
#include <cstddef>
#include <boost/filesystem/directory.hpp>
@@ -21,6 +22,7 @@
#include <boost/system/error_code.hpp>
#include <vector>
#include <wx/app.h>
#include <wx/gdicmn.h>
#include <utility>
#include <wx/image.h>
@@ -36,6 +38,9 @@
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/plugin/PluginFsUtils.hpp"
#include "slic3r/plugin/host/PluginResourceTextures.hpp"
namespace Slic3r::GUI {
namespace {
@@ -50,7 +55,7 @@ bool is_image_file(const boost::filesystem::path &path)
return ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp";
}
void scan_dir(const boost::filesystem::path &dir, bool is_user, std::vector<TextureLibraryEntry> &out)
void scan_dir(const boost::filesystem::path &dir, TextureSource source, std::vector<TextureLibraryEntry> &out)
{
boost::system::error_code ec;
if (!boost::filesystem::is_directory(dir, ec))
@@ -60,12 +65,43 @@ void scan_dir(const boost::filesystem::path &dir, bool is_user, std::vector<Text
for (boost::filesystem::directory_iterator it(dir, ec), end; it != end && !ec; it.increment(ec)) {
if (!boost::filesystem::is_regular_file(it->path(), ec) || !is_image_file(it->path()))
continue;
out.push_back({ it->path().stem().string(), it->path().string(), is_user });
out.push_back({ it->path().stem().string(), it->path().string(), source });
}
std::sort(out.begin() + first, out.end(),
[](const TextureLibraryEntry &a, const TextureLibraryEntry &b) { return a.name < b.name; });
}
// Appends every loaded pack's textures, ordered by the pack's display name and then by file name. Each
// entry carries the contributing pack's display name, which the picker uses to group a pack's textures
// into one section; a pack name is unique among loaded packs of this capability type, so the name both
// groups the section and keeps its entries contiguous. A pack whose directory fails validation is
// skipped, so one bad pack never hides the others.
void scan_plugin_textures(std::vector<TextureLibraryEntry> &out)
{
std::vector<TextureLibraryEntry> found;
for (const ResourceTextureEntry &pack : PluginResourceTextures::instance().entries()) {
std::string error;
const std::vector<std::string> files =
collect_resource_texture_files(pack.texture_dir, pack.plugin_root, error);
if (!error.empty()) {
BOOST_LOG_TRIVIAL(warning) << "Texture pack '" << pack.display_name << "' was skipped: " << error;
continue;
}
for (const std::string &file : files) {
const boost::filesystem::path path(file);
found.push_back({ path.stem().string(), file, TextureSource::Plugin, pack.display_name });
}
}
std::stable_sort(found.begin(), found.end(), [](const TextureLibraryEntry &a, const TextureLibraryEntry &b) {
if (a.pack_name != b.pack_name)
return a.pack_name < b.pack_name;
return a.name < b.name;
});
for (TextureLibraryEntry &entry : found)
out.push_back(std::move(entry));
}
// True if the image carries any colour at all, i.e. some pixel's channels are not all equal. A
// photo saved as RGB but actually grey should still be stored as a grayscale PNG - it is a third of
// the bytes, and the colour feature keys off has_color(), so storing a grey image as RGB would offer
@@ -208,6 +244,11 @@ bool is_supported_height_map(const std::vector<unsigned char> &bytes)
std::vector<TextureLibraryEntry> g_library;
bool g_library_scanned = false;
// The generation g_library was last built from, so texture_library() can rescan after a pack change.
uint64_t g_library_scan_generation = 0;
// Bumped by plugin callbacks, read by the GUI frame. Atomic so a read never races a write.
std::atomic<uint64_t> g_library_generation{ 0 };
// Writes `bytes` into the user's own texture folder as `stem`.png, uniquified so an existing texture -
// shipped-name collision, or a second edit of the same source - is never overwritten. Returns the new
@@ -255,6 +296,8 @@ const size_t RECENT_MAX = 200;
std::vector<std::string> recent_texture_names()
{
std::vector<std::string> names;
if (wxTheApp == nullptr) // headless or shutting down: there is no app config to read
return names;
if (wxGetApp().app_config == nullptr)
return names;
const std::string value = wxGetApp().app_config->get(RECENT_SECTION, RECENT_KEY);
@@ -269,6 +312,8 @@ std::vector<std::string> recent_texture_names()
void store_recent_texture_names(std::vector<std::string> names)
{
if (wxTheApp == nullptr) // headless or shutting down: there is no app config to write
return;
if (wxGetApp().app_config == nullptr)
return;
if (names.size() > RECENT_MAX)
@@ -279,8 +324,9 @@ void store_recent_texture_names(std::vector<std::string> names)
wxGetApp().app_config->set(RECENT_SECTION, RECENT_KEY, value);
}
// Reorders the user's entries (the tail of `library`) by recent use. Stable, so textures never used keep
// the name order scan_dir() gave them, after every used one.
// Reorders the user's entries by recent use. Stable, so textures never used keep the name order
// scan_dir() gave them, after every used one. Only the user's own range is touched: shipped and pack
// entries must keep the order their scans produced.
void sort_user_textures(std::vector<TextureLibraryEntry> &library)
{
const std::vector<std::string> names = recent_texture_names();
@@ -291,8 +337,11 @@ void sort_user_textures(std::vector<TextureLibraryEntry> &library)
const auto it = rank.find(boost::filesystem::path(e.path).filename().string());
return it == rank.end() ? std::numeric_limits<int>::max() : it->second;
};
const auto first_user = std::find_if(library.begin(), library.end(), [](const TextureLibraryEntry &e) { return e.is_user; });
std::stable_sort(first_user, library.end(),
const auto first_user = std::find_if(library.begin(), library.end(),
[](const TextureLibraryEntry &e) { return e.source == TextureSource::User; });
const auto user_end = std::find_if(first_user, library.end(),
[](const TextureLibraryEntry &e) { return e.source != TextureSource::User; });
std::stable_sort(first_user, user_end,
[&rank_of](const TextureLibraryEntry &a, const TextureLibraryEntry &b) { return rank_of(a) < rank_of(b); });
}
@@ -311,6 +360,16 @@ bool in_user_texture_dir(const std::string &path)
} // namespace
uint64_t texture_library_generation()
{
return g_library_generation.load();
}
void bump_texture_library_generation()
{
g_library_generation.fetch_add(1);
}
std::string user_texture_dir()
{
const boost::filesystem::path dir = boost::filesystem::path(Slic3r::data_dir()) / "textures" / "displacement";
@@ -325,17 +384,20 @@ std::string user_texture_dir()
const std::vector<TextureLibraryEntry> &texture_library(bool force_rescan)
{
if (g_library_scanned && !force_rescan)
const uint64_t generation = texture_library_generation();
if (g_library_scanned && !force_rescan && generation == g_library_scan_generation)
return g_library;
g_library.clear();
scan_dir(boost::filesystem::path(Slic3r::resources_dir()) / "textures" / "displacement", false, g_library);
scan_dir(boost::filesystem::path(Slic3r::resources_dir()) / "textures" / "displacement", TextureSource::Shipped, g_library);
const std::string user_dir = user_texture_dir();
if (!user_dir.empty())
scan_dir(boost::filesystem::path(user_dir), true, g_library);
scan_dir(boost::filesystem::path(user_dir), TextureSource::User, g_library);
sort_user_textures(g_library);
scan_plugin_textures(g_library);
g_library_scanned = true;
g_library_scan_generation = generation;
g_library_scanned = true;
return g_library;
}
+31 -11
View File
@@ -1,6 +1,7 @@
#ifndef slic3r_TextureLibrary_hpp_
#define slic3r_TextureLibrary_hpp_
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
@@ -10,26 +11,45 @@
namespace Slic3r::GUI {
// One selectable height-map texture in the texture-displacement gizmo's texture picker.
// Where a library texture came from. The picker lists each source under its own heading.
enum class TextureSource { Shipped, User, Plugin };
// One selectable height-map texture in the texture-displacement gizmo's texture picker. A texture that
// came from a plugin pack also carries the provenance the picker groups its sections by.
struct TextureLibraryEntry
{
std::string name; // display name (the file's stem, e.g. "Wood Grain")
std::string path; // absolute path on disk
bool is_user; // imported by the user, as opposed to shipped with OrcaSlicer
std::string name; // display name (the file's stem, e.g. "Wood Grain")
std::string path; // absolute path on disk
TextureSource source; // which of the three sources it came from
std::string pack_name; // contributing pack's display name; empty for Shipped and User
};
// Every available height-map texture: the ones shipped in resources/textures/displacement first, sorted
// by name, then the user's own from <data_dir>/textures/displacement - most recently used first (see
// touch_user_texture()), then any never used, by name.
// Every available height-map texture, in the order the picker lists them: the ones shipped in
// resources/textures/displacement first, by name; then the user's own from <data_dir>/textures/displacement,
// most recently used first (see touch_user_texture()) and the rest by name; then the loaded texture
// packs, ordered by pack display name and then by file name.
//
// The two live in separate directories deliberately: an app update replaces the resources tree
// wholesale, so anything the user imported has to sit somewhere that update can never overwrite or
// delete. `is_user` is what the picker uses to show them under separate headings.
// The shipped and user textures live in separate directories deliberately: an app update replaces the
// resources tree wholesale, so anything the user imported has to sit somewhere that update can never
// overwrite or delete. Pack textures are read-only like the shipped ones. Every texture carries its
// `source`; a pack texture also carries `pack_name`, and the picker groups pack textures into one
// collapsible section per pack name. A pack's name is unique among loaded packs of this capability type -
// the loader refuses a collision with "Capability collision for type ... and name ...", see
// PluginManager::check_registry_locked (PluginManager.cpp:211-225) - so the name on its own identifies a
// section and keeps that pack's entries contiguous.
//
// Scanned once and cached. Pass force_rescan after an import, or to pick up a file the user dropped
// into either folder by hand while the app was running.
// into either folder by hand while the app was running. A rescan also happens on its own when the pack
// registry changes, which is what texture_library_generation() tracks.
const std::vector<TextureLibraryEntry> &texture_library(bool force_rescan = false);
// Monotonic counter, bumped whenever the set of loaded texture packs changes. The GUI reads it to
// decide whether texture_library() must rescan, and plugin callbacks bump it. Safe to read from any
// thread at any time.
uint64_t texture_library_generation();
void bump_texture_library_generation();
// Moves one of the user's own textures to the front of the recently-used order texture_library() lists
// them in, and stores that order in the app config so it survives a restart. No-op for anything that is
// not in user_texture_dir().
+2 -1
View File
@@ -1,4 +1,5 @@
#include "WebGuideDialog.hpp"
#include "CloudProvider.hpp"
#include "ConfigWizard.hpp"
#include <algorithm>
@@ -755,7 +756,7 @@ void GuideFrame::OnScriptMessage(wxWebViewEvent &evt)
if (wxGetApp().is_user_login()) {
BOOST_LOG_TRIVIAL(info) << "logout: user_logout on user_guide_finish";
// agent->user_logout();
wxGetApp().request_user_logout();
wxGetApp().request_user_logout(ORCA_CLOUD_PROVIDER, /*user_initiated=*/true);
}
}
}
+303 -6
View File
@@ -36,12 +36,17 @@
#include <wx/textdlg.h>
#include <wx/toplevel.h>
#include <wx/url.h>
#include <wx/uri.h>
#include <slic3r/GUI/Widgets/WebView.hpp>
#include <slic3r/GUI/Widgets/WebViewHostDialog.hpp>
#include <wx/webview.h>
#include <wx/utils.h>
#include <wx/window.h>
#ifdef __linux__
#include <webkit2/webkit2.h>
#endif
#include <wx/infobar.h>
namespace pt = boost::property_tree;
@@ -55,6 +60,13 @@ namespace GUI {
#define LOGIN_INFO_UPDATE_TIMER_ID 10002
// OrcaCloud is a native pane of its own: the site refuses to be framed
// (x-frame-options: DENY / CSP frame-ancestors 'none').
static const char *const kCloudUrl = "https://cloud.orcaslicer.com";
// #LeftBoard in resources/web/homepage/css/home.css is a 262px column plus a 1px right
// border; the homepage keeps that width as the nav column while the cloud pane is open.
static const int kLeftNavWidth = 263;
// The home page's strings (resources/web/homepage), keyed by their data-i18n and T() ids.
static json home_page_strings()
{
@@ -136,7 +148,11 @@ WebViewPanel::WebViewPanel(wxWindow *parent)
m_reset_on_show = WebView::NeedsRecreateOnShow();
SetSizer(topsizer);
topsizer->Add(m_browser, wxSizerFlags().Expand().Proportion(1));
// The homepage shell is the left nav column; when OrcaCloud opens, its page lives in a
// second native pane beside it (the site refuses to be framed).
m_content_sizer = new wxBoxSizer(wxHORIZONTAL);
m_content_sizer->Add(m_browser, wxSizerFlags().Expand().Proportion(1));
topsizer->Add(m_content_sizer, wxSizerFlags().Expand().Proportion(1));
// Log backend information
/* m_browser->GetUserAgent() may lead crash
@@ -292,12 +308,290 @@ void WebViewPanel::create_browser()
m_browser->Hide();
}
// The cloud page gets its own native view, created on first use and kept alive afterwards
// so its session and scroll position survive hiding.
void WebViewPanel::create_cloud_browser()
{
m_cloud_browser = WebView::CreateWebView(this, wxEmptyString);
#ifdef __linux__
// The pane's web session is a cookie (guide §3), and WebKitGTK keeps cookies only in memory
// unless a persistent store is set up, so without this the sign-in is lost on every restart
// (and on every reset_browser). Each wxWebView has its own context, so give the cloud pane its
// own store instead of sharing PrinterWebView's. Mirrors PrinterWebView::create_browser.
if (auto* wv = static_cast<WebKitWebView*>(m_cloud_browser->GetNativeBackend())) {
const auto cookies_path = boost::filesystem::path(data_dir() + "/cache/cloud_cookies.db");
auto* cookie_manager = webkit_web_context_get_cookie_manager(webkit_web_view_get_context(wv));
webkit_cookie_manager_set_persistent_storage(cookie_manager, cookies_path.c_str(),
WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE);
}
#endif
// Webview events are command events, so they would otherwise propagate up to the
// panel's handlers, which drive the homepage view. Keep the cloud pane's events here.
m_cloud_browser->Bind(wxEVT_WEBVIEW_NAVIGATING, &WebViewPanel::OnCloudWebViewEvent, this);
m_cloud_browser->Bind(wxEVT_WEBVIEW_NAVIGATED, &WebViewPanel::OnCloudWebViewEvent, this);
m_cloud_browser->Bind(wxEVT_WEBVIEW_LOADED, &WebViewPanel::OnCloudWebViewEvent, this);
m_cloud_browser->Bind(wxEVT_WEBVIEW_TITLE_CHANGED, &WebViewPanel::OnCloudWebViewEvent, this);
m_cloud_browser->Bind(wxEVT_WEBVIEW_ERROR, &WebViewPanel::OnCloudWebViewEvent, this);
m_cloud_browser->Bind(wxEVT_WEBVIEW_NEWWINDOW, &WebViewPanel::OnCloudWebViewEvent, this);
m_cloud_browser->Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, &WebViewPanel::OnCloudWebViewEvent, this);
m_cloud_browser->Hide();
if (m_content_sizer)
m_content_sizer->Add(m_cloud_browser, wxSizerFlags().Expand().Proportion(1));
}
// The scheme://host[:port] origin of an http(s) URL, lower-cased and with the default port
// dropped, or empty for any other scheme or a malformed URL. Comparing origins (not a string
// prefix) is what keeps a lookalike host such as "https://cloud.orcaslicer.com.evil.com" out.
static wxString http_origin_of(const wxString& url)
{
const wxURI uri(url);
if (!uri.HasScheme() || !uri.HasServer())
return {};
const wxString scheme = uri.GetScheme().Lower();
if (scheme != "http" && scheme != "https")
return {};
wxString origin = scheme + "://" + uri.GetServer().Lower();
const wxString port = uri.GetPort();
if (!port.IsEmpty() && port != (scheme == "https" ? "443" : "80"))
origin += ":" + port;
return origin;
}
// Whether a NAVIGATING event will replace the pane's top-level document rather than load a
// subframe. Edge and macOS report the target frame; WebKitGTK does not (IsTargetMainFrame() is
// always false there), so a user-gesture navigation stands in: a page's own embedded frames load
// without a gesture and stay put, while a user following a link to another site does not.
static bool cloud_navigation_is_top_level(const wxWebViewEvent& evt)
{
#ifdef __WXGTK__
return evt.GetNavigationAction() == wxWEBVIEW_NAV_ACTION_USER;
#else
return evt.IsTargetMainFrame();
#endif
}
// Sends an off-site navigation to the system browser instead of embedding it. Deferred: this runs
// inside a native webview navigation callback, so launching must not re-enter it.
static void open_cloud_url_externally(const wxString& url)
{
if (url.empty())
return;
BOOST_LOG_TRIVIAL(info) << "cloud pane: opening externally " << WebView::RedactUrl(url).ToUTF8().data();
wxGetApp().CallAfter([url] { wxLaunchDefaultBrowser(url); });
}
// Consumed on the cloud view so it never reaches the homepage handlers. {web} pages stay in the
// pane; every other site (OAuth providers included) opens in the system browser (guide §3).
void WebViewPanel::OnCloudWebViewEvent(wxWebViewEvent& evt)
{
const wxEventType event_type = evt.GetEventType();
if (event_type == wxEVT_WEBVIEW_NAVIGATING) {
const wxString url = evt.GetURL();
// Guide §3: keep every {web} page in the pane and open other sites (OAuth providers
// included) in the system browser. Only top-level navigations are diverted, and
// should_divert_cloud_url() excludes same-origin URLs and internal schemes.
if (should_divert_cloud_url(url) && cloud_navigation_is_top_level(evt)) {
evt.Veto();
open_cloud_url_externally(url);
}
} else if (event_type == wxEVT_WEBVIEW_NEWWINDOW) {
const wxString url = evt.GetURL();
if (!url.empty()) {
if (is_cloud_origin(url))
load_cloud_url(url);
else
open_cloud_url_externally(url);
}
evt.Veto();
} else if (event_type == wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED) {
on_cloud_script_message(evt.GetString().utf8_string());
} else if (event_type == wxEVT_WEBVIEW_LOADED || event_type == wxEVT_WEBVIEW_ERROR) {
// A Start URL deferred behind a sign-out (see load_cloud_after_signout).
if (!m_cloud_after_signout_url.IsEmpty()) {
const wxString next = std::exchange(m_cloud_after_signout_url, wxString());
load_cloud_url(next);
}
}
}
// The page asks for a web session by posting JSON through window.wx; only the newest request
// counts (tracked in GUI_App). Nothing here touches the UI directly: the desktop login dialog and
// the network mint must not run on the webview's message-dispatch stack.
void WebViewPanel::on_cloud_script_message(const std::string& body)
{
const json msg = json::parse(body, nullptr, false);
if (msg.is_discarded() || !msg.is_object() || msg.value("command", std::string()) != "request_web_session")
return;
std::string state;
if (msg.contains("data") && msg["data"].is_object())
state = msg["data"].value("state", std::string());
if (state.empty())
return;
wxGetApp().CallAfter([state = std::move(state)] { wxGetApp().request_cloud_web_session(state); });
}
void WebViewPanel::apply_cloud_layout(bool cloud_on)
{
if (!m_content_sizer)
return;
// Cloud mode pins the homepage to the nav column width; otherwise it fills the panel.
if (m_browser) {
if (wxSizerItem* item = m_content_sizer->GetItem(m_browser))
item->SetProportion(cloud_on ? 0 : 1);
// kLeftNavWidth is in DIP: the web view's CSS px are DIP, so scale to physical pixels.
m_browser->SetMinSize(cloud_on ? m_browser->FromDIP(wxSize(kLeftNavWidth, -1)) : wxDefaultSize);
}
Layout();
}
void WebViewPanel::load_cloud_url(const wxString& url)
{
if (url.empty())
return;
// Track the origin of whatever the pane is told to load (Start URL, Complete URL, sign-out or
// the plain cloud fallback), so navigation policy has the right origin from the first load on.
const wxString origin = http_origin_of(url);
if (!origin.IsEmpty())
m_cloud_base_url = origin;
if (!m_cloud_browser)
return;
// Raw LoadURL: WebView::LoadUrl re-serialises through wxURI, which must not alter the
// fragment that carries the one-time handoff token. Log only the redacted URL.
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << WebView::RedactUrl(url).ToUTF8().data();
m_cloud_browser->LoadURL(url);
}
bool WebViewPanel::is_cloud_origin(const wxString& url) const
{
const wxString origin = http_origin_of(url);
return !origin.IsEmpty() && !m_cloud_base_url.IsEmpty() && origin == m_cloud_base_url;
}
bool WebViewPanel::should_divert_cloud_url(const wxString& url) const
{
const wxString origin = http_origin_of(url);
// Unknown cloud origin or a non-web scheme: never divert (the pane must keep working).
if (origin.IsEmpty() || m_cloud_base_url.IsEmpty())
return false;
return origin != m_cloud_base_url;
}
// Explicit sign-out ends the pane's own web session. That session is a cookie JS cannot clear
// (HttpOnly, guide §3/§8) and ClearHistory() is a no-op on WebKitGTK, so drop the pane's stored
// data natively. wx 3.3.2 ignores the type mask on every backend (any non-zero mask clears
// everything) and needs an invalid `since` to delete cookies, so the default "all" call is used.
void WebViewPanel::clear_cloud_storage()
{
if (m_cloud_browser)
m_cloud_browser->ClearBrowsingData(wxWEBVIEW_BROWSING_DATA_ALL);
}
void WebViewPanel::load_cloud_after_signout(const wxString& url)
{
if (m_cloud_pending_signout && !m_cloud_signout_url.IsEmpty()) {
// The pane did not exist when the user signed out, so its persisted session must be
// cleared before the sign-out page runs and `url` loads once that navigation finishes
// (see OnCloudWebViewEvent).
m_cloud_pending_signout = false;
m_cloud_after_signout_url = url;
clear_cloud_storage();
load_cloud_url(m_cloud_signout_url);
return;
}
load_cloud_url(url);
}
void WebViewPanel::schedule_cloud_load(const wxString& url)
{
if (!m_cloud_browser) {
load_cloud_after_signout(url);
return;
}
// CreateWebView queues the "wx" handler add on the view (macOS/Linux); queue the load behind
// it so the page's first scripts already see the bridge (guide §3).
m_cloud_browser->CallAfter([this, url]() { load_cloud_after_signout(url); });
}
void WebViewPanel::cloud_signed_out(const wxString& signout_url)
{
if (signout_url.empty())
return;
m_cloud_signout_url = signout_url;
// The next Start URL must re-seed the pane (it now reflects the signed-out state), so forget
// the previous seed regardless of which branch below runs.
m_cloud_seed_url.clear();
if (m_cloud_browser) {
m_cloud_pending_signout = false;
// Run the sign-out page (ends the server-side session), then clear the pane's stored data.
load_cloud_url(signout_url);
clear_cloud_storage();
} else {
// Pane never opened this run: clear the persisted session on its first open.
m_cloud_pending_signout = true;
}
}
void WebViewPanel::set_cloud_mode(bool on, const wxString& url, const wxString& page_url)
{
if (on) {
// load_cloud_url records the origin from whatever it loads, including the plain cloud
// fallback used when `url` is empty.
if (!m_cloud_browser) {
create_cloud_browser();
m_cloud_seed_url = url;
schedule_cloud_load(url.empty() ? wxString(kCloudUrl) : url);
} else if (!page_url.empty()) {
// The pane already holds a session, so an explicit page loads straight in; the handoff is
// only needed to establish one, not to reach an ordinary page.
load_cloud_url(page_url);
} else if (!url.empty() && url != m_cloud_seed_url) {
// Re-seed only when the Start URL actually changed — a login, an account switch or a
// sign-out changes it; an ordinary re-open does not (guide §8). Reloading an unchanged
// Start URL re-runs the page's auth check on every open, which flashes "Signing you
// in…" and re-requests a session (guide §10: mint when the page asks, not per open).
// The pane is kept alive, so an unchanged open just shows the page it already holds.
m_cloud_seed_url = url;
load_cloud_after_signout(url);
}
m_cloud_browser->Show();
// The homepage shell owns the nav highlight and the cloud-mode layout class. A native open
// (not the page's own GotoCloud) must ask it to switch; the page's SetCloudMode no-ops if it
// already did. OnDocumentLoaded re-applies this when the homepage was still loading.
RunScript("SetCloudMode(true)");
} else if (m_cloud_browser) {
// Closing keeps the pane alive with its session, so re-opening does not have to sign in
// again (guide §10). Nothing is cleared here.
m_cloud_browser->Hide();
}
apply_cloud_layout(on && m_cloud_browser != nullptr);
}
void WebViewPanel::reset_browser()
{
// Rebuild both views the same way, keeping the cloud pane's open/closed state.
const bool cloud_on = m_cloud_browser && m_cloud_browser->IsShown();
m_browser->Destroy(); // also removes it from the sizer
create_browser();
GetSizer()->Add(m_browser, wxSizerFlags().Expand().Proportion(1));
Layout();
m_content_sizer->Insert(size_t(0), m_browser, wxSizerFlags().Expand().Proportion(1));
if (m_cloud_browser) {
m_cloud_browser->Destroy();
m_cloud_browser = nullptr;
create_cloud_browser();
schedule_cloud_load(wxString(kCloudUrl));
m_cloud_browser->Show(cloud_on);
// The rebuilt pane holds no seed, so the next open loads the Start URL again.
m_cloud_seed_url.clear();
}
apply_cloud_layout(cloud_on);
}
bool WebViewPanel::Show(bool show)
@@ -647,7 +941,7 @@ void WebViewPanel::update_mode()
*/
void WebViewPanel::OnNavigationRequest(wxWebViewEvent& evt)
{
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << evt.GetURL().ToUTF8().data();
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << WebView::RedactUrl(evt.GetURL()).ToUTF8().data();
const wxString &url = evt.GetURL();
if (url.StartsWith("File://") || url.StartsWith("file://")) {
if (!url.Contains("/web/homepage/index.html")) {
@@ -693,7 +987,7 @@ void WebViewPanel::OnNavigationComplete(wxWebViewEvent& evt)
{
m_browser->Show();
Layout();
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << evt.GetURL().ToUTF8().data();
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << WebView::RedactUrl(evt.GetURL()).ToUTF8().data();
if (wxGetApp().get_mode() == comDevelop)
wxLogMessage("%s", "Navigation complete; url='" + evt.GetURL() + "'");
UpdateState();
@@ -712,6 +1006,9 @@ void WebViewPanel::OnDocumentLoaded(wxWebViewEvent& evt)
if (wxGetApp().get_mode() == comDevelop)
wxLogMessage("%s", "Document loaded; url='" + evt.GetURL() + "'");
}
// A reloaded homepage loses its cloud-mode class; restore it if the pane is open.
if (m_cloud_browser && m_cloud_browser->IsShown())
RunScript("SetCloudMode(true)");
UpdateState();
SendCloudProvidersInfo();
}
@@ -727,7 +1024,7 @@ void WebViewPanel::OnTitleChanged(wxWebViewEvent &evt)
*/
void WebViewPanel::OnNewWindow(wxWebViewEvent& evt)
{
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << evt.GetURL().ToUTF8().data();
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << ": " << WebView::RedactUrl(evt.GetURL()).ToUTF8().data();
wxString flag = " (other)";
if (evt.GetNavigationAction() == wxWEBVIEW_NAV_ACTION_USER)
+52
View File
@@ -28,6 +28,7 @@
#include "wx/fs_mem.h"
#include "wx/stdpaths.h"
#include <wx/panel.h>
#include <wx/sizer.h>
#include <wx/tbarbase.h>
#include "wx/textctrl.h"
#include <wx/timer.h>
@@ -114,14 +115,65 @@ public:
void update_mode();
// Shows/hides the native OrcaCloud pane beside this homepage nav column. `url` is the
// cloud Start URL ({web}/auth/handoff?next=…&user_id=…), which the page uses to decide
// whether it already has a session; empty falls back to the plain cloud page.
// `page_url` is a full {web} page URL: an already-open pane is navigated straight there,
// without re-running the handoff, which is what an explicit "open this page" wants. Empty
// keeps whatever page the pane holds.
void set_cloud_mode(bool on, const wxString& url = wxString(), const wxString& page_url = wxString());
// True while the OrcaCloud pane exists and is visible.
bool cloud_mode_active() const { return m_cloud_browser && m_cloud_browser->IsShown(); }
// Loads `url` in the cloud pane — the webview that owns the session handoff. Raw LoadURL (no
// wxURI re-serialisation) so the Complete URL's #fragment stays intact; never logged.
void load_cloud_url(const wxString& url);
// Clears the cloud pane's own session on an explicit desktop sign-out. If the pane has not
// been created yet, the sign-out is remembered and applied when it is first opened.
void cloud_signed_out(const wxString& signout_url);
bool Show(bool show = true) override;
private:
void create_browser();
void create_cloud_browser();
void reset_browser();
// Consumes the cloud pane's webview events so they stay off the homepage handlers.
void OnCloudWebViewEvent(wxWebViewEvent& evt);
// True when `url` has the same scheme/host/port as the cloud origin ({web}).
bool is_cloud_origin(const wxString& url) const;
// True when a navigation to `url` must leave the pane: an http(s) URL on another origin than
// the (known) cloud origin. False for non-web schemes and while the origin is unknown.
bool should_divert_cloud_url(const wxString& url) const;
// Parses a request_web_session message and defers it to GUI_App.
void on_cloud_script_message(const std::string& body);
// Loads `url`, first clearing a lazily-required sign-out (see cloud_signed_out).
void load_cloud_after_signout(const wxString& url);
// First load of a freshly created pane: deferred so CreateWebView's queued "wx" handler add
// runs first and the page sees window.wx at load time (guide §3).
void schedule_cloud_load(const wxString& url);
// Clears the pane's stored web data (its cookie-backed session) on an explicit sign-out.
void clear_cloud_storage();
// Re-applies the content sizer's proportions and min sizes for the cloud state.
void apply_cloud_layout(bool cloud_on);
wxWebView* m_browser;
wxWebView* m_cloud_browser{nullptr};
wxBoxSizer* m_content_sizer{nullptr};
wxString m_home_url;
bool m_reset_on_show{false};
// {web} origin (scheme://host[:port]) of the loaded pane. Set from every load, including the
// plain cloud fallback, so navigation policy can keep {web} pages in the pane and send other
// sites to the system browser.
wxString m_cloud_base_url;
// Start URL the pane was last seeded with. An open whose URL matches this is an ordinary
// re-open (nothing changed), so it only shows the pane instead of reloading it (guide §10).
wxString m_cloud_seed_url;
// Sign-out clear the pane must apply once it exists / next loads.
wxString m_cloud_signout_url;
wxString m_cloud_after_signout_url;
bool m_cloud_pending_signout{false};
wxButton * m_button_stop;
wxTextCtrl *m_url;
#if !BBL_RELEASE_TO_PUBLIC
+7 -1
View File
@@ -444,6 +444,12 @@ bool WebView::DownloadAndInstallWebViewRuntime()
return DownloadAndInstallWV2RT() == 0;
}
#endif
wxString WebView::RedactUrl(const wxString& url)
{
const size_t cut = url.find_first_of(wxString("?#"));
return cut == wxString::npos ? url : url.Left(cut);
}
void WebView::LoadUrl(wxWebView * webView, wxString const &url)
{
auto url2 = url;
@@ -451,7 +457,7 @@ void WebView::LoadUrl(wxWebView * webView, wxString const &url)
url2.Replace("\\", "/");
#endif
if (!url2.empty()) { url2 = wxURI(url2).BuildURI(); }
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << url2.ToUTF8();
BOOST_LOG_TRIVIAL(trace) << __FUNCTION__ << RedactUrl(url2).ToUTF8();
webView->LoadURL(url2);
}
+4
View File
@@ -21,6 +21,10 @@ public:
#endif
static void LoadUrl(wxWebView * webView, wxString const &url);
// Returns the URL truncated at the first '?' or '#', so a query/fragment carrying secrets
// (e.g. a session-handoff token) is never written to a log.
static wxString RedactUrl(const wxString& url);
static bool RunScript(wxWebView * webView, wxString const & msg);
// Marks "wx" as registered so CreateWebView's deferred add skips the duplicate.
+8
View File
@@ -485,6 +485,14 @@ void Http::priv::http_perform()
if (!postfields.empty()) {
::curl_easy_setopt(curl, CURLOPT_POSTFIELDS, postfields.c_str());
::curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, postfields.size());
} else if (method == "POST" && form == nullptr && mime == nullptr) {
// A POST with no body. Without CURLOPT_POSTFIELDS, curl takes the request body from
// the read callback, but form_file_read_cb only has a usable user pointer for a PUT
// (set_put_body) or a streamed multipart part (form_add_file); for an empty POST it
// dereferences libcurl's default READDATA as a form_file and crashes. Point curl at a
// zero-length body so the read callback is never used.
::curl_easy_setopt(curl, CURLOPT_POSTFIELDS, postfields.c_str());
::curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, static_cast<curl_off_t>(0));
}
CURLcode res = ::curl_easy_perform(curl);
+146
View File
@@ -109,6 +109,10 @@ constexpr const char* ORCA_CLOUD_PRINTER = "/api/v1/printers";
constexpr const char* ORCA_CLOUD_LOGIN_PATH = "/orcaslicer-login";
// Gateway endpoint that mints a one-time web-session handoff token for the signed-in desktop
// user. The cloud pane reloads the returned token into the webview (see build_web_session_*).
constexpr const char* ORCA_WEB_SESSION_HANDOFF_PATH = "/api/v1/account/web-session-handoff";
constexpr const char* CONFIG_ORCA_API_URL = "orca_api_url";
constexpr const char* CONFIG_ORCA_AUTH_URL = "orca_auth_url";
constexpr const char* CONFIG_ORCA_CLOUD_URL = "orca_cloud_url";
@@ -3019,6 +3023,148 @@ std::string OrcaCloudServiceAgent::get_cloud_login_url(const std::string& langua
return url;
}
// Percent-encodes every byte outside the RFC 3986 unreserved set (A-Za-z0-9-._~), so token values
// survive fragment parsing intact. Deliberately tiny: no URL-encoder dependency.
static std::string percent_encode_unreserved(const std::string& s)
{
static const char* const hex = "0123456789ABCDEF";
std::string out;
out.reserve(s.size());
for (unsigned char c : s) {
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
c == '-' || c == '.' || c == '_' || c == '~') {
out.push_back(static_cast<char>(c));
} else {
out.push_back('%');
out.push_back(hex[c >> 4]);
out.push_back(hex[c & 0x0F]);
}
}
return out;
}
std::string OrcaCloudServiceAgent::build_web_session_start_url(const std::string& next) const
{
if (cloud_base_url.empty())
return "";
// The page replaces a `next` that is not a same-site path with /app/profiles, so pass a
// same-site path (the guide's default) and let it validate.
std::string url = cloud_base_url + "/auth/handoff?next=" +
percent_encode_unreserved(next.empty() ? "/app/profiles" : next);
std::lock_guard<std::mutex> lock(session_mutex);
if (session.logged_in && !session.user_id.empty())
url += "&user_id=" + percent_encode_unreserved(session.user_id);
return url;
}
std::string OrcaCloudServiceAgent::build_web_session_complete_url(const std::string& token_hash,
const std::string& state) const
{
if (cloud_base_url.empty() || token_hash.empty() || state.empty())
return "";
// Fragment (not query) so the one-time token never reaches a server or a log.
return cloud_base_url + "/auth/handoff#token_hash=" + percent_encode_unreserved(token_hash) +
"&state=" + percent_encode_unreserved(state);
}
std::string OrcaCloudServiceAgent::build_web_session_signout_url() const
{
if (cloud_base_url.empty())
return "";
return cloud_base_url + "/auth/handoff?signout=1";
}
// Extracts the machine-readable code from a handoff error body. 401 uses {"error":"<code>"};
// the other statuses use {"error":{"code":"<code>","message":"..."}}.
static std::string handoff_error_code(const std::string& body)
{
const json j = json::parse(body, nullptr, false);
if (j.is_discarded() || !j.is_object() || !j.contains("error"))
return "";
const auto& err = j["error"];
if (err.is_string())
return err.get<std::string>();
if (err.is_object() && err.contains("code") && err["code"].is_string())
return err["code"].get<std::string>();
return "";
}
int OrcaCloudServiceAgent::request_web_session_handoff(std::string& token_hash, std::string& error_code,
unsigned int& http_code)
{
token_hash.clear();
error_code.clear();
http_code = 0;
const std::string url = api_base_url + ORCA_WEB_SESSION_HANDOFF_PATH;
BOOST_LOG_TRIVIAL(trace) << "OrcaCloudServiceAgent: POST " << ORCA_WEB_SESSION_HANDOFF_PATH;
// Refresh the desktop token the usual way before minting (newest access token).
ensure_token_fresh("web_session_handoff");
auto perform = [&]() {
HttpResult result;
try {
auto http = Http::post(url);
http.tls_verify(true);
const std::string token = get_access_token();
auto headers = data_headers();
for (const auto& pair : headers) {
http.header(pair.first, pair.second);
}
if (!token.empty()) {
http.header("Authorization", "Bearer " + token);
}
// No request body: the endpoint identifies the user by the bearer token alone.
http.on_complete([&](std::string resp_body, unsigned resp_status) {
result.success = true;
result.status = resp_status;
result.body = resp_body;
})
.on_error([&](std::string resp_body, std::string error, unsigned resp_status) {
result.success = false;
result.status = resp_status == 0 ? 404 : resp_status;
result.body = resp_body;
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: web-session-handoff HTTP error - " << error;
})
.timeout_max(30)
.perform_sync();
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: web-session-handoff exception - " << e.what();
}
return result;
};
HttpResult res = perform();
// Reuse the shared 401 policy (refresh once, then retry), exactly like http_get/http_post:
// `suppress` is true for a transient refresh failure (keep the session and token, no logout)
// and false when the refresh token was rejected — so a 401 that remains after the refresh
// surfaces through the generic HTTP-error callback and drives the normal desktop logout.
// Every other handoff status (409/429/503/transport) stays silent here, so the page can
// offer "Try again" instead of the desktop showing a generic cloud error.
const bool suppress = resolve_unauthorized(res, perform, "web_session_handoff");
if (!suppress && res.status == 401)
invoke_http_error_callback(res.status, res.body);
http_code = res.status;
if (res.status == 200) {
const json j = json::parse(res.body, nullptr, false);
if (!j.is_discarded() && j.is_object() && j.contains("token_hash") && j["token_hash"].is_string())
token_hash = j["token_hash"].get<std::string>();
} else {
error_code = handoff_error_code(res.body);
}
return (res.success && res.status == 200) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
int OrcaCloudServiceAgent::get_mw_user_preference(std::function<void(std::string)> callback)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_mw_user_preference (stub)";
@@ -203,6 +203,28 @@ public:
// ========================================================================
std::string get_cloud_service_host() override;
std::string get_cloud_login_url(const std::string& language = "") override;
// ========================================================================
// OrcaCloud webview session handoff (OrcaCloud "Webview Session Handoff" guide)
// ========================================================================
// The cloud pane never receives the desktop's access/refresh tokens. Instead the page posts
// request_web_session, the desktop mints a single-use token through the gateway and loads the
// Complete URL; the site then signs itself in. These builders return "" when no cloud URL is
// configured.
//
// Start URL: {web}/auth/handoff?next=<path>[&user_id=<desktop user id>]. `next` defaults to
// /app/profiles; user_id is included only while signed in.
std::string build_web_session_start_url(const std::string& next = std::string()) const;
// Complete URL: {web}/auth/handoff#token_hash=<hash>&state=<state>. Both values URL-encoded.
std::string build_web_session_complete_url(const std::string& token_hash, const std::string& state) const;
// Clears only the webview's own session; loaded on an explicit desktop sign-out.
std::string build_web_session_signout_url() const;
// POST {api}/api/v1/account/web-session-handoff with the desktop access token (no body),
// refreshing once on 401 through the shared policy. On 200 writes the single-use `token_hash`;
// otherwise writes the server's error code (e.g. WEB_SESSION_HANDOFF_UNAVAILABLE) into
// `error_code`. Blocking: call from a worker thread. Never logs the token.
int request_web_session_handoff(std::string& token_hash, std::string& error_code, unsigned int& http_code);
int connect_server() override;
bool is_server_connected() override;
int refresh_connection() override;
+1
View File
@@ -529,6 +529,7 @@ std::string PluginConfig::capability_type_label(PluginCapabilityType type)
case PluginCapabilityType::Visualization: return _u8L("Visualization");
case PluginCapabilityType::Script: return _u8L("Script");
case PluginCapabilityType::SlicingPipeline: return _u8L("Slicing Pipeline");
case PluginCapabilityType::ResourceTexture: return _u8L("Resource Texture");
default: return _u8L("Unknown");
}
}
+73
View File
@@ -9,6 +9,7 @@
#include <boost/filesystem/directory.hpp>
#include <boost/filesystem/file_status.hpp>
#include <boost/log/trivial.hpp>
#include <boost/algorithm/string/case_conv.hpp>
#include <boost/nowide/fstream.hpp>
#include "PluginAuditManager.hpp"
@@ -1255,4 +1256,76 @@ boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::pat
return {};
}
std::vector<std::string> collect_resource_texture_files(const std::string& dir,
const std::string& plugin_root,
std::string& error)
{
namespace fs = boost::filesystem;
// Picker sanity bound, not a security control: the popup draws every entry in its grid.
constexpr size_t kMaxResourceTextureFiles = 64;
std::vector<std::string> files;
error.clear();
if (dir.empty()) {
error = _u8L("Resource texture directory is not set");
return files;
}
if (plugin_root.empty()) {
error = _u8L("Plugin folder could not be determined.");
return files;
}
boost::system::error_code ec;
const fs::path resolved_dir = fs::weakly_canonical(fs::path(dir), ec);
if (ec) {
error = _u8L("Resource texture directory could not be resolved");
return files;
}
if (!fs::is_directory(resolved_dir, ec) || ec) {
error = _u8L("Resource texture path is not a directory");
return files;
}
const fs::path resolved_root = fs::weakly_canonical(fs::path(plugin_root), ec);
if (ec) {
error = _u8L("Plugin folder could not be resolved");
return files;
}
if (!is_inside_allowed_root(resolved_dir, resolved_root)) {
error = _u8L("Resource texture directory is outside the plugin folder");
return files;
}
for (fs::directory_iterator it(resolved_dir, ec), end; it != end && !ec; it.increment(ec)) {
const fs::path& path = it->path();
if (!fs::is_regular_file(path, ec) || ec)
continue;
std::string ext = path.extension().string();
boost::algorithm::to_lower(ext);
if (ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".bmp")
continue;
// Re-check per file: a symlink inside the pack must not resolve outside it.
if (!is_inside_allowed_root(path, resolved_root))
continue;
files.push_back(path.string());
if (files.size() >= kMaxResourceTextureFiles)
break;
}
if (ec) {
error = _u8L("Resource texture directory could not be read");
return {};
}
return files;
}
} // namespace Slic3r
+6
View File
@@ -176,6 +176,12 @@ bool read_wheel_plugin_metadata(const boost::filesystem::path& whl_path, PluginD
// Returns the path to the entry file, or an empty path with error set if zero or multiple candidates.
boost::filesystem::path find_installed_plugin_entry(const boost::filesystem::path& plugin_dir, std::string& error);
// Validated non-recursive .png/.jpg/.jpeg/.bmp files directly inside `dir`, every one contained in
// `plugin_root`. Empty with `error` set on failure.
std::vector<std::string> collect_resource_texture_files(const std::string& dir,
const std::string& plugin_root,
std::string& error);
// Canonical writer: emits the .install_state.json schema for the given state.
bool write_install_state(const boost::filesystem::path& plugin_dir, const PluginInstallState& state);
// Builds a PluginInstallState from the descriptor and delegates to the canonical writer.
+32
View File
@@ -9,6 +9,7 @@
#include "PluginFsUtils.hpp"
#include "PythonInterpreter.hpp"
#include "PythonPluginBridge.hpp"
#include "pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp"
#include "libslic3r/Utils.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "slic3r/GUI/format.hpp"
@@ -387,6 +388,37 @@ bool load(const PluginDescriptor& descriptor,
instance->set_config_ui_available(false);
}
// ResourceTexture capabilities resolve their texture directory here, under the
// GIL this block already holds, and cache the validated result. A resolution
// fault only costs this capability its textures, so it is caught locally like
// has_config_ui() above rather than failing the load.
if (auto* resource_texture = dynamic_cast<ResourceTexturePluginCapability*>(instance.get())) {
try {
const std::string texture_dir = resource_texture->get_texture_dir();
if (texture_dir.empty()) {
resource_texture->set_texture_dir({});
} else {
std::string texture_error;
collect_resource_texture_files(texture_dir, descriptor.plugin_root, texture_error);
if (texture_error.empty()) {
resource_texture->set_texture_dir(texture_dir);
} else {
BOOST_LOG_TRIVIAL(warning)
<< "Plugin capability '" << found.name << "' of plugin '" << descriptor.plugin_key
<< "': texture directory rejected (" << texture_error
<< "); the capability will provide no textures";
resource_texture->set_texture_dir({});
}
}
} catch (const std::exception& ex) {
BOOST_LOG_TRIVIAL(warning)
<< "Plugin capability '" << found.name << "' of plugin '" << descriptor.plugin_key
<< "': get_texture_dir() failed (" << ex.what()
<< "); the capability will provide no textures";
resource_texture->set_texture_dir({});
}
}
capabilities.push_back(std::move(instance));
}
} catch (const std::exception& ex) {
+3
View File
@@ -25,6 +25,7 @@
#include "PyPluginTrampoline.hpp"
#include "pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp"
#include "pluginTypes/pages/PagesPluginCapability.hpp"
#include "pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp"
#include "pluginTypes/script/ScriptPluginCapability.hpp"
#include "pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
#include <pybind11/pytypes.h>
@@ -374,6 +375,7 @@ void bind_python_api(pybind11::module_& m)
.value("Visualization", PluginCapabilityType::Visualization)
.value("Script", PluginCapabilityType::Script)
.value("SlicingPipeline", PluginCapabilityType::SlicingPipeline)
.value("ResourceTexture", PluginCapabilityType::ResourceTexture)
.value("Unknown", PluginCapabilityType::Unknown)
.export_values();
@@ -527,6 +529,7 @@ void bind_python_api(pybind11::module_& m)
PagesPluginCapability::RegisterBindings(m);
ScriptPluginCapability::RegisterBindings(m);
SlicingPipelinePluginCapability::RegisterBindings(m);
ResourceTexturePluginCapability::RegisterBindings(m);
PluginHost::RegisterBindings(m);
BOOST_LOG_TRIVIAL(debug) << "Registered ScriptPluginCapability Python bindings";
+5 -1
View File
@@ -14,7 +14,7 @@
namespace Slic3r {
enum class PluginCapabilityType { PrinterConnection = 0, Pages, Analysis, Importer, Exporter, Visualization, Script, SlicingPipeline, Unknown };
enum class PluginCapabilityType { PrinterConnection = 0, Pages, Analysis, Importer, Exporter, Visualization, Script, SlicingPipeline, ResourceTexture, Unknown };
struct PluginCapabilityId
{
@@ -48,6 +48,7 @@ inline std::string plugin_capability_type_to_string(PluginCapabilityType type)
case PluginCapabilityType::Visualization: return "visualization";
case PluginCapabilityType::Script: return "script";
case PluginCapabilityType::SlicingPipeline: return "slicing-pipeline";
case PluginCapabilityType::ResourceTexture: return "resource-texture";
default: return "unknown";
}
}
@@ -63,6 +64,7 @@ inline std::string plugin_capability_type_display_name(PluginCapabilityType type
case PluginCapabilityType::Visualization: return "Visualization";
case PluginCapabilityType::Script: return "Script";
case PluginCapabilityType::SlicingPipeline: return "Slicing Pipeline";
case PluginCapabilityType::ResourceTexture: return "Resource Texture";
default: return "Unknown";
}
}
@@ -92,6 +94,8 @@ inline PluginCapabilityType plugin_capability_type_from_string(std::string_view
return PluginCapabilityType::Script;
if (lowered == "slicing-pipeline")
return PluginCapabilityType::SlicingPipeline;
if (lowered == "resource-texture")
return PluginCapabilityType::ResourceTexture;
return PluginCapabilityType::Unknown;
}
@@ -0,0 +1,115 @@
#include "PluginResourceTextures.hpp"
#include "slic3r/plugin/PluginDescriptor.hpp"
#include "slic3r/plugin/PluginManager.hpp"
#include "slic3r/plugin/pluginTypes/resourceTexture/ResourceTexturePluginCapability.hpp"
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <string>
#include <utility>
#include <vector>
namespace Slic3r {
PluginResourceTextures &PluginResourceTextures::instance()
{
static PluginResourceTextures registry;
return registry;
}
std::optional<ResourceTextureEntry> PluginResourceTextures::build_entry(const PluginCapabilityId &id) const
{
// Query by id and re-check state; the manager copies the shared_ptr and releases its lock.
auto capability = PluginManager::instance().get_plugin_capability(id, /*only_enabled=*/false);
if (!capability || !capability->is_enabled() || capability->type() != PluginCapabilityType::ResourceTexture)
return std::nullopt;
auto texture_capability = std::dynamic_pointer_cast<ResourceTexturePluginCapability>(capability);
if (!texture_capability)
return std::nullopt;
// An empty directory means resolution failed at load, so the pack contributes nothing.
const std::string &texture_dir = texture_capability->texture_dir();
if (texture_dir.empty())
return std::nullopt;
PluginDescriptor descriptor;
if (!PluginManager::instance().try_get_plugin_descriptor_for_capability(capability->name(),
PluginCapabilityType::ResourceTexture,
descriptor))
return std::nullopt;
ResourceTextureEntry entry;
entry.id = capability->identity();
entry.plugin_key = entry.id.plugin_key;
entry.display_name = capability->name();
entry.texture_dir = texture_dir;
entry.plugin_root = descriptor.plugin_root;
return entry;
}
void PluginResourceTextures::on_plugin_register(const std::string &plugin_key)
{
for (const auto &capability : PluginManager::instance().get_plugin_capabilities(plugin_key, PluginCapabilityType::ResourceTexture)) {
if (capability)
on_cap_register(capability->identity());
}
}
void PluginResourceTextures::on_plugin_deregister(const std::string &plugin_key)
{
std::lock_guard<std::mutex> lock(m_mutex);
for (auto it = m_entries.begin(); it != m_entries.end();) {
if (it->first.plugin_key == plugin_key)
it = m_entries.erase(it);
else
++it;
}
}
void PluginResourceTextures::on_cap_register(const PluginCapabilityId &id)
{
std::optional<ResourceTextureEntry> entry = build_entry(id);
std::lock_guard<std::mutex> lock(m_mutex);
if (entry)
m_entries[entry->id] = std::move(*entry);
}
void PluginResourceTextures::on_cap_deregister(const PluginCapabilityId &id)
{
std::lock_guard<std::mutex> lock(m_mutex);
m_entries.erase(id);
}
void PluginResourceTextures::initial_sweep()
{
std::map<PluginCapabilityId, ResourceTextureEntry> entries;
for (const auto &capability : PluginManager::instance().get_plugin_capabilities("", PluginCapabilityType::ResourceTexture)) {
if (!capability)
continue;
std::optional<ResourceTextureEntry> entry = build_entry(capability->identity());
if (entry)
entries[entry->id] = std::move(*entry);
}
std::lock_guard<std::mutex> lock(m_mutex);
m_entries = std::move(entries);
}
std::vector<ResourceTextureEntry> PluginResourceTextures::entries() const
{
std::lock_guard<std::mutex> lock(m_mutex);
std::vector<ResourceTextureEntry> result;
result.reserve(m_entries.size());
for (const auto &entry : m_entries)
result.push_back(entry.second);
return result;
}
} // namespace Slic3r
@@ -0,0 +1,50 @@
#pragma once
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <map>
#include <mutex>
#include <optional>
#include <string>
#include <vector>
namespace Slic3r {
// One ResourceTexture capability contributing a texture directory to the texture picker.
struct ResourceTextureEntry
{
PluginCapabilityId id;
std::string plugin_key;
std::string display_name;
std::string texture_dir;
std::string plugin_root; // so a scan can re-check containment
};
// Tracks every loaded and enabled ResourceTexture capability by id. Holds strings only: a
// shared_ptr here would keep a Python object alive past Py_Finalize.
class PluginResourceTextures
{
public:
static PluginResourceTextures &instance();
PluginResourceTextures(const PluginResourceTextures &) = delete;
PluginResourceTextures &operator=(const PluginResourceTextures &) = delete;
void on_plugin_register(const std::string &plugin_key);
void on_plugin_deregister(const std::string &plugin_key);
void on_cap_register(const PluginCapabilityId &id);
void on_cap_deregister(const PluginCapabilityId &id);
void initial_sweep();
std::vector<ResourceTextureEntry> entries() const;
private:
PluginResourceTextures() = default;
std::optional<ResourceTextureEntry> build_entry(const PluginCapabilityId &id) const;
mutable std::mutex m_mutex;
std::map<PluginCapabilityId, ResourceTextureEntry> m_entries;
};
} // namespace Slic3r
@@ -0,0 +1,32 @@
#include "ResourceTexturePluginCapability.hpp"
#include "ResourceTexturePluginCapabilityTrampoline.hpp"
#include <pybind11/pybind11.h>
#include <pybind11/pytypes.h>
#include <boost/log/trivial.hpp>
#include <memory>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <string>
namespace py = pybind11;
namespace Slic3r {
void ResourceTexturePluginCapability::RegisterBindings(pybind11::module_& module)
{
BOOST_LOG_TRIVIAL(debug) << "Registering orca.resource_texture bindings";
auto resource_texture = module.def_submodule("resource_texture", "Plugin resource texture API");
py::class_<ResourceTexturePluginCapability, PluginCapabilityInterface,
PyResourceTexturePluginCapabilityTrampoline,
std::shared_ptr<ResourceTexturePluginCapability>>(resource_texture,
"ResourceTexturePluginCapabilityBase")
.def(py::init<>())
.def("get_type", &ResourceTexturePluginCapability::get_type)
.def("get_texture_dir", &ResourceTexturePluginCapability::get_texture_dir);
}
} // namespace Slic3r
@@ -0,0 +1,28 @@
#pragma once
#include "../../PythonPluginInterface.hpp"
#include "pybind11/pybind11.h"
#include <string>
#include <utility>
namespace Slic3r {
class ResourceTexturePluginCapability : public PluginCapabilityInterface
{
public:
static void RegisterBindings(pybind11::module_& module);
PluginCapabilityType get_type() const override { return PluginCapabilityType::ResourceTexture; }
// Python contract: the directory the host scans for textures. Resolved once at materialization
// under the GIL; never called from the GUI. Overridden in Python through the trampoline.
virtual std::string get_texture_dir() const = 0;
// C++-only read of the validated directory cached by the loader; empty if resolution failed.
const std::string& texture_dir() const { return m_texture_dir; }
void set_texture_dir(std::string dir) { m_texture_dir = std::move(dir); }
private:
std::string m_texture_dir;
};
} // namespace Slic3r
@@ -0,0 +1,30 @@
#pragma once
#include "ResourceTexturePluginCapability.hpp"
#include "../../PyPluginTrampoline.hpp"
#include <pybind11/pybind11.h>
#include <pybind11/pytypes.h>
#include <string>
#include "slic3r/plugin/PythonPluginInterface.hpp"
namespace Slic3r {
class PyResourceTexturePluginCapabilityTrampoline :
public PyPluginCommonTrampoline<ResourceTexturePluginCapability>
{
public:
using PyPluginCommonTrampoline<ResourceTexturePluginCapability>::PyPluginCommonTrampoline;
std::string get_texture_dir() const override
{
ORCA_PY_OVERRIDE_AUDITED(
[] {},
PYBIND11_OVERRIDE_PURE,
std::string,
ResourceTexturePluginCapability,
get_texture_dir);
}
};
} // namespace Slic3r
+1
View File
@@ -9,6 +9,7 @@ add_executable(${_TEST_NAME}_tests
test_fill.cpp
test_flow.cpp
test_gcode_processor.cpp
test_gcode_statistics.cpp
test_gcode_timing.cpp
test_gcode.cpp
test_gcodeprocessor.cpp
+88
View File
@@ -16,6 +16,7 @@
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/Point.hpp"
#include <cstddef>
#include <fstream>
#include <ios>
#include <string>
@@ -50,6 +51,93 @@ TEST_CASE("Klipper object labels name each copy without the characters Klipper c
}
}
// Firmwares read the compatibility header lines (Anycubic's GoKlipper heats the bed with
// first_layer_bed_temperature), so they must describe the filament the plate prints, not filament 1.
TEST_CASE("The G-code header names the first layer temperatures of the filament the plate prints", "[GCode]")
{
const std::string formula = GENERATE("by_first_filament", "by_highest_temp");
DYNAMIC_SECTION("bed_temperature_formula " << formula) {
// Filament 1: bed 60, nozzle 200. Filament 2: bed 100, nozzle 250. Everything prints with filament 2.
DynamicPrintConfig config = Test::multifilament_config(2, {
{"bed_temperature_formula", formula},
// Authored 1..n as PresetBundle writes it; the all-1s default collapses every per-filament value
// to filament 1.
{"filament_self_index", "1,2"},
{"filament_extruder_variant", "Direct Drive Standard;Direct Drive Standard"},
{"enable_prime_tower", 0},
{"nozzle_temperature_initial_layer", "200,250"},
{"nozzle_temperature", "200,250"},
{"supertack_plate_temp_initial_layer", "60,100"},
{"cool_plate_temp_initial_layer", "60,100"},
{"textured_cool_plate_temp_initial_layer", "60,100"},
{"eng_plate_temp_initial_layer", "60,100"},
{"hot_plate_temp_initial_layer", "60,100"},
{"textured_plate_temp_initial_layer", "60,100"},
{"sparse_infill_filament_id", 2},
{"internal_solid_filament_id", 2},
{"top_surface_filament_id", 2},
{"bottom_surface_filament_id", 2},
{"outer_wall_filament_id", 2},
{"inner_wall_filament_id", 2},
{"skirt_loops", 0},
{"brim_type", "no_brim"},
});
const std::string gcode = Test::slice({Test::cube(20)}, config);
const auto header_value = [&gcode](const std::string& key) {
const std::string token = "; " + key + " = ";
const size_t pos = gcode.find(token);
REQUIRE(pos != std::string::npos);
return std::stoi(gcode.substr(pos + token.size()));
};
CHECK(header_value("first_layer_bed_temperature") == 100);
CHECK(header_value("first_layer_temperature") == 250);
}
}
// Printing by object reassigns the initial filament to each object's first filament while it emits the objects, so
// the header must still name the filament the print starts with: the firmware heats the bed for the first object.
TEST_CASE("The G-code header names the first object's filament when printing by object", "[GCode]")
{
// Filament 1: bed 60, nozzle 200. Filament 2: bed 100, nozzle 250. Object 1 prints with filament 2, object 2
// with filament 1.
DynamicPrintConfig config = Test::multifilament_config(2, {
{"print_sequence", "by object"},
{"bed_temperature_formula", "by_first_filament"},
{"filament_self_index", "1,2"},
{"filament_extruder_variant", "Direct Drive Standard;Direct Drive Standard"},
{"enable_prime_tower", 0},
{"nozzle_temperature_initial_layer", "200,250"},
{"nozzle_temperature", "200,250"},
{"supertack_plate_temp_initial_layer", "60,100"},
{"cool_plate_temp_initial_layer", "60,100"},
{"textured_cool_plate_temp_initial_layer", "60,100"},
{"eng_plate_temp_initial_layer", "60,100"},
{"hot_plate_temp_initial_layer", "60,100"},
{"textured_plate_temp_initial_layer", "60,100"},
{"skirt_loops", 0},
{"brim_type", "no_brim"},
});
const std::string gcode = Test::slice_with_object_overrides({Test::cube(20), Test::cube(20)}, config,
{{{"extruder", "2"}}, {{"extruder", "1"}}});
// Filament 2 starts and filament 1 follows, so the last object's filament differs from the first one's.
const size_t first_t1 = gcode.find("\nT1");
const size_t first_t0 = gcode.find("\nT0");
REQUIRE(first_t1 != std::string::npos);
REQUIRE(first_t0 != std::string::npos);
REQUIRE(first_t1 < first_t0);
const auto header_value = [&gcode](const std::string& key) {
const std::string token = "; " + key + " = ";
const size_t pos = gcode.find(token);
REQUIRE(pos != std::string::npos);
return std::stoi(gcode.substr(pos + token.size()));
};
CHECK(header_value("first_layer_bed_temperature") == 100);
CHECK(header_value("first_layer_temperature") == 250);
}
TEST_CASE("Object label comments number each object and copy with or without exclude object", "[GCode]")
{
// repetier has no exclude-object commands, so set_object_info() writes nothing for it.
+124
View File
@@ -0,0 +1,124 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "libslic3r/Config.hpp"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/GCode/GCodeStatistics.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/Print.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Utils.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
#include <nlohmann/json.hpp>
#include <cstddef>
#include <initializer_list>
#include <set>
#include <string>
#include <vector>
using namespace Slic3r;
TEST_CASE("The statistics of a sliced plate add up per layer, feature and object instance", "[GCodeStatistics]")
{
// A 20 mm cube at 0.2 mm layers prints 100 of them; without skirt or brim all its filament goes to its instances.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({ { "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "skirt_loops", 0 },
{ "brim_type", "no_brim" },
{ "filament_density", 1.25 } });
Print print;
Model model;
Test::init_print({ Test::cube(20) }, print, model, config, nullptr, true, 2);
GCodeProcessorResult result;
Test::gcode(print, &result);
ScopedTemporaryFile file(".json");
export_gcode_statistics(result, file.string());
std::string text;
load_string_file(file.path(), text);
const nlohmann::json statistics = nlohmann::json::parse(text);
const nlohmann::json &summary = statistics["summary"];
const nlohmann::json &layers = statistics["layers"];
REQUIRE(layers.size() == 100);
CHECK(summary["layer_count"] == 100);
CHECK_THAT(layers.front()["z"].get<double>(), Catch::Matchers::WithinAbs(0.2, 1e-3));
CHECK_THAT(layers.back()["z"].get<double>(), Catch::Matchers::WithinAbs(20., 1e-3));
double time = 0., volume = 0.;
for (const nlohmann::json &layer : layers) {
time += layer["time"].get<double>();
volume += layer["volume"].get<double>();
double features_time = 0.;
for (const auto &[name, feature] : layer["features"].items())
features_time += feature["time"].get<double>();
CHECK(features_time <= layer["time"].get<double>() + 1e-3);
}
// Each value is rounded to the millisecond and the cubic millimeter.
CHECK_THAT(time, Catch::Matchers::WithinAbs(summary["time"].get<double>(), 0.1));
CHECK_THAT(volume, Catch::Matchers::WithinAbs(summary["volume"].get<double>(), 0.1));
CHECK_THAT(summary["weight"].get<double>(), Catch::Matchers::WithinRel(summary["volume"].get<double>() * 1.25 / 1000., 1e-4));
CHECK(summary.contains("filament_cost"));
// The filament splits by use as the preview's table does.
REQUIRE(summary["filaments"].size() == 1);
const nlohmann::json &filament = summary["filaments"].front();
double used = 0.;
for (const char *use : { "model", "support", "prime_tower", "flush" })
used += filament[use]["volume"].get<double>();
CHECK_THAT(used, Catch::Matchers::WithinAbs(filament["volume"].get<double>(), 0.01));
const nlohmann::json &features = summary["features"];
CHECK(features.contains("Outer wall"));
CHECK(features.contains("Sparse infill"));
double model_volume = 0.;
for (const auto &[name, feature] : features.items())
if (name != "Custom")
model_volume += feature["volume"].get<double>();
const nlohmann::json &objects = statistics["objects"];
REQUIRE(objects.size() == 2);
double objects_volume = 0.;
for (size_t i = 0; i < objects.size(); ++i) {
CHECK(objects[i]["name"] == model.objects.front()->name);
CHECK(objects[i]["instance"] == int(i));
CHECK(objects[i]["support_volume"].get<double>() == 0.);
objects_volume += objects[i]["volume"].get<double>();
}
CHECK_THAT(objects_volume, Catch::Matchers::WithinAbs(model_volume, 0.1));
}
TEST_CASE("Copies sliced as objects of their own keep their number in the model object", "[GCodeStatistics]")
{
// A copy of another scale slices as a print object of its own.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({ { "skirt_loops", 0 }, { "brim_type", "no_brim" } });
Print print;
Model model;
Test::init_print({ Test::cube(20) }, print, model, config, nullptr, true, 2);
model.objects.front()->instances.back()->set_scaling_factor(Vec3d(0.5, 0.5, 0.5));
model.objects.front()->ensure_on_bed();
print.apply(model, config);
REQUIRE(print.objects().size() == 2);
GCodeProcessorResult result;
Test::gcode(print, &result);
const nlohmann::json objects = nlohmann::json::parse(gcode_statistics_json(result))["objects"];
std::set<int> instances;
for (const nlohmann::json &object : objects)
instances.insert(object["instance"].get<int>());
CHECK(instances == std::set<int>{ 0, 1 });
}
TEST_CASE("The statistics file takes the G-code's name with the statistics extension", "[GCodeStatistics]")
{
CHECK(gcode_statistics_path("plate_1.gcode") == "plate_1.statistics.json");
CHECK(gcode_statistics_path("out/Cube_PLA_1h.gcode") == "out/Cube_PLA_1h.statistics.json");
}
+38
View File
@@ -410,6 +410,44 @@ static bool load_project(const std::string& path, Model& model, const ScopedTemp
return loaded;
}
TEST_CASE("A plate's G-code statistics are stored next to its G-code", "[3mf]")
{
const bool with_statistics = GENERATE(false, true);
ScopedTemporaryFile gcode(".gcode");
save_string_file(gcode.path(), "G1 X10 Y10 E1\n");
Model model;
ModelObject* object = model.add_object();
object->add_volume(make_cube(10., 10., 10.));
object->add_instance();
ScopedTemporaryDir backup_dir("orca_statistics_src");
model.set_backup_path(backup_dir.string());
// A sliced plate's info reads the printer settings.
DynamicPrintConfig cfg = DynamicPrintConfig::full_print_config();
PlateData plate;
plate.plate_index = 0;
plate.gcode_file = gcode.string();
plate.is_sliced_valid = true;
if (with_statistics)
plate.statistics = R"({ "version": 1 })";
ScopedTemporaryFile file(".3mf");
const std::string path = file.string();
StoreParams sp;
sp.path = path.c_str();
sp.model = &model;
sp.config = &cfg;
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence | SaveStrategy::WithGcode;
sp.plate_data_list.push_back(&plate);
REQUIRE(store_bbs_3mf(sp));
std::string stored;
REQUIRE(read_cad_recipe_entry(path, stored, "Metadata/plate_1.gcode"));
CHECK(read_cad_recipe_entry(path, stored, "Metadata/plate_1.statistics.json") == with_statistics);
if (with_statistics)
CHECK(stored == plate.statistics);
}
TEST_CASE("A project with a plate id below 1 fails to load", "[3mf][Regression]")
{
const int plate_id = GENERATE(0, -1);
+2
View File
@@ -30,6 +30,7 @@ add_executable(${_TEST_NAME}_tests
test_printer_agent.cpp
test_plugin_install.cpp
test_plugin_lifecycle.cpp
test_plugin_resource_texture.cpp
test_plugin_printer_agent.cpp
test_printhost.cpp
test_slicing_pipeline_bindings.cpp
@@ -42,6 +43,7 @@ add_executable(${_TEST_NAME}_tests
test_texture_color_palette.cpp
test_texture_island_view.cpp
test_file_url.cpp
test_imgui_wrapper.cpp
test_user_manager.cpp
../fff_print/test_helpers.cpp
)
+61
View File
@@ -0,0 +1,61 @@
// How ImGuiWrapper turns wx mouse events into ImGui input. ImGuiWrapper lives in libslic3r_gui; this is
// the suite that links it. Same Windows include prologue as test_texture_color_palette.cpp (wx pulls in
// <windows.h>; keep WIN32_LEAN_AND_MEAN / NOMINMAX ahead of it).
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <imgui/imgui.h>
#include <wx/event.h>
#include "slic3r/GUI/ImGuiWrapper.hpp"
using Catch::Matchers::WithinAbs;
using Slic3r::GUI::ImGuiWrapper;
namespace {
// ImGui's wheel after one wheel event, as wx on macOS builds it: a delta of 10 and the rotation in whole
// points of trackpad travel.
float wheel_after(int rotation, wxMouseWheelAxis axis)
{
ImGuiWrapper imgui;
imgui.set_display_size(800.f, 600.f); // update_mouse_data() ignores input until the display has a size
wxMouseEvent evt(wxEVT_MOUSEWHEEL);
evt.m_wheelRotation = rotation;
evt.m_wheelDelta = 10;
evt.m_wheelAxis = axis;
imgui.update_mouse_data(evt);
return ImGui::GetIO().MouseWheel;
}
} // namespace
TEST_CASE("a vertical wheel event steps ImGui's wheel in its own direction", "[ImGuiWrapper]")
{
const int rotation = GENERATE(1, 7, 120, -1, -7, -120);
CHECK_THAT(wheel_after(rotation, wxMOUSE_WHEEL_VERTICAL), WithinAbs(rotation > 0 ? 1.0 : -1.0, 0.0));
}
// Both kinds arrive from a macOS trackpad while scrolling; see ImGuiWrapper::update_mouse_data().
TEST_CASE("a wheel event with no rotation or on the horizontal axis does not step ImGui's wheel", "[ImGuiWrapper][Regression]")
{
const auto [rotation, axis] = GENERATE(table<int, wxMouseWheelAxis>({
{ 0, wxMOUSE_WHEEL_VERTICAL },
{ 5, wxMOUSE_WHEEL_HORIZONTAL },
{ -5, wxMOUSE_WHEEL_HORIZONTAL },
}));
CHECK_THAT(wheel_after(rotation, axis), WithinAbs(0.0, 0.0));
}
@@ -0,0 +1,941 @@
// ResourceTexture plugin type: directory validation, the host registry's lifecycle, the texture
// listing's plugin section, hostile get_texture_dir() overrides, decode parity and the .3mf round
// trip without the pack installed.
//
// The GUI wiring lives in GUI_App::init_plugin_gui_wiring; the test binary has no app, so the same
// registry callbacks are installed here. As in test_plugin_lifecycle.cpp, each Catch2 test case runs
// in its own process (catch_discover_tests), so the PluginManager and interpreter come up at most once
// per test.
//
// Same Windows include prologue as test_texture_color_palette.cpp: wx pulls in <windows.h>, so keep
// WIN32_LEAN_AND_MEAN / NOMINMAX ahead of the Catch2 headers.
#include "test_utils.hpp"
#include <slic3r/plugin/PluginDescriptor.hpp>
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
#include <boost/filesystem.hpp>
#include <boost/filesystem/operations.hpp>
#include <boost/filesystem/path.hpp>
#include <boost/system/error_code.hpp>
#include <catch2/catch_all.hpp>
#include <catch2/catch_message.hpp>
#include <catch2/catch_test_macros.hpp>
#include <algorithm>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <fstream>
#include <initializer_list>
#include <ios>
#include <iterator>
#include <memory>
#include <string>
#include <vector>
#include <wx/image.h>
#include "libslic3r/Config.hpp"
#include "libslic3r/Format/bbs_3mf.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/PNGReadWrite.hpp"
#include "libslic3r/Preset.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/Semver.hpp"
#include "libslic3r/TextureDisplacement.hpp"
#include "libslic3r/TriangleMesh.hpp"
#include "libslic3r/Utils.hpp"
#include "libslic3r/Zipper.hpp"
#include "slic3r/GUI/TextureLibrary.hpp"
#include "slic3r/plugin/PluginFsUtils.hpp"
#include "slic3r/plugin/PluginManager.hpp"
#include "slic3r/plugin/PythonInterpreter.hpp"
#include "slic3r/plugin/PythonPluginInterface.hpp"
#include "slic3r/plugin/host/PluginResourceTextures.hpp"
#include "plugin_test_utils.hpp"
using namespace Slic3r;
namespace fs = boost::filesystem;
namespace {
// Brings the plugin system up, and tears it down explicitly at the end of the test, as
// test_plugin_lifecycle.cpp does. Declare this FIRST in a test so it is destroyed last.
struct ScopedPluginManager
{
// Before initialize(): the interpreter creates {data_dir}/python/packages and {data_dir}/log.
ScopedDataDir python_data_dir{"texture-python"};
bool initialized = false;
ScopedPluginManager() { initialized = PluginManager::instance().initialize(); }
~ScopedPluginManager()
{
PluginManager::instance().shutdown();
PythonInterpreter::instance().shutdown();
}
};
// The app installs these in GUI_App::init_plugin_gui_wiring. Without an app the callbacks run inline
// on the load worker, which is what lets the tests observe the registry right after the wait.
void install_resource_texture_wiring()
{
static bool installed = false;
if (installed)
return;
installed = true;
PluginManager& manager = PluginManager::instance();
manager.subscribe_on_load_callback([](const std::string& plugin_key) {
PluginResourceTextures::instance().on_plugin_register(plugin_key);
GUI::bump_texture_library_generation();
});
manager.subscribe_on_unload_callback([](const std::string& plugin_key) {
PluginResourceTextures::instance().on_plugin_deregister(plugin_key);
GUI::bump_texture_library_generation();
});
manager.subscribe_on_capability_load_callback([](const PluginCapabilityId& capability) {
if (capability.type != PluginCapabilityType::ResourceTexture)
return;
PluginResourceTextures::instance().on_cap_register(capability);
GUI::bump_texture_library_generation();
});
manager.subscribe_on_capability_unload_callback([](const PluginCapabilityId& capability) {
if (capability.type != PluginCapabilityType::ResourceTexture)
return;
PluginResourceTextures::instance().on_cap_deregister(capability);
GUI::bump_texture_library_generation();
});
}
// A minimal resource-texture plugin. `display_name` is the PEP723 package name and, unless
// `capability_name` is given, the capability's get_name() too; pass `capability_name` to keep the
// package name and the capability name independent.
std::string make_resource_texture_source(const std::string& display_name,
const std::string& get_texture_dir_body,
const std::string& capability_name = "")
{
const std::string cap_name = capability_name.empty() ? display_name : capability_name;
return std::string{}
+ "# /// script\n"
+ "# requires-python = \">=3.12\"\n"
+ "# dependencies = []\n"
+ "#\n"
+ "# [tool.orcaslicer.plugin]\n"
+ "# name = \"" + display_name + "\"\n"
+ "# description = \"Resource texture pack fixture.\"\n"
+ "# author = \"tests\"\n"
+ "# version = \"1.0.0\"\n"
+ "# type = \"resource-texture\"\n"
+ "# ///\n"
+ "import os\n"
+ "import orca\n"
+ "\n\n"
+ "class PackTextures(orca.resource_texture.ResourceTexturePluginCapabilityBase):\n"
+ " def get_name(self):\n"
+ " return \"" + cap_name + "\"\n"
+ "\n"
+ " def get_texture_dir(self):\n"
+ " " + get_texture_dir_body + "\n"
+ "\n\n"
+ "@orca.plugin\n"
+ "class TexturePack(orca.base):\n"
+ " def register_capabilities(self):\n"
+ " orca.register_capability(PackTextures)\n";
}
std::string default_texture_dir_body()
{
return "return os.path.join(os.path.dirname(__file__), \"textures\")";
}
// Writes {data_dir}/orca_plugins/<stem>/<stem>.py and returns the plugin directory.
fs::path write_plugin(const ScopedDataDir& data_dir_guard, const std::string& stem, const std::string& source)
{
const fs::path plugin_dir = data_dir_guard.plugins_dir() / stem;
fs::create_directories(plugin_dir);
std::ofstream out((plugin_dir / (stem + ".py")).string(), std::ios::binary);
out << source;
out.close();
return plugin_dir;
}
bool load_and_wait(PluginManager& manager, const std::string& plugin_key, std::string& error)
{
manager.load_plugin(plugin_key, /*skip_deps=*/true);
return manager.wait_for_plugin_load(plugin_key, std::chrono::seconds(120), error);
}
// The Plugin-sourced slice of the texture listing, which is exactly what the picker's third heading
// draws. Pass force to call texture_library(true); without it the listing is reused unless a pack
// callback moved the generation.
std::vector<GUI::TextureLibraryEntry> plugin_textures(bool force)
{
std::vector<GUI::TextureLibraryEntry> found;
for (const GUI::TextureLibraryEntry& entry : GUI::texture_library(force))
if (entry.source == GUI::TextureSource::Plugin)
found.push_back(entry);
return found;
}
void write_text_file(const fs::path& path, const std::string& content = "x")
{
fs::create_directories(path.parent_path());
std::ofstream out(path.string(), std::ios::binary);
out << content;
}
std::vector<unsigned char> read_file_bytes(const fs::path& path)
{
std::ifstream ifs(path.string(), std::ios::binary);
return std::vector<unsigned char>(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
}
void write_bytes(const fs::path& path, const std::vector<unsigned char>& bytes)
{
fs::create_directories(path.parent_path());
std::ofstream out(path.string(), std::ios::binary);
out.write(reinterpret_cast<const char*>(bytes.data()), std::streamsize(bytes.size()));
}
void write_gray_png(const fs::path& path, uint8_t value, size_t w = 4, size_t h = 4)
{
fs::create_directories(path.parent_path());
const std::vector<uint8_t> pixels(w * h, value);
REQUIRE(Slic3r::png::write_gray_to_file(path.string(), w, h, pixels));
}
// A 24-bit gray BMP (R == G == B), synthesised without an image toolkit. Rows are bottom-up and
// padded to four bytes; the image is uniform, so the orientation does not matter.
std::vector<unsigned char> make_gray_bmp(int width, int height, uint8_t value)
{
const int row_bytes = ((width * 3 + 3) / 4) * 4;
const uint32_t pixel_bytes = uint32_t(row_bytes) * uint32_t(height);
const uint32_t file_size = 14 + 40 + pixel_bytes;
std::vector<unsigned char> bmp(file_size, 0);
const auto put16 = [&bmp](size_t at, uint16_t v) {
bmp[at] = uint8_t(v);
bmp[at + 1] = uint8_t(v >> 8);
};
const auto put32 = [&bmp](size_t at, uint32_t v) {
bmp[at] = uint8_t(v);
bmp[at + 1] = uint8_t(v >> 8);
bmp[at + 2] = uint8_t(v >> 16);
bmp[at + 3] = uint8_t(v >> 24);
};
bmp[0] = 'B';
bmp[1] = 'M';
put32(2, file_size);
put32(10, 54); // pixel data offset
put32(14, 40); // BITMAPINFOHEADER size
put32(18, uint32_t(width));
put32(22, uint32_t(height));
put16(26, 1); // planes
put16(28, 24); // bits per pixel
put32(34, pixel_bytes);
for (int y = 0; y < height; ++y) {
unsigned char* row = bmp.data() + 54 + size_t(y) * size_t(row_bytes);
for (int x = 0; x < width; ++x) {
row[x * 3] = value;
row[x * 3 + 1] = value;
row[x * 3 + 2] = value;
}
}
return bmp;
}
// The app calls wxInitAllImageHandlers() during startup and, before that, wxEntryStart() runs the
// wxImageModule, which registers the standard handlers. wxInitAllImageHandlers() adds PNG, JPEG and
// the rest but NOT the BMP handler, so a test binary without a wxApp must add the standard set too or
// a .bmp texture cannot be loaded here at all.
void ensure_wx_image_handlers()
{
static bool initialized = false;
if (initialized)
return;
initialized = true;
wxImage::InitStandardHandlers();
wxInitAllImageHandlers();
}
// Stores `model` as a one-plate project, mirroring tests/libslic3r/test_3mf.cpp.
void store_project(Model& model, const std::string& path)
{
ScopedTemporaryDir backup_dir("orca_tex_pack_src");
model.set_backup_path(backup_dir.string());
DynamicPrintConfig cfg;
PlateData plate;
plate.plate_index = 0;
StoreParams sp;
sp.path = path.c_str();
sp.model = &model;
sp.config = &cfg;
sp.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence;
sp.plate_data_list.push_back(&plate);
REQUIRE(store_bbs_3mf(sp));
}
bool load_project(const std::string& path, Model& model, const ScopedTemporaryDir& backup_dir)
{
model.set_backup_path(backup_dir.string());
DynamicPrintConfig config;
ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable };
PlateDataPtrs plates;
std::vector<Preset*> project_presets;
bool is_bbl_3mf = false;
bool is_orca_3mf = false;
Semver file_version;
const bool loaded = load_bbs_3mf(path.c_str(), &config, &ctxt, &model, &plates, &project_presets,
&is_bbl_3mf, &is_orca_3mf, &file_version, nullptr,
LoadStrategy::LoadModel | LoadStrategy::LoadConfig);
release_PlateData_list(plates);
return loaded;
}
} // namespace
// ── Validation (collect_resource_texture_files, no Python) ──────────────────────────────────────
TEST_CASE("A texture file directly inside the plugin folder is listed", "[PluginResourceTexture]")
{
ScopedTemporaryDir root("tex-root");
write_text_file(root.path() / "textures" / "grain.png");
std::string error;
const std::vector<std::string> files =
collect_resource_texture_files((root.path() / "textures").string(), root.string(), error);
CHECK(error.empty());
REQUIRE(files.size() == 1);
CHECK(fs::path(files.front()).filename() == "grain.png");
}
TEST_CASE("A directory reached with a parent-directory escape is refused", "[PluginResourceTexture]")
{
ScopedTemporaryDir parent("tex-escape");
const fs::path root = parent.path() / "pack";
const fs::path outside = parent.path() / "outside";
fs::create_directories(root);
write_text_file(outside / "leak.png");
std::string error;
const std::vector<std::string> files =
collect_resource_texture_files((root / ".." / "outside").string(), root.string(), error);
CHECK(files.empty());
CHECK_FALSE(error.empty());
}
TEST_CASE("An absolute directory outside the plugin folder is refused", "[PluginResourceTexture]")
{
ScopedTemporaryDir root("tex-abs-root");
ScopedTemporaryDir elsewhere("tex-abs-elsewhere");
write_text_file(elsewhere.path() / "leak.png");
std::string error;
const std::vector<std::string> files = collect_resource_texture_files(elsewhere.string(), root.string(), error);
CHECK(files.empty());
CHECK_FALSE(error.empty());
}
TEST_CASE("A symlink pointing outside the plugin folder is skipped, not reported as an error", "[PluginResourceTexture]")
{
ScopedTemporaryDir root("tex-symlink-root");
ScopedTemporaryDir target("tex-symlink-target");
write_text_file(target.path() / "secret.png");
const fs::path link = root.path() / "textures" / "link.png";
fs::create_directories(link.parent_path());
boost::system::error_code ec;
fs::create_symlink(target.path() / "secret.png", link, ec);
if (ec)
SKIP("symlinks unavailable on this filesystem: " + ec.message());
std::string error;
const std::vector<std::string> files =
collect_resource_texture_files(link.parent_path().string(), root.string(), error);
// The directory itself is legitimate; the escaping link is dropped, not an error.
CHECK(error.empty());
CHECK(files.empty());
}
TEST_CASE("Files in a subdirectory of the texture directory are not listed", "[PluginResourceTexture]")
{
ScopedTemporaryDir root("tex-recursive");
write_text_file(root.path() / "top.png");
write_text_file(root.path() / "nested" / "deep.png");
std::string error;
const std::vector<std::string> files = collect_resource_texture_files(root.string(), root.string(), error);
CHECK(error.empty());
REQUIRE(files.size() == 1);
CHECK(fs::path(files.front()).filename() == "top.png");
}
TEST_CASE("An entry that is not a regular file is skipped", "[PluginResourceTexture]")
{
ScopedTemporaryDir root("tex-nonregular");
write_text_file(root.path() / "real.png");
fs::create_directories(root.path() / "fake.png"); // a directory named like an image
std::string error;
const std::vector<std::string> files = collect_resource_texture_files(root.string(), root.string(), error);
CHECK(error.empty());
REQUIRE(files.size() == 1);
CHECK(fs::path(files.front()).filename() == "real.png");
}
TEST_CASE("Image extensions are matched case-insensitively and other extensions are ignored",
"[PluginResourceTexture]")
{
ScopedTemporaryDir root("tex-extensions");
for (const char* name : { "a.png", "b.PNG", "c.jpg", "d.JPG", "e.jpeg", "f.JPEG", "g.bmp", "h.BMP" })
write_text_file(root.path() / name);
for (const char* name : { "i.txt", "j.gif", "k.pngx", "l.pn", "m.png.bak" })
write_text_file(root.path() / name);
std::string error;
const std::vector<std::string> files = collect_resource_texture_files(root.string(), root.string(), error);
CHECK(error.empty());
CHECK(files.size() == 8);
std::vector<std::string> names;
for (const std::string& file : files)
names.push_back(fs::path(file).filename().string());
for (const char* accepted : { "a.png", "b.PNG", "c.jpg", "d.JPG", "e.jpeg", "f.JPEG", "g.bmp", "h.BMP" })
CHECK(std::find(names.begin(), names.end(), accepted) != names.end());
for (const char* rejected : { "i.txt", "j.gif", "k.pngx", "l.pn", "m.png.bak" })
CHECK(std::find(names.begin(), names.end(), rejected) == names.end());
}
TEST_CASE("The listing stops at 64 image files", "[PluginResourceTexture]")
{
ScopedTemporaryDir root("tex-cap");
for (int i = 0; i < 70; ++i)
write_text_file(root.path() / ("file" + std::to_string(i) + ".png"));
std::string error;
const std::vector<std::string> files = collect_resource_texture_files(root.string(), root.string(), error);
CHECK(error.empty());
CHECK(files.size() == 64);
}
TEST_CASE("A missing texture directory yields no files and an error", "[PluginResourceTexture]")
{
ScopedTemporaryDir root("tex-missing");
std::string error;
const std::vector<std::string> files =
collect_resource_texture_files((root.path() / "textures").string(), root.string(), error);
CHECK(files.empty());
CHECK_FALSE(error.empty());
}
// ── Lifecycle through the real listing ──────────────────────────────────────────────────────────
TEST_CASE("A resource-texture pack's images appear in the listing and clear on disable and unload",
"[PluginResourceTexture][PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("texture-listing");
const std::string plugin_key = "Texture_Pack";
const fs::path plugin_dir =
write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Texture Pack", default_texture_dir_body()));
const fs::path textures = plugin_dir / "textures";
write_gray_png(textures / "alpha.png", 0x20);
write_gray_png(textures / "beta.png", 0x80);
write_text_file(textures / "gamma.jpg"); // the listing only checks the extension; decode is covered below
write_text_file(textures / "notes.txt");
// A user-owned texture, so the listing has a non-pack entry whose provenance must be empty.
write_gray_png(fs::path(GUI::user_texture_dir()) / "user_made.png", 0x40);
PluginManager& manager = PluginManager::instance();
install_resource_texture_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, plugin_key, error));
INFO("load error: " << error);
CHECK(error.empty());
CHECK(PluginResourceTextures::instance().entries().size() == 1);
const uint64_t loaded_generation = GUI::texture_library_generation();
const auto loaded = plugin_textures(true);
REQUIRE(loaded.size() == 3);
std::vector<std::string> names;
for (const GUI::TextureLibraryEntry& entry : loaded)
names.push_back(entry.name);
CHECK(std::find(names.begin(), names.end(), "alpha") != names.end());
CHECK(std::find(names.begin(), names.end(), "beta") != names.end());
CHECK(std::find(names.begin(), names.end(), "gamma") != names.end());
CHECK(std::find(names.begin(), names.end(), "notes") == names.end());
// Every pack entry names its contributing pack; shipped and user entries carry no pack.
for (const GUI::TextureLibraryEntry& entry : GUI::texture_library(true)) {
if (entry.source == GUI::TextureSource::Plugin) {
CHECK_FALSE(entry.pack_name.empty());
CHECK(entry.pack_name == "Texture Pack");
} else {
CHECK(entry.pack_name.empty());
}
}
// A no-argument call reuses the cached listing until a pack callback moves the generation.
CHECK(plugin_textures(false).size() == 3);
manager.set_capability_enabled({ PluginCapabilityType::ResourceTexture, "Texture Pack", plugin_key }, false);
CHECK(GUI::texture_library_generation() > loaded_generation);
CHECK(PluginResourceTextures::instance().entries().empty());
CHECK(plugin_textures(false).empty());
manager.set_capability_enabled({ PluginCapabilityType::ResourceTexture, "Texture Pack", plugin_key }, true);
CHECK(plugin_textures(false).size() == 3);
REQUIRE(manager.unload_plugin(plugin_key));
CHECK(PluginResourceTextures::instance().entries().empty());
CHECK(plugin_textures(false).empty());
}
TEST_CASE("Two distinctly named packs each list as one contiguous run",
"[PluginResourceTexture][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("texture-contiguous");
// Loaded out of name order, so it is the listing's pack-name sort that orders the runs.
const std::string key_zeta = "Pack_Zeta";
const std::string key_alpha = "Pack_Alpha";
const fs::path dir_zeta = write_plugin(
data_dir_guard, key_zeta, make_resource_texture_source("Zeta Pack", default_texture_dir_body()));
const fs::path dir_alpha = write_plugin(
data_dir_guard, key_alpha, make_resource_texture_source("Alpha Pack", default_texture_dir_body()));
write_gray_png(dir_zeta / "textures" / "zeta_one.png", 0x20);
write_gray_png(dir_zeta / "textures" / "zeta_two.png", 0x40);
write_gray_png(dir_alpha / "textures" / "alpha_one.png", 0x60);
write_gray_png(dir_alpha / "textures" / "alpha_two.png", 0x80);
PluginManager& manager = PluginManager::instance();
install_resource_texture_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, key_zeta, error));
REQUIRE(load_and_wait(manager, key_alpha, error));
std::vector<GUI::TextureLibraryEntry> pack_entries;
for (const GUI::TextureLibraryEntry& entry : GUI::texture_library(true))
if (entry.source == GUI::TextureSource::Plugin)
pack_entries.push_back(entry);
REQUIRE(pack_entries.size() == 4);
const auto count_pack = [&pack_entries](const std::string& name) {
return std::count_if(pack_entries.begin(), pack_entries.end(),
[&name](const GUI::TextureLibraryEntry& e) { return e.pack_name == name; });
};
CHECK(count_pack("Zeta Pack") == 2);
CHECK(count_pack("Alpha Pack") == 2);
// Collapsing consecutive entries by pack name must leave one run per pack: a name never reappears
// after another name intervenes, which is what lets the picker draw one section per pack.
std::vector<std::string> runs;
for (const GUI::TextureLibraryEntry& entry : pack_entries)
if (runs.empty() || runs.back() != entry.pack_name)
runs.push_back(entry.pack_name);
std::vector<std::string> sorted_runs = runs;
std::sort(sorted_runs.begin(), sorted_runs.end());
CHECK(std::unique(sorted_runs.begin(), sorted_runs.end()) == sorted_runs.end());
CHECK(runs == std::vector<std::string>({ "Alpha Pack", "Zeta Pack" }));
manager.unload_plugin(key_zeta);
manager.unload_plugin(key_alpha);
}
TEST_CASE("A second pack declaring a loaded capability name is refused",
"[PluginResourceTexture][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("texture-capability-clash");
// Distinct package names, one capability name: the loader allows only the first.
const std::string key_first = "Clash_First";
const std::string key_second = "Clash_Second";
const std::string capability = "Shared Textures";
const fs::path dir_first = write_plugin(
data_dir_guard, key_first,
make_resource_texture_source("Clash Package One", default_texture_dir_body(), capability));
write_plugin(data_dir_guard, key_second,
make_resource_texture_source("Clash Package Two", default_texture_dir_body(), capability));
write_gray_png(dir_first / "textures" / "one.png", 0x20);
write_gray_png(dir_first / "textures" / "two.png", 0x40);
PluginManager& manager = PluginManager::instance();
install_resource_texture_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, key_first, error));
REQUIRE(manager.is_plugin_loaded(key_first));
const std::vector<GUI::TextureLibraryEntry> before = plugin_textures(true);
REQUIRE(before.size() == 2);
for (const GUI::TextureLibraryEntry& entry : before)
CHECK(entry.pack_name == capability);
// The second package claims the capability name the first already owns, so the load is refused.
std::string second_error;
CHECK_FALSE(load_and_wait(manager, key_second, second_error));
CHECK_FALSE(manager.is_plugin_loaded(key_second));
// The first pack's textures are untouched by the refused load.
const std::vector<GUI::TextureLibraryEntry> after = plugin_textures(true);
REQUIRE(after.size() == before.size());
for (size_t i = 0; i < before.size(); ++i) {
CHECK(after[i].name == before[i].name);
CHECK(after[i].path == before[i].path);
CHECK(after[i].pack_name == before[i].pack_name);
}
manager.unload_plugin(key_first);
}
TEST_CASE("A resource-texture pack with no images contributes no textures", "[PluginResourceTexture][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("texture-empty");
const std::string plugin_key = "Empty_Pack";
const fs::path plugin_dir =
write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Empty Pack", default_texture_dir_body()));
fs::create_directories(plugin_dir / "textures"); // valid, but empty
PluginManager& manager = PluginManager::instance();
install_resource_texture_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, plugin_key, error));
CHECK(manager.is_plugin_loaded(plugin_key));
CHECK(plugin_textures(true).empty());
manager.unload_plugin(plugin_key);
}
// ── Hostile get_texture_dir() overrides ─────────────────────────────────────────────────────────
TEST_CASE("A texture directory outside the plugin root contributes no textures", "[PluginResourceTexture][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("texture-hostile");
ScopedResourcesDir resources_dir_guard("texture-hostile-res");
write_text_file(fs::path(resources_dir_guard.dir) / "sentinel.png"); // would list if it were scanned
const std::string data_pack = "Data_Dir_Pack";
write_plugin(data_dir_guard, data_pack,
make_resource_texture_source("Data Dir Pack", "return r\"" + data_dir_guard.dir.string() + "\""));
const std::string resources_pack = "Resources_Dir_Pack";
write_plugin(data_dir_guard, resources_pack,
make_resource_texture_source("Resources Dir Pack", "return r\"" + resources_dir_guard.dir.string() + "\""));
PluginManager& manager = PluginManager::instance();
install_resource_texture_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, data_pack, error));
REQUIRE(load_and_wait(manager, resources_pack, error));
// The packs load; their out-of-root directories are simply refused.
CHECK(manager.is_plugin_loaded(data_pack));
CHECK(manager.is_plugin_loaded(resources_pack));
CHECK(PluginResourceTextures::instance().entries().empty());
CHECK(plugin_textures(true).empty());
manager.unload_plugin(data_pack);
manager.unload_plugin(resources_pack);
}
TEST_CASE("A raising get_texture_dir leaves the pack loaded with no textures", "[PluginResourceTexture][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("texture-raising");
const std::string plugin_key = "Raising_Pack";
write_plugin(data_dir_guard, plugin_key,
make_resource_texture_source("Raising Pack", "raise RuntimeError(\"no textures today\")"));
PluginManager& manager = PluginManager::instance();
install_resource_texture_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, plugin_key, error));
CHECK(error.empty());
CHECK(manager.is_plugin_loaded(plugin_key));
CHECK(PluginResourceTextures::instance().entries().empty());
CHECK(plugin_textures(true).empty());
manager.unload_plugin(plugin_key);
}
// ── Decoding parity ─────────────────────────────────────────────────────────────────────────────
TEST_CASE("Pack images decode through the same path as hand-picked files", "[PluginResourceTexture]")
{
ensure_wx_image_handlers();
ScopedTemporaryDir pack_root("tex-pack");
ScopedTemporaryDir hand_root("tex-hand");
const fs::path pack_dir = pack_root.path() / "textures";
fs::create_directories(pack_dir);
// PNG: grayscale, so load_texture_image_data passes it through verbatim.
const fs::path pack_png = pack_dir / "grain.png";
write_gray_png(pack_png, 0x40);
const std::vector<unsigned char> png_bytes = read_file_bytes(pack_png);
REQUIRE_FALSE(png_bytes.empty());
write_bytes(hand_root.path() / "grain.png", png_bytes);
// BMP: not a PNG, so it goes through wxImage and is re-encoded; the same file must give the same
// bytes wherever it sits.
const std::vector<unsigned char> bmp_bytes = make_gray_bmp(4, 4, 0x40);
const fs::path pack_bmp = pack_dir / "bump.bmp";
write_bytes(pack_bmp, bmp_bytes);
const fs::path hand_bmp = hand_root.path() / "bump.bmp";
write_bytes(hand_bmp, bmp_bytes);
// The collector lists both pack files, so these are the paths a pack would serve.
std::string collect_error;
const std::vector<std::string> pack_files =
collect_resource_texture_files(pack_dir.string(), pack_root.string(), collect_error);
REQUIRE(collect_error.empty());
CHECK(pack_files.size() == 2);
std::string pack_error;
std::string hand_error;
const auto pack_png_decoded = GUI::load_texture_image_data(pack_png.string(), pack_error);
const auto hand_png_decoded = GUI::load_texture_image_data((hand_root.path() / "grain.png").string(), hand_error);
REQUIRE(pack_png_decoded != nullptr);
REQUIRE(hand_png_decoded != nullptr);
CHECK(*pack_png_decoded == png_bytes); // supported PNG is passed through unchanged
CHECK(*pack_png_decoded == *hand_png_decoded);
const auto pack_bmp_decoded = GUI::load_texture_image_data(pack_bmp.string(), pack_error);
const auto hand_bmp_decoded = GUI::load_texture_image_data(hand_bmp.string(), hand_error);
REQUIRE(pack_bmp_decoded != nullptr);
REQUIRE(hand_bmp_decoded != nullptr);
REQUIRE_FALSE(pack_bmp_decoded->empty());
CHECK(*pack_bmp_decoded == *hand_bmp_decoded);
}
TEST_CASE("Reinstalling a pack with different bytes at the same path changes the decoded content",
"[PluginResourceTexture][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("texture-reinstall");
const std::string plugin_key = "Reinstall_Pack";
const fs::path plugin_dir =
write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Reinstall Pack", default_texture_dir_body()));
const fs::path texture = plugin_dir / "textures" / "grain.png";
write_gray_png(texture, 0x10);
PluginManager& manager = PluginManager::instance();
install_resource_texture_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, plugin_key, error));
auto loaded = plugin_textures(true);
REQUIRE(loaded.size() == 1);
const std::string path = loaded.front().path;
std::string decode_error;
const auto first = GUI::load_texture_image_data(path, decode_error);
REQUIRE(first != nullptr);
REQUIRE(manager.unload_plugin(plugin_key));
// The reinstalled pack writes different bytes to the same file path.
write_gray_png(texture, 0xE0);
REQUIRE(load_and_wait(manager, plugin_key, error));
loaded = plugin_textures(true);
REQUIRE(loaded.size() == 1);
CHECK(loaded.front().path == path);
const auto second = GUI::load_texture_image_data(path, decode_error);
REQUIRE(second != nullptr);
CHECK(*first != *second);
}
// ── Wheel metadata ──────────────────────────────────────────────────────────────────────────────
TEST_CASE("A minimal resource-texture wheel is accepted", "[PluginResourceTexture][Wheel]")
{
ScopedTemporaryDir dir("texture-wheel");
const fs::path wheel = dir.path() / "texture_pack-1.0-py3-none-any.whl";
const std::string dist_info = "texture_pack-1.0.dist-info";
const std::string metadata =
"Metadata-Version: 2.1\n"
"Name: texture_pack\n"
"Version: 1.0\n"
"Summary: Resource texture pack fixture\n"
"Author: tests\n";
const std::string wheel_metadata =
"Wheel-Version: 1.0\n"
"Generator: manual\n"
"Root-Is-Purelib: true\n"
"Tag: py3-none-any\n";
const std::string record = dist_info + "/METADATA,,\n" + dist_info + "/WHEEL,,\n" + dist_info + "/RECORD,,\n";
const std::string top_level = "texture_pack\n";
{
Zipper zipper(wheel.string(), Zipper::NO_COMPRESSION);
zipper.add_entry(dist_info + "/METADATA", metadata.data(), metadata.size());
zipper.add_entry(dist_info + "/WHEEL", wheel_metadata.data(), wheel_metadata.size());
zipper.add_entry(dist_info + "/RECORD", record.data(), record.size());
// read_wheel_plugin_metadata leaves `error` set when an optional top_level.txt is missing.
zipper.add_entry(dist_info + "/top_level.txt", top_level.data(), top_level.size());
zipper.finalize();
}
PluginDescriptor descriptor;
std::string error;
REQUIRE(read_wheel_plugin_metadata(wheel, descriptor, error));
INFO("wheel metadata error: " << error);
CHECK(error.empty());
CHECK(descriptor.name == "texture_pack");
CHECK(descriptor.version == "1.0");
CHECK(descriptor.entry_package == "texture_pack");
}
// ── .3mf round trip ─────────────────────────────────────────────────────────────────────────────
TEST_CASE("A project keeps a pack texture after the pack is uninstalled",
"[PluginResourceTexture][PluginLifecycle][Python][3mf]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("texture-3mf");
const std::string plugin_key = "Texture_Pack_3mf";
const fs::path plugin_dir =
write_plugin(data_dir_guard, plugin_key, make_resource_texture_source("Texture Pack", default_texture_dir_body()));
write_gray_png(plugin_dir / "textures" / "grain.png", 0x60);
PluginManager& manager = PluginManager::instance();
install_resource_texture_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
REQUIRE(load_and_wait(manager, plugin_key, error));
const auto loaded = plugin_textures(true);
REQUIRE(loaded.size() == 1);
std::string decode_error;
const auto pack_bytes = GUI::load_texture_image_data(loaded.front().path, decode_error);
REQUIRE(pack_bytes != nullptr);
REQUIRE_FALSE(pack_bytes->empty());
// Build a project the way the gizmo would: the layer carries the pack texture's bytes.
Model model;
ModelObject* object = model.add_object();
ModelVolume* volume = object->add_volume(make_cube(10., 10., 10.));
object->add_instance();
TextureDisplacementLayer layer;
layer.slot = 0;
layer.name = loaded.front().name;
layer.path = loaded.front().path;
layer.image_data = pack_bytes;
volume->texture_displacement_layers.push_back(layer);
ScopedTemporaryFile first(".3mf");
store_project(model, first.string());
// Uninstall the pack; the project still has to open and re-save with the same image bytes.
REQUIRE(manager.unload_plugin(plugin_key));
CHECK(plugin_textures(true).empty());
ScopedTemporaryDir backup_dir("orca_tex_pack_dst");
Model reloaded;
REQUIRE(load_project(first.string(), reloaded, backup_dir));
REQUIRE(reloaded.objects.size() == 1);
REQUIRE(reloaded.objects.front()->volumes.size() == 1);
const std::vector<TextureDisplacementLayer>& reloaded_layers =
reloaded.objects.front()->volumes.front()->texture_displacement_layers;
REQUIRE(reloaded_layers.size() == 1);
REQUIRE(reloaded_layers.front().image_data);
CHECK(*reloaded_layers.front().image_data == *pack_bytes);
ScopedTemporaryFile second(".3mf");
store_project(reloaded, second.string());
ScopedTemporaryDir backup_dir2("orca_tex_pack_dst2");
Model reloaded_again;
REQUIRE(load_project(second.string(), reloaded_again, backup_dir2));
REQUIRE(reloaded_again.objects.size() == 1);
REQUIRE(reloaded_again.objects.front()->volumes.size() == 1);
const std::vector<TextureDisplacementLayer>& again_layers =
reloaded_again.objects.front()->volumes.front()->texture_displacement_layers;
REQUIRE(again_layers.size() == 1);
REQUIRE(again_layers.front().image_data);
CHECK(*again_layers.front().image_data == *pack_bytes);
}