diff --git a/.github/workflows/build_deps.yml b/.github/workflows/build_deps.yml index 9927f5bef6..4e01051073 100644 --- a/.github/workflows/build_deps.yml +++ b/.github/workflows/build_deps.yml @@ -149,7 +149,7 @@ jobs: working-directory: ${{ github.workspace }} run: | if [ -z "${{ vars.SELF_HOSTED }}" ]; then - brew install automake texinfo libtool + brew install automake texinfo libtool pkgconf yasm nasm fi ./build_release_macos.sh -dx ${{ !vars.SELF_HOSTED && '-1' || '' }} -a ${{ inputs.arch }} -t 10.15 (cd "${{ github.workspace }}/deps/build/${{ inputs.arch }}" && \ diff --git a/.github/workflows/build_orca.yml b/.github/workflows/build_orca.yml index 112c0b279b..e8dcef0b05 100644 --- a/.github/workflows/build_orca.yml +++ b/.github/workflows/build_orca.yml @@ -385,6 +385,13 @@ jobs: dir "C:/Program Files (x86)/Windows Kits/10/Include" choco install nsis + - name: Install pkg-config + # FFmpeg is discovered via pkg-config (pkg_check_modules LIBAV in + # src/slic3r/CMakeLists.txt); the Windows runners don't ship it. + if: runner.os == 'Windows' && !vars.SELF_HOSTED + run: | + choco install pkgconfiglite -y + - name: Build slicer Win if: runner.os == 'Windows' working-directory: ${{ github.workspace }} diff --git a/.gitignore b/.gitignore index 4d3ccb5c7b..cdcd1c90b4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ Build Build.bat /build*/ CMakeLists.txt.user +CMakeUserPresets.json **/CMakeLists.txt.autosave deps/build* MYMETA.json diff --git a/CMakeLists.txt b/CMakeLists.txt index 9f0db669b8..c912cdd08f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -298,6 +298,8 @@ if (APPLE) SET(CMAKE_XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "com.orcaslicer.OrcaSlicer") message(STATUS "Orca: IS_CROSS_COMPILE: ${IS_CROSS_COMPILE}") +elseif (CMAKE_SYSTEM_NAME STREQUAL "Linux") + set(CMAKE_INSTALL_RPATH "$ORIGIN") endif () # Proposal for C++ unit tests and sandboxes @@ -474,7 +476,8 @@ set(CMAKE_POSITION_INDEPENDENT_CODE ON) # WIN10SDK_PATH is used to point CMake to the WIN10 SDK installation directory. # We pick it from environment if it is not defined in another way # ORCA: Removed Netfabb STL fixing service support in favor of CGAL. -# if(WIN32) +if(WIN32) + find_package(PkgConfig REQUIRED) # if(NOT DEFINED WIN10SDK_PATH) # if(DEFINED ENV{WIN10SDK_PATH}) # set(WIN10SDK_PATH "$ENV{WIN10SDK_PATH}") @@ -510,7 +513,7 @@ set(CMAKE_POSITION_INDEPENDENT_CODE ON) # else() # message("Building without Win10 Netfabb STL fixing service support") # endif() -# endif() +endif() if (APPLE) message("OS X SDK Path: ${CMAKE_OSX_SYSROOT}") @@ -1065,6 +1068,10 @@ function(orcaslicer_copy_dlls target config postfix output_dlls) ${CMAKE_PREFIX_PATH}/bin/occt/TKXDESTEP.dll ${CMAKE_PREFIX_PATH}/bin/occt/TKXSBase.dll ${CMAKE_PREFIX_PATH}/bin/freetype.dll + ${CMAKE_PREFIX_PATH}/bin/avcodec-61.dll + ${CMAKE_PREFIX_PATH}/bin/swresample-5.dll + ${CMAKE_PREFIX_PATH}/bin/swscale-8.dll + ${CMAKE_PREFIX_PATH}/bin/avutil-59.dll DESTINATION ${_out_dir}) set(${output_dlls} @@ -1100,12 +1107,58 @@ function(orcaslicer_copy_dlls target config postfix output_dlls) ${_out_dir}/TKXSBase.dll ${_out_dir}/freetype.dll - + ${_out_dir}/avcodec-61.dll + ${_out_dir}/swresample-5.dll + ${_out_dir}/swscale-8.dll + ${_out_dir}/avutil-59.dll PARENT_SCOPE ) endfunction() +function(orcaslicer_copy_sos target config postfix output_sos) + + get_property(_is_multi GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) + get_target_property(_alt_out_dir ${target} RUNTIME_OUTPUT_DIRECTORY) + + if (_alt_out_dir) + set(_out_dir "${_alt_out_dir}") + elseif (_is_multi) + set(_out_dir "${CMAKE_CURRENT_BINARY_DIR}/${config}") + else () + set(_out_dir "${CMAKE_CURRENT_BINARY_DIR}") + endif () + + file(COPY ${CMAKE_PREFIX_PATH}/lib/libavcodec.so + ${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61 + ${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61.3.100 + ${CMAKE_PREFIX_PATH}/lib/libavutil.so + ${CMAKE_PREFIX_PATH}/lib/libavutil.so.59 + ${CMAKE_PREFIX_PATH}/lib/libavutil.so.59.8.100 + ${CMAKE_PREFIX_PATH}/lib/libswscale.so + ${CMAKE_PREFIX_PATH}/lib/libswscale.so.8 + ${CMAKE_PREFIX_PATH}/lib/libswscale.so.8.1.100 + ${CMAKE_PREFIX_PATH}/lib/libswresample.so + ${CMAKE_PREFIX_PATH}/lib/libswresample.so.5 + ${CMAKE_PREFIX_PATH}/lib/libswresample.so.5.1.100 + DESTINATION ${_out_dir}) + + set(${output_sos} + ${_out_dir}/libavcodec.so + ${_out_dir}/libavcodec.so.61 + ${_out_dir}/libavcodec.so.61.3.100 + ${_out_dir}/libavutil.so + ${_out_dir}/libavutil.so.59 + ${_out_dir}/libavutil.so.59.8.100 + ${_out_dir}/libswscale.so + ${_out_dir}/libswscale.so.8 + ${_out_dir}/libswscale.so.8.1.100 + ${_out_dir}/libswresample.so + ${_out_dir}/libswresample.so.5 + ${_out_dir}/libswresample.so.5.1.100 + PARENT_SCOPE + ) +endfunction() # Bundled sources set their own warning flags, and a plain -Wall there means /Wall # (= -Weverything) under clang-cl. Target options are applied after the ones a target @@ -1217,6 +1270,20 @@ else () endif() endif () +if (CMAKE_SYSTEM_NAME STREQUAL "Linux") + set(LIBRARY_FILES + ${LIBDIR_BIN}/libavcodec.so.61 + ${LIBDIR_BIN}/libavcodec.so.61.3.100 + ${LIBDIR_BIN}/libavutil.so.59 + ${LIBDIR_BIN}/libavutil.so.59.8.100 + ${LIBDIR_BIN}/libswresample.so.5 + ${LIBDIR_BIN}/libswresample.so.5.1.100 + ${LIBDIR_BIN}/libswscale.so.8 + ${LIBDIR_BIN}/libswscale.so.8.1.100 + ) + install(FILES ${LIBRARY_FILES} DESTINATION "${CMAKE_INSTALL_PREFIX}/bin") +endif () + install(FILES ${CMAKE_SOURCE_DIR}/LICENSE.txt DESTINATION ".") configure_file(${LIBDIR}/dev-utils/platform/unix/fhs.hpp.in ${LIBDIR_BIN}/dev-utils/platform/unix/fhs.hpp) diff --git a/build_release_vs.bat b/build_release_vs.bat index 78419dadf5..a52d940455 100644 --- a/build_release_vs.bat +++ b/build_release_vs.bat @@ -20,6 +20,18 @@ for %%a in (%*) do ( if "%%a"=="-x" set USE_NINJA=1 ) +@REM Check for clang-cl option (-l). Combined with -x it also builds the deps with +@REM clang-cl; on the Visual Studio generator it applies to the slicer only, because +@REM the dependency sub-builds have no toolset to inherit and stay on MSVC. +set CLANG_ARG= +set TOOLSET_ARG= +for %%a in (%*) do ( + if "%%a"=="-l" ( + set CLANG_ARG=-DCMAKE_C_COMPILER=clang-cl -DCMAKE_CXX_COMPILER=clang-cl + set TOOLSET_ARG=-T ClangCL + ) +) + @REM Check for unit-tests option ("tests") set BUILD_TESTS=OFF for %%a in (%*) do ( @@ -127,12 +139,13 @@ if "%1"=="slicer" ( GOTO :slicer ) echo "building deps.." +if defined CLANG_ARG if "%USE_NINJA%"=="0" echo Note: -l needs -x for the dependencies; building them with MSVC. echo on REM Set minimum CMake policy to avoid <3.5 errors set CMAKE_POLICY_VERSION_MINIMUM=3.5 if "%USE_NINJA%"=="1" ( - cmake ../ -G %CMAKE_GENERATOR% -DCMAKE_BUILD_TYPE=%build_type% + cmake ../ -G %CMAKE_GENERATOR% %CLANG_ARG% -DCMAKE_BUILD_TYPE=%build_type% cmake --build . --config %build_type% --target deps ) else ( cmake ../ -G %CMAKE_GENERATOR% -A %arch% -DCMAKE_BUILD_TYPE=%build_type% @@ -151,10 +164,10 @@ cd %build_dir% echo on set CMAKE_POLICY_VERSION_MINIMUM=3.5 if "%USE_NINJA%"=="1" ( - cmake .. -G %CMAKE_GENERATOR% -DORCA_TOOLS=ON %SIG_FLAG% -DBUILD_TESTS=%BUILD_TESTS% -DCMAKE_BUILD_TYPE=%build_type% + cmake .. -G %CMAKE_GENERATOR% %CLANG_ARG% -DORCA_TOOLS=ON %SIG_FLAG% -DBUILD_TESTS=%BUILD_TESTS% -DCMAKE_BUILD_TYPE=%build_type% cmake --build . --config %build_type% --target all ) else ( - cmake .. -G %CMAKE_GENERATOR% -A %arch% -DORCA_TOOLS=ON %SIG_FLAG% -DBUILD_TESTS=%BUILD_TESTS% -DCMAKE_BUILD_TYPE=%build_type% + cmake .. -G %CMAKE_GENERATOR% -A %arch% %TOOLSET_ARG% -DORCA_TOOLS=ON %SIG_FLAG% -DBUILD_TESTS=%BUILD_TESTS% -DCMAKE_BUILD_TYPE=%build_type% cmake --build . --config %build_type% --target ALL_BUILD -- -m ) @echo off diff --git a/deps/Assimp/Assimp.cmake b/deps/Assimp/Assimp.cmake new file mode 100644 index 0000000000..9b973a55d5 --- /dev/null +++ b/deps/Assimp/Assimp.cmake @@ -0,0 +1,43 @@ +if(CMAKE_VERSION VERSION_LESS 3.22) + set(_assimp_url "https://github.com/assimp/assimp/archive/refs/tags/v5.3.1.tar.gz") + set(_assimp_hash "SHA256=a07666be71afe1ad4bc008c2336b7c688aca391271188eb9108d0c6db1be53f1") +else() + set(_assimp_url "https://github.com/assimp/assimp/archive/refs/tags/v5.4.3.tar.gz") + set(_assimp_hash "SHA256=66dfbaee288f2bc43172440a55d0235dfc7bf885dda6435c038e8000e79582cb") +endif() + +# Assimp's bundled zlib (contrib/zlib) is too old to compile against the modern +# macOS SDK: its zutil.h takes the classic-Mac branch under TARGET_OS_MAC and +# does `#define fdopen(fd,mode) NULL`, which then clobbers the SDK's real +# `fdopen` prototype in and breaks the build. On macOS use the system +# zlib (already found by find_package(ZLIB) in deps-unix-common) instead. +if(APPLE) + set(_assimp_build_zlib "-DASSIMP_BUILD_ZLIB=OFF") +else() + set(_assimp_build_zlib "-DASSIMP_BUILD_ZLIB=ON") +endif() + +orcaslicer_add_cmake_project(Assimp + URL ${_assimp_url} + URL_HASH ${_assimp_hash} + CMAKE_ARGS + # Assimp's ccache support sets the global RULE_LAUNCH_COMPILE, which breaks + # the Ninja RC rule. The superbuild forwards CMAKE__COMPILER_LAUNCHER. + -DASSIMP_BUILD_USE_CCACHE=OFF + -DASSIMP_BUILD_TESTS=OFF + -DASSIMP_BUILD_SAMPLES=OFF + -DASSIMP_BUILD_ASSIMP_TOOLS=OFF + -DASSIMP_INSTALL_PDB=OFF + -DASSIMP_NO_EXPORT=ON + -DASSIMP_BUILD_ALL_IMPORTERS_BY_DEFAULT=OFF + -DASSIMP_BUILD_GLTF_IMPORTER=ON + -DASSIMP_BUILD_OBJ_IMPORTER=ON + -DASSIMP_BUILD_FBX_IMPORTER=ON + ${_assimp_build_zlib} + -DASSIMP_WARNINGS_AS_ERRORS=OFF + -DBUILD_WITH_STATIC_CRT=OFF +) + +if (MSVC) + add_debug_dep(dep_Assimp) +endif () diff --git a/deps/Boost/Boost.cmake b/deps/Boost/Boost.cmake index bdd801857e..08b62b9fb8 100644 --- a/deps/Boost/Boost.cmake +++ b/deps/Boost/Boost.cmake @@ -24,6 +24,13 @@ if (MSVC AND DEP_DEBUG) set(_options "FORWARD_CONFIG") endif () +# Boost.Container's bundled dlmalloc passes int* where the Win32 Interlocked API +# takes volatile long*; cl compiles that with a warning, clang errors out. +set(_boost_c_flags_line "") +if (MSVC AND CMAKE_C_COMPILER_ID STREQUAL "Clang") + set(_boost_c_flags_line "-DCMAKE_C_FLAGS:STRING=-Wno-incompatible-pointer-types") +endif () + orcaslicer_add_cmake_project(Boost ${_options} URL "https://github.com/boostorg/boost/releases/download/boost-1.84.0/boost-1.84.0.tar.gz" @@ -38,6 +45,7 @@ orcaslicer_add_cmake_project(Boost "${_context_abi_line}" "${_context_arch_line}" "${_context_impl_line}" + "${_boost_c_flags_line}" ) -set(DEP_Boost_DEPENDS ZLIB) \ No newline at end of file +set(DEP_Boost_DEPENDS ZLIB) diff --git a/deps/CMakeLists.txt b/deps/CMakeLists.txt index 39cc5de182..ed3af70d03 100644 --- a/deps/CMakeLists.txt +++ b/deps/CMakeLists.txt @@ -157,8 +157,16 @@ endif () function(orcaslicer_add_cmake_project projectname) cmake_parse_arguments(P_ARGS "FORWARD_CONFIG" "INSTALL_DIR;BUILD_COMMAND;INSTALL_COMMAND" "CMAKE_ARGS" ${ARGN}) + # MSVC is true for clang-cl as well, so the sub-build toolchain has to key on the + # generator. A non-Visual-Studio superbuild passes its own generator down, and with + # it the CMAKE_C_COMPILER / CMAKE_CXX_COMPILER forwarded below. + set(_dep_msvc_gen FALSE) + if (MSVC AND CMAKE_GENERATOR MATCHES "Visual Studio") + set(_dep_msvc_gen TRUE) + endif () + set(_configs_line -DCMAKE_BUILD_TYPE:STRING=${CMAKE_BUILD_TYPE}) - if (_is_multi OR MSVC) + if (_is_multi OR _dep_msvc_gen) if (P_ARGS_FORWARD_CONFIG) set(_configs_line -DCMAKE_BUILD_TYPE:STRING=${CMAKE_BUILD_TYPE}) elseif (ORCA_INCLUDE_DEBUG_INFO AND NOT DEP_DEBUG) @@ -174,7 +182,7 @@ function(orcaslicer_add_cmake_project projectname) set(_target_config "Release") endif() - if (MSVC) + if (_dep_msvc_gen) set(_gen CMAKE_GENERATOR "${DEP_MSVC_GEN}" CMAKE_GENERATOR_PLATFORM "${DEP_PLATFORM}") else() set(_gen "") @@ -182,7 +190,7 @@ function(orcaslicer_add_cmake_project projectname) if ($ENV{CMAKE_BUILD_PARALLEL_LEVEL}) set(_build_j "") # assume environment will control --build parallel setting - elseif(MSVC) + elseif(_dep_msvc_gen) set(_build_j "/m") else() set(_build_j "-j${NPROC}") @@ -367,6 +375,9 @@ include(libnoise/libnoise.cmake) include(Draco/Draco.cmake) +include(FFMPEG/FFMPEG.cmake) +include(Assimp/Assimp.cmake) + # I *think* 1.1 is used for *just* md5 hashing? # 3.1 has everything in the right place, but the md5 funcs used are deprecated @@ -448,6 +459,8 @@ set(_dep_list dep_libnoise dep_python3 dep_wxInspector + dep_FFMPEG + dep_Assimp ) if (MSVC) diff --git a/deps/CURL/CURL.cmake b/deps/CURL/CURL.cmake index a5ae1b9d00..3d649dfdea 100644 --- a/deps/CURL/CURL.cmake +++ b/deps/CURL/CURL.cmake @@ -56,6 +56,18 @@ else() set(_curl_static ON) endif() +# curl 7.75's configure probes and code rely on C laxness cl allows but clang +# errors on (implicit function declarations, int* vs u_long* in ioctlsocket), +# which flips probe results and misconfigures nonblock.c into the AmigaOS +# IoctlSocket branch. Relax both diagnostics so the probes behave like cl, and +# pin the camel-case probes off since they only "pass" by implicit declaration. +set(_curl_c_flags_line "") +set(_curl_probe_overrides "") +if (MSVC AND CMAKE_C_COMPILER_ID STREQUAL "Clang") + set(_curl_c_flags_line "-DCMAKE_C_FLAGS:STRING=-Wno-implicit-function-declaration -Wno-incompatible-pointer-types") + set(_curl_probe_overrides -DHAVE_IOCTLSOCKET_CAMEL=0 -DHAVE_IOCTLSOCKET_CAMEL_FIONBIO=0) +endif () + orcaslicer_add_cmake_project(CURL # GIT_REPOSITORY https://github.com/curl/curl.git # GIT_TAG curl-7_75_0 @@ -69,6 +81,8 @@ orcaslicer_add_cmake_project(CURL -DBUILD_CURL_EXE:BOOL=OFF -DCMAKE_POSITION_INDEPENDENT_CODE=ON -DCURL_STATICLIB=${_curl_static} + "${_curl_c_flags_line}" + ${_curl_probe_overrides} ${_curl_platform_flags} ) diff --git a/deps/Eigen/Eigen.cmake b/deps/Eigen/Eigen.cmake index 599976debb..2a9cc7105c 100644 --- a/deps/Eigen/Eigen.cmake +++ b/deps/Eigen/Eigen.cmake @@ -7,5 +7,20 @@ orcaslicer_add_cmake_project(Eigen URL https://gitlab.com/libeigen/eigen/-/archive/5.0.1/eigen-5.0.1.zip URL_HASH SHA256=0dbb1f9e3aaad66f352c03227d8c983f6f0b49e0b07e71a7300f4abcc01aee12 CMAKE_ARGS "${_eigen_extra_flags}" + # Only the headers are consumed here. Everything below builds nothing we + # use, and all three enable_language(Fortran): test/CMakeLists.txt:9, + # lapack/CMakeLists.txt:6 and blas/testing/CMakeLists.txt:2. They default + # to ON because the dependency configures as its own top-level project. + # + # Whether that probe is harmless depends on what CMake finds. The Visual + # Studio generator supports no Fortran, so it finds nothing; clang-cl sits + # next to the LLVM toolset's flang, which works. MSVC with Ninja finds + # Strawberry Perl's MinGW gfortran instead, which the deps build already + # requires for OpenSSL, and hands it the MSVC-style /machine:x64 that + # MinGW's ld reads as a missing input file. The configure dies there and + # takes the rest of the superbuild with it. + -DEIGEN_BUILD_TESTING=OFF + -DEIGEN_BUILD_BLAS=OFF + -DEIGEN_BUILD_LAPACK=OFF DEPENDS dep_Boost dep_GMP dep_MPFR ) diff --git a/deps/FFMPEG/FFMPEG.cmake b/deps/FFMPEG/FFMPEG.cmake new file mode 100644 index 0000000000..38e952bbdd --- /dev/null +++ b/deps/FFMPEG/FFMPEG.cmake @@ -0,0 +1,87 @@ +set(_conf_cmd ./configure) + +if (MSVC) + set(_source_dir "${CMAKE_BINARY_DIR}/dep_FFMPEG-prefix/src/dep_FFMPEG") + + set(PREBUILD_URL_arm64 "https://github.com/Noisyfox/FFmpeg-Builds-Orca/releases/download/autobuild-2026-07-17-14-28/ffmpeg-n7.0.3-31-g9b6ffd74b5-winarm64-orca-shared-7.0.zip") + set(PREBUILD_HASH_arm64 "12f4140279f2f8469885e1b5b2e8be9d788882914c21523cacd56989f3548054") + set(PREBUILD_URL_x64 "https://github.com/Noisyfox/FFmpeg-Builds-Orca/releases/download/autobuild-2026-07-17-14-28/ffmpeg-n7.0.3-31-g9b6ffd74b5-win64-orca-shared-7.0.zip") + set(PREBUILD_HASH_x64 "e65916020ddb9ef84b2666dfbcbfc9b1d67f69d15b4a66db53754637bf2d498c") + + ExternalProject_Add(dep_FFMPEG + URL ${PREBUILD_URL_${DEPS_ARCH}} + URL_HASH SHA256=${PREBUILD_HASH_${DEPS_ARCH}} + DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/FFMPEG + CONFIGURE_COMMAND "" + BUILD_COMMAND "" + INSTALL_COMMAND + COMMAND ${CMAKE_COMMAND} -E copy_directory "${_source_dir}/bin" "${DESTDIR}/bin" + COMMAND ${CMAKE_COMMAND} -E copy_directory "${_source_dir}/lib" "${DESTDIR}/lib" + COMMAND ${CMAKE_COMMAND} -E copy_directory "${_source_dir}/include" "${DESTDIR}/include" + ) + +else () + if (APPLE) + set(_minos_cmd + "--extra-cflags=-mmacosx-version-min=${DEP_OSX_TARGET}" + "--extra-ldflags=-mmacosx-version-min=${DEP_OSX_TARGET}" + ) + # Static FFmpeg: nothing to bundle into the .app, no rpath handling. + # Disable the VideoToolbox/AudioToolbox HW-accel paths: the player decodes + # in software (swscale), and the auto-detected HW objects would drag in + # system frameworks that the static libs would then depend on. + set(_link_cmd --enable-static --disable-shared --disable-videotoolbox --disable-audiotoolbox) + if (IS_CROSS_COMPILE) + set(_cross_cmd --enable-cross-compile) + set(_pic_cmd --enable-pic) + if (${CMAKE_SYSTEM_PROCESSOR} MATCHES "x86_64") + set(_arch_cmd --arch=arm64) + set(_cc_cmd "--cc=clang -arch arm64") + else() + set(_arch_cmd --arch=x86_64) + set(_cc_cmd "--cc=clang -arch x86_64") + endif() + endif() + else () + set(_link_cmd --enable-shared) + endif () + + set(_build_j -j) + if(DEFINED ENV{CMAKE_BUILD_PARALLEL_LEVEL}) + set(_build_j "-j$ENV{CMAKE_BUILD_PARALLEL_LEVEL}") + endif() + + ExternalProject_Add(dep_FFMPEG + URL https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n7.0.3.tar.gz + URL_HASH SHA256=DEEDCABE339165214A3637DF4C86A507AEF0D793CF8774FF68735F4737E8DDBC + DOWNLOAD_DIR ${DEP_DOWNLOAD_DIR}/FFMPEG + CONFIGURE_COMMAND ${_conf_cmd} + ${_cross_cmd} + ${_pic_cmd} + ${_arch_cmd} + ${_cc_cmd} + "--prefix=${DESTDIR}" + ${_link_cmd} + ${_minos_cmd} + --disable-doc + --enable-small + --disable-outdevs + --disable-filters + --enable-filter=*null*,afade,*fifo,*format,*resample,aeval,allrgb,allyuv,atempo,pan,*bars,color,*key,crop,draw*,eq*,framerate,*_qsv,*_vaapi,*v4l2*,hw*,scale,volume,test* + --disable-protocols + --enable-protocol=file,fd,pipe,rtp,udp + --disable-muxers + --enable-muxer=rtp + --disable-encoders + --disable-decoders + --enable-decoder=*aac*,h264*,mp3*,mjpeg,rv* + --disable-demuxers + --enable-demuxer=h264,mp3,mov + --disable-zlib + --disable-avdevice + BUILD_IN_SOURCE ON + BUILD_COMMAND make ${_build_j} + INSTALL_COMMAND make install + ) + +endif() diff --git a/deps/OCCT/0001-OCCT-fix.patch b/deps/OCCT/0001-OCCT-fix.patch index 27f5db7e0f..d251cc7ab6 100644 --- a/deps/OCCT/0001-OCCT-fix.patch +++ b/deps/OCCT/0001-OCCT-fix.patch @@ -1,3 +1,20 @@ +diff --git a/adm/cmake/occt_defs_flags.cmake b/adm/cmake/occt_defs_flags.cmake +index 00000000..00000001 100644 +--- a/adm/cmake/occt_defs_flags.cmake ++++ b/adm/cmake/occt_defs_flags.cmake +@@ -134,7 +134,11 @@ + set (CMAKE_CXX_FLAGS "-std=c++0x ${CMAKE_CXX_FLAGS}") + endif() + # Optimize size of binaries +- set (CMAKE_SHARED_LINKER_FLAGS "-Wl,-s ${CMAKE_SHARED_LINKER_FLAGS}") ++ # clang-cl reports the Clang compiler ID, and OCCT builds shared on Windows, ++ # where the MSVC-style linker gets this flag as an argument it does not know. ++ if (NOT WIN32) ++ set (CMAKE_SHARED_LINKER_FLAGS "-Wl,-s ${CMAKE_SHARED_LINKER_FLAGS}") ++ endif() + elseif(MINGW) + add_definitions(-D_WIN32_WINNT=0x0601) + # _WIN32_WINNT=0x0601 (use Windows 7 SDK) diff --git a/CMakeLists.txt b/CMakeLists.txt index d98acc0f..28eb8eb4 100644 --- a/CMakeLists.txt @@ -168,6 +185,32 @@ index d98acc0f..28eb8eb4 100644 endforeach() if (BUILD_SAMPLES_QT) +diff --git a/adm/cmake/occt_macros.cmake b/adm/cmake/occt_macros.cmake +index 224c96b1..8c94a1c5 100644 +--- a/adm/cmake/occt_macros.cmake ++++ b/adm/cmake/occt_macros.cmake +@@ -608,7 +608,7 @@ macro (OCCT_INSERT_CODE_FOR_TARGET) + install(CODE "if (\"\${CMAKE_INSTALL_CONFIG_NAME}\" MATCHES \"^([Rr][Ee][Ll][Ee][Aa][Ss][Ee])$\") + set (OCCT_INSTALL_BIN_LETTER \"\") + elseif (\"\${CMAKE_INSTALL_CONFIG_NAME}\" MATCHES \"^([Rr][Ee][Ll][Ww][Ii][Tt][Hh][Dd][Ee][Bb][Ii][Nn][Ff][Oo])$\") +- set (OCCT_INSTALL_BIN_LETTER \"i\") ++ set (OCCT_INSTALL_BIN_LETTER \"\") + elseif (\"\${CMAKE_INSTALL_CONFIG_NAME}\" MATCHES \"^([Dd][Ee][Bb][Uu][Gg])$\") + set (OCCT_INSTALL_BIN_LETTER \"d\") + endif()") +diff --git a/adm/cmake/occt_toolkit.cmake b/adm/cmake/occt_toolkit.cmake +index 550e0e2f..7ac1a3b8 100644 +--- a/adm/cmake/occt_toolkit.cmake ++++ b/adm/cmake/occt_toolkit.cmake +@@ -241,7 +241,7 @@ + else() + set (aReleasePdbConf) + endif() +- install (FILES ${CMAKE_BINARY_DIR}/${OS_WITH_BIT}/${COMPILER}/bin\${OCCT_INSTALL_BIN_LETTER}/${PROJECT_NAME}.pdb ++ install (FILES $ + CONFIGURATIONS Debug ${aReleasePdbConf} RelWithDebInfo + DESTINATION "${INSTALL_DIR_BIN}\${OCCT_INSTALL_BIN_LETTER}") + endif() diff --git a/src/Font/Font_FTFont.cxx b/src/Font/Font_FTFont.cxx index 5ae9899f..0a17372b 100644 --- a/src/Font/Font_FTFont.cxx diff --git a/deps/OpenSSL/OpenSSL.cmake b/deps/OpenSSL/OpenSSL.cmake index e43997265b..ddeb680052 100644 --- a/deps/OpenSSL/OpenSSL.cmake +++ b/deps/OpenSSL/OpenSSL.cmake @@ -17,10 +17,20 @@ else() endif() if(WIN32) - set(_conf_cmd perl Configure ) + set(_openssl_msvc_env CC=cl CXX=cl RC=rc CL=/FS) + # OpenSSL's perl Configure honors the CC environment variable, but the + # VC-WIN64A makefile only works with cl (an unquoted clang-cl path with + # spaces, e.g. exported by CLion, silently produces no .obj files and the + # lib step fails with LNK1181). Pin the upstream toolchain. + # Keep rc.exe resolved from the MSVC developer environment as well. The + # absolute Windows SDK path contains spaces and OpenSSL 1.1.1 writes it to + # the generated nmake file without quoting, which skips .res generation. + # /FS serializes access to OpenSSL's shared generated PDB when cl is + # driven through nmake from a Ninja configure step. + set(_conf_cmd ${CMAKE_COMMAND} -E env ${_openssl_msvc_env} perl Configure ) set(_cross_comp_prefix_line "") - set(_make_cmd nmake) - set(_install_cmd nmake install_sw ) + set(_make_cmd ${CMAKE_COMMAND} -E env ${_openssl_msvc_env} nmake) + set(_install_cmd ${CMAKE_COMMAND} -E env ${_openssl_msvc_env} nmake install_sw ) else() if(APPLE) set(_conf_cmd export MACOSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET} && ./Configure -mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET}) diff --git a/deps/wxInspector/wxInspector.cmake b/deps/wxInspector/wxInspector.cmake index 97c3810809..4a5b28407f 100644 --- a/deps/wxInspector/wxInspector.cmake +++ b/deps/wxInspector/wxInspector.cmake @@ -1,3 +1,26 @@ +# wxInspector finds wxWidgets through CMake's FindwxWidgets module, which only +# searches lib/vc*_lib because _WX_TOOL is hardcoded to "vc". A superbuild driven +# by clang-cl installs wxWidgets into lib/clang_x64_lib, so hand the module the +# directory wxWidgets actually used, derived the same way wxWidgetsConfig.cmake +# derives it. +set(_wxinspector_wx_hints "") +if (MSVC) + if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + set(_wx_compiler_prefix "clang") + else () + set(_wx_compiler_prefix "vc") + endif () + set(_wx_arch_suffix "") + if (CMAKE_GENERATOR_PLATFORM AND NOT CMAKE_GENERATOR_PLATFORM STREQUAL "Win32") + string(TOLOWER "_${CMAKE_GENERATOR_PLATFORM}" _wx_arch_suffix) + elseif (CMAKE_SIZEOF_VOID_P EQUAL 8) + set(_wx_arch_suffix "_x64") + endif () + set(_wxinspector_wx_hints + "-DwxWidgets_ROOT_DIR=${DESTDIR}" + "-DwxWidgets_LIB_DIR=${DESTDIR}/lib/${_wx_compiler_prefix}${_wx_arch_suffix}_lib") +endif () + orcaslicer_add_cmake_project( wxInspector URL https://github.com/Noisyfox/wxInspector/archive/refs/tags/v1.0.0.zip @@ -6,6 +29,7 @@ orcaslicer_add_cmake_project( CMAKE_ARGS -DCMAKE_CXX_FLAGS="-DwxDEBUG_LEVEL=0" -DCMAKE_POSITION_INDEPENDENT_CODE=ON + ${_wxinspector_wx_hints} ) if (MSVC) diff --git a/deps/wxWidgets/0001-Clang-CL-fix.patch b/deps/wxWidgets/0001-Clang-CL-fix.patch deleted file mode 100644 index 23bf23b3f4..0000000000 --- a/deps/wxWidgets/0001-Clang-CL-fix.patch +++ /dev/null @@ -1,28 +0,0 @@ ---- - build/cmake/wxWidgetsConfig.cmake.in | 10 +++++++++- - 1 file changed, 10 insertions(+), 1 deletion(-) - -diff --git a/build/cmake/wxWidgetsConfig.cmake.in b/build/cmake/wxWidgetsConfig.cmake.in -index 1a83f36..70ad8a4 100644 ---- a/build/cmake/wxWidgetsConfig.cmake.in -+++ b/build/cmake/wxWidgetsConfig.cmake.in -@@ -58,7 +58,16 @@ if(WIN32_MSVC_NAMING) - endif() - endif() - --include("${CMAKE_CURRENT_LIST_DIR}${wxPLATFORM_LIB_DIR}/@PROJECT_NAME@Targets.cmake") -+if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang" AND CMAKE_CXX_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC") -+ if (CMAKE_GENERATOR_PLATFORM STREQUAL "ARM64" OR CMAKE_VS_PLATFORM_NAME STREQUAL "ARM64" OR CMAKE_SYSTEM_PROCESSOR MATCHES "^(ARM64|arm64|aarch64)$") -+ set(_wx_clang_msvc_lib_dir "vc_arm64_lib") -+ else() -+ set(_wx_clang_msvc_lib_dir "vc_x64_lib") -+ endif() -+ include("${CMAKE_CURRENT_LIST_DIR}${wxPLATFORM_LIB_DIR}/${_wx_clang_msvc_lib_dir}/@PROJECT_NAME@Targets.cmake") -+else() -+ include("${CMAKE_CURRENT_LIST_DIR}${wxPLATFORM_LIB_DIR}/@PROJECT_NAME@Targets.cmake") -+endif() - - macro(wx_inherit_property source dest name) - # property name without _ --- -2.43.0 diff --git a/deps/wxWidgets/wxWidgets.cmake b/deps/wxWidgets/wxWidgets.cmake index 07bb31d8be..1e2cc85f78 100644 --- a/deps/wxWidgets/wxWidgets.cmake +++ b/deps/wxWidgets/wxWidgets.cmake @@ -28,7 +28,6 @@ orcaslicer_add_cmake_project( GIT_SHALLOW ON GIT_SUBMODULES 3rdparty/catch 3rdparty/pcre 3rdparty/libwebp DEPENDS ${PNG_PKG} ${ZLIB_PKG} ${EXPAT_PKG} ${JPEG_PKG} - PATCH_COMMAND git apply --verbose --ignore-space-change --whitespace=fix ${CMAKE_CURRENT_LIST_DIR}/0001-Clang-CL-fix.patch CMAKE_ARGS -DwxBUILD_PRECOMP=ON ${_wx_toolkit} diff --git a/docs/superpowers/plans/2026-08-14-ffmpeg-player-macos.md b/docs/superpowers/plans/2026-08-14-ffmpeg-player-macos.md new file mode 100644 index 0000000000..811626fc8b --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-ffmpeg-player-macos.md @@ -0,0 +1,376 @@ +# macOS FFmpeg Media Player Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make macOS use the same FFmpeg-based media player (`wxMediaCtrl3` + `AVVideoDecoder`) as Windows/Linux, linking the static FFmpeg libraries from the deps build, and remove the old `wxMediaCtrl2.mm` BambuPlayer-based player. + +**Architecture:** The new player is platform-neutral C++ already used on Linux/Windows. Enabling it on macOS is pure build wiring: compile `wxMediaCtrl3.cpp` + `AVVideoDecoder.cpp` on macOS, drop the `__WXMAC__` alias that redirects `wxMediaCtrl3` to the old `wxMediaCtrl2`, and link static FFmpeg (`libavcodec.a`/`libswscale.a`/`libavutil.a`) from the deps install. The Bambu stream API is dlsym'd at runtime from the network plugin (`libBambuSource.dylib`), which already exports it — no plugin changes needed. Rendering reuses the existing `wxImage` → `DrawBitmap` paint path (same as Linux). + +**Tech Stack:** C++17, wxWidgets, CMake, FFmpeg 7.0.3 (libavcodec/libswscale/libavutil), macOS (Xcode generator), `deps/` ExternalProject build system. + +## Global Constraints + +- Branch: `dev/ffmpeg-player-macos`. Commit after every task. +- **Linux and Windows builds must not change** — the FFmpeg deps flag change is guarded by `APPLE`; Linux keeps `--enable-shared`, Windows keeps its prebuilt DLL zips. +- Static FFmpeg only on macOS: deps produce `libavcodec.a`/`libswscale.a`/`libavutil.a`; the app links those explicitly — the app binary must have **no** `libav*` dylib references (`otool -L` check). +- Follow existing code style: PascalCase classes, snake_case functions, C++17. +- No changes to `StatusPanel.cpp`, `MediaPlayCtrl.*`, or the BambuTunnel interface — the app already creates `wxMediaCtrl3` and uses only its public interface. +- The player cannot be unit-tested (hardware/plugin-dependent GUI code); verification is build-level, link-level, and manual runtime on a Mac. +- `localization/i18n/list.txt` references only `wxMediaCtrl2.cpp` (Win/Linux, stays) — no translation-list changes needed. +- Build dirs on the dev machine: main app = `build_arm64/` (Xcode generator, multi-config), deps = `deps/build/arm64/` (Unix Makefiles). App target name: `OrcaSlicer`. Substitute your own configured build dirs where noted. + +--- + +### Task 1: Enable wxMediaCtrl3 on macOS and link static FFmpeg + +**Files:** +- Modify: `src/slic3r/GUI/wxMediaCtrl3.h` (lines 18–22: the `#ifdef __WXMAC__` alias branch) +- Modify: `src/slic3r/GUI/wxMediaCtrl3.cpp:13` (uncomment the event define) +- Modify: `src/slic3r/GUI/wxMediaCtrl2.cpp:101` (remove the event define) +- Modify: `src/slic3r/CMakeLists.txt` (APPLE source list ~lines 779–792; FFmpeg link block ~lines 905–910) + +**Interfaces:** +- Consumes: nothing new (all classes already exist). +- Produces: `wxMediaCtrl3` class compiled on macOS with the same interface as Linux/Windows — `Load(wxURI)`, `Play()`, `Stop()`, `SetIdleImage(wxString)`, `GetState()`, `GetLastError()`, `GetVideoSize()`, event `EVT_MEDIA_CTRL_STAT` defined once in the lib (from `wxMediaCtrl3.cpp`). + +- [ ] **Step 1: Remove the macOS alias in wxMediaCtrl3.h** + +Current (lines 16–23 of `src/slic3r/GUI/wxMediaCtrl3.h`): + +```cpp +void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, int height); + +#ifdef __WXMAC__ + +#include "wxMediaCtrl2.h" +#define wxMediaCtrl3 wxMediaCtrl2 + +#else + +#define BAMBU_DYNAMIC +``` + +New: + +```cpp +void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, int height); + +#define BAMBU_DYNAMIC +``` + +Also remove the matching `#endif` that closed the `#else` branch (the one before the final `#endif /* wxMediaCtrl3_h */`), so the file's `#ifndef`/`#endif` guard pair stays balanced. + +- [ ] **Step 2: Move the EVT_MEDIA_CTRL_STAT definition into wxMediaCtrl3.cpp** + +In `src/slic3r/GUI/wxMediaCtrl3.cpp:13`, uncomment: + +```cpp +//wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); +``` + +becomes: + +```cpp +wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); +``` + +In `src/slic3r/GUI/wxMediaCtrl2.cpp:101`, delete: + +```cpp +wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); +``` + +(One definition total in the lib — `MediaPlayCtrl.cpp:59` binds this event on the media ctrl.) + +- [ ] **Step 3: Update the APPLE source list in CMakeLists.txt** + +In `src/slic3r/CMakeLists.txt`, the APPLE branch (currently compiles `wxMediaCtrl2.mm`, which becomes dead on macOS): + +```cmake + GUI/wxMediaCtrl2.mm + GUI/wxMediaCtrl2.h + GUI/wxMediaCtrl3.h + ) +``` + +becomes: + +```cmake + GUI/AVVideoDecoder.cpp + GUI/AVVideoDecoder.hpp + GUI/wxMediaCtrl3.cpp + GUI/wxMediaCtrl3.h + ) +``` + +(The `else ()` branch — Win/Linux — stays exactly as it is.) + +- [ ] **Step 4: Link static FFmpeg on macOS** + +In `src/slic3r/CMakeLists.txt`, the FFmpeg block (currently `if (NOT APPLE)`): + +```cmake +if (NOT APPLE) + pkg_check_modules(LIBAV REQUIRED IMPORTED_TARGET + libavcodec + libswscale + libavutil + ) + target_link_libraries(libslic3r_gui PkgConfig::LIBAV) +endif() +``` + +becomes: + +```cmake +if (APPLE) + # Static FFmpeg from the deps install: nothing to bundle into the .app, + # no rpath/install_name handling. Order matters: avcodec -> swscale -> avutil. + find_library(LIBAVCODEC_LIBRARY NAMES libavcodec.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) + find_library(LIBSWSCALE_LIBRARY NAMES libswscale.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) + find_library(LIBAVUTIL_LIBRARY NAMES libavutil.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) + target_link_libraries(libslic3r_gui ${LIBAVCODEC_LIBRARY} ${LIBSWSCALE_LIBRARY} ${LIBAVUTIL_LIBRARY}) + target_include_directories(libslic3r_gui SYSTEM PRIVATE ${CMAKE_PREFIX_PATH}/include) +else () + pkg_check_modules(LIBAV REQUIRED IMPORTED_TARGET + libavcodec + libswscale + libavutil + ) + target_link_libraries(libslic3r_gui PkgConfig::LIBAV) +endif() +``` + +The deps install (`${CMAKE_PREFIX_PATH}/lib`) already contains the three `.a` files from the existing arm64 deps build — no deps rebuild needed for this task. + +- [ ] **Step 5: Reconfigure and build the app** + +Run (Xcode generator; `cmake` re-runs automatically on build): + +```bash +cmake --build build_arm64 --config RelWithDebInfo --target OrcaSlicer +``` + +Expected: configure succeeds (no `pkg_check_modules` errors on macOS, `find_library` finds all three `.a` files), compile succeeds (`wxMediaCtrl3.cpp` and `AVVideoDecoder.cpp` compile on macOS without changes), link succeeds. + +If CMake complains that `wxMediaCtrl3.h` is included but not in the source list or similar IDE-only warnings — ignore; headers in the list are cosmetic. + +- [ ] **Step 6: Verify no dynamic FFmpeg dependency** + +```bash +otool -L build_arm64/src/RelWithDebInfo/OrcaSlicer.app/Contents/MacOS/OrcaSlicer | grep -i "libav" || echo "OK: no dynamic FFmpeg" +``` + +Expected: prints `OK: no dynamic FFmpeg` (empty grep output). This is the whole point of static linking — nothing to bundle into the `.app`. + +- [ ] **Step 7: Quick sanity — macOS unit tests still pass** + +```bash +ctest --test-dir build_arm64/tests/libslic3r --output-on-failure +``` + +Expected: passes (add `-C RelWithDebInfo` if the multi-config generator requires it). If no tests were built in this build dir, build target `tests` first (`cmake --build build_arm64 --config RelWithDebInfo --target tests`). + +- [ ] **Step 8: Commit** + +```bash +git add src/slic3r/CMakeLists.txt src/slic3r/GUI/wxMediaCtrl3.h src/slic3r/GUI/wxMediaCtrl3.cpp src/slic3r/GUI/wxMediaCtrl2.cpp +git commit -m "feat: use FFmpeg media player on macOS with static FFmpeg" +``` + +--- + +### Task 2: Static-only FFmpeg in the macOS deps build + +**Files:** +- Modify: `deps/FFMPEG/FFMPEG.cmake` (non-MSVC branch, APPLE section and CONFIGURE_COMMAND) + +**Interfaces:** +- Consumes: nothing. +- Produces: a deps install on macOS containing only `libavcodec.a`, `libswscale.a`, `libavutil.a` (+ headers) — no `libav*` dylibs, so no bundling/rpath machinery is ever needed on macOS. Linux and Windows output are unchanged. + +- [ ] **Step 1: Add the static flag variable** + +In `deps/FFMPEG/FFMPEG.cmake`, inside the non-MSVC `else ()` branch, in the existing `if (APPLE)` block: + +```cmake + if (APPLE) + set(_minos_cmd + "CFLAGS=-mmacosx-version-min=${DEP_OSX_TARGET}" + "LDFLAGS=-mmacosx-version-min=${DEP_OSX_TARGET}" + ) +``` + +add after the `_minos_cmd` set: + +```cmake + # Static FFmpeg: nothing to bundle into the .app, no rpath handling. + # Shared flags must come AFTER --enable-shared below so they win. + set(_link_cmd --enable-static --disable-shared) +``` + +and add a matching `else ()` after the `if (IS_CROSS_COMPILE) ... endif()` block inside that `if (APPLE)`, so non-Apple Unix keeps shared: + +```cmake + else () + set(_link_cmd --enable-shared) + endif () +``` + +(If the existing `if (IS_CROSS_COMPILE)` block is the last thing inside `if (APPLE)`, the new `else ()` closes the `if (APPLE)` itself.) + +- [ ] **Step 2: Use the variable in CONFIGURE_COMMAND** + +In the `ExternalProject_Add(dep_FFMPEG ...)` configure command: + +```cmake + "--prefix=${DESTDIR}" + --enable-shared +``` + +becomes: + +```cmake + "--prefix=${DESTDIR}" + --enable-shared + ${_link_cmd} +``` + +Order matters: `--enable-shared` comes first, then `--enable-static --disable-shared` (APPLE) or `--enable-shared` (Linux) — the last flag wins in FFmpeg configure. + +- [ ] **Step 3: Rebuild the FFmpeg dep (slow — several minutes, run in background)** + +The changed CONFIGURE_COMMAND invalidates the ExternalProject stamp, so this re-configures and rebuilds FFmpeg: + +```bash +cmake --build deps/build/arm64 --target dep_FFMPEG +``` + +For a fully clean static-only check (removes the previous shared build tree, which can leave stale `.dylib` files behind in the in-source build): + +```bash +rm -rf deps/build/arm64/dep_FFMPEG-prefix +cmake --build deps/build/arm64 --target dep_FFMPEG +``` + +- [ ] **Step 4: Verify the artifacts** + +```bash +ls deps/build/arm64/dep_FFMPEG-prefix/src/dep_FFMPEG/libavcodec/*.a +ls deps/build/arm64/dep_FFMPEG-prefix/src/dep_FFMPEG/libavcodec/*.dylib 2>/dev/null || echo "OK: no dylibs" +``` + +Expected: `libavcodec.a` present, second command prints `OK: no dylibs`. Check `libavutil` and `libswscale` the same way. + +- [ ] **Step 5: Verify the app still links against the static libs** + +```bash +cmake --build build_arm64 --config RelWithDebInfo --target OrcaSlicer +otool -L build_arm64/src/RelWithDebInfo/OrcaSlicer.app/Contents/MacOS/OrcaSlicer | grep -i "libav" || echo "OK: no dynamic FFmpeg" +``` + +Expected: build succeeds, `OK: no dynamic FFmpeg`. + +- [ ] **Step 6: Commit** + +```bash +git add deps/FFMPEG/FFMPEG.cmake +git commit -m "build: build static-only FFmpeg for macOS deps" +``` + +--- + +### Task 3: Remove the old macOS player + +**Files:** +- Delete: `src/slic3r/GUI/wxMediaCtrl2.mm` +- Delete: `src/slic3r/GUI/BambuPlayer/BambuPlayer.h` (and the empty `BambuPlayer/` dir) +- Modify: `src/slic3r/GUI/wxMediaCtrl2.h` (remove the `#ifdef __WXMAC__` section, lines 22–60) + +**Interfaces:** +- Consumes: Task 1 (macOS no longer references `wxMediaCtrl2` — nothing includes `wxMediaCtrl2.h` on macOS anymore; `wxMediaCtrl2` is never instantiated on any platform). +- Produces: a clean tree where the old BambuPlayer-based player is gone from macOS. The `BambuPlayer` ObjC class itself remains inside the network plugin (external prebuilt binary) — only the GUI-side consumer is removed. + +- [ ] **Step 1: Delete the old player files** + +```bash +git rm src/slic3r/GUI/wxMediaCtrl2.mm +git rm src/slic3r/GUI/BambuPlayer/BambuPlayer.h +rmdir src/slic3r/GUI/BambuPlayer 2>/dev/null || true +``` + +- [ ] **Step 2: Strip the __WXMAC__ section from wxMediaCtrl2.h** + +In `src/slic3r/GUI/wxMediaCtrl2.h`, remove the entire macOS branch of the `#ifdef __WXMAC__` guard — from `#ifdef __WXMAC__` (line 22) through the closing `};` of the mac class (line 60), and the `#else` marker — leaving only the non-mac `class wxMediaCtrl2 : public wxMediaCtrl { ... };` definition followed by the final `#endif /* wxMediaCtrl2_h */`. The resulting file keeps its `#ifndef`/`#endif` include guard pair balanced. + +The file stays on disk because Win/Linux compile `wxMediaCtrl2.cpp`, which includes it. + +- [ ] **Step 3: Grep for leftover references** + +```bash +grep -rn "wxMediaCtrl2.mm\|BambuPlayer/BambuPlayer.h\|BambuPlayer" src/slic3r --include="*.cpp" --include="*.h" --include="*.mm" --include="*.txt" +``` + +Expected: no hits in `src/slic3r/GUI` (ignore `localization/i18n/list.txt:196`, which lists the Win/Linux `wxMediaCtrl2.cpp` and stays). + +- [ ] **Step 4: Rebuild the app** + +```bash +cmake --build build_arm64 --config RelWithDebInfo --target OrcaSlicer +``` + +Expected: configure + compile + link succeed with the deleted files gone. + +- [ ] **Step 5: Commit** + +```bash +git add -A src/slic3r/GUI +git commit -m "refactor: remove old BambuPlayer-based media player from macOS" +``` + +--- + +### Task 4: Runtime verification on hardware + +**Files:** none — manual verification. + +**Interfaces:** consumes all prior tasks. Final gate: the new player must actually stream on a Mac. + +- [ ] **Step 1: Launch the freshly built app** + +```bash +open build_arm64/src/RelWithDebInfo/OrcaSlicer.app +``` + +Expected: app launches normally; no crash in the network/device subsystem. + +- [ ] **Step 2: Load the network plugin and open the Device tab** + +Log in / ensure the network plugin (`libBambuSource.dylib`) loads, select a printer, open the Device tab (camera monitoring panel). + +Expected: the camera preview area shows the idle image initially (no crash — this exercises `wxMediaCtrl3::SetIdleImage` and the `wxImage` load path on macOS for the first time). + +- [ ] **Step 3: Start the stream and watch it render** + +Click play / wait for `MediaPlayCtrl` to start the stream. + +Expected: live video renders in the panel. Check the console/log output (`BOOST_LOG` goes to the terminal if run from it, or check the log file): +- `stat_log ...` lines appear (the `EVT_MEDIA_CTRL_STAT` path is live — proves the Bambu C API dlsym worked from `libBambuSource.dylib`); +- no repeated decode/error messages like `AVVideoDecoder: ...` or `can not find function ...` (proves `StaticBambuLib::get` resolved all Bambu functions); +- Stop/Play toggle works; idle image reappears on stop; +- window resize keeps aspect ratio (exercises `DoSetSize`/`adjust_frame_size`/`paintEvent`). + +- [ ] **Step 4: Confirm the old player is really gone** + +Expected: nothing in the logs references `BambuPlayer` (the ObjC class is no longer dlsym'd); the video path is entirely `wxMediaCtrl3` + `AVVideoDecoder`. + +If a printer is unavailable, at minimum verify Steps 1–2 (launch + idle image) and note in the PR that live-stream verification needs hardware. + +- [ ] **Step 5: Final review pass** + +```bash +git log --oneline -6 +git show --stat HEAD # and each of the three task commits +``` + +Expected: the last 4 commits are the design doc + the 3 implementation tasks (each task commit touches only its listed files). Review the diff for scope: no Linux/Windows changes beyond the two `EVT_MEDIA_CTRL_STAT` lines in Task 1, no `StatusPanel`/`MediaPlayCtrl` changes. diff --git a/docs/superpowers/specs/2026-08-14-ffmpeg-player-macos-design.md b/docs/superpowers/specs/2026-08-14-ffmpeg-player-macos-design.md new file mode 100644 index 0000000000..812a7bf735 --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-ffmpeg-player-macos-design.md @@ -0,0 +1,110 @@ +# FFmpeg Media Player for macOS — Design + +Date: 2026-08-14 +Branch: `dev/ffmpeg-player-macos` + +## Problem + +The branch's new FFmpeg-based media player (`wxMediaCtrl3` + `AVVideoDecoder`) is used on +Windows and Linux, but macOS still runs the old player: `wxMediaCtrl2.mm`, an ObjC +`BambuPlayer` class dlsym'd from the Bambu network plugin that renders via CALayer. +On macOS, `wxMediaCtrl3` is currently aliased to `wxMediaCtrl2` and FFmpeg is not linked +into the app at all. + +Goal: make macOS use the same FFmpeg player as Windows/Linux, linking the **static** +FFmpeg libraries from the deps build instead of dynamic ones. + +## Current state (verified) + +- New player (Win/Linux): `GUI/wxMediaCtrl3.cpp` + `GUI/AVVideoDecoder.cpp`. Decodes with + FFmpeg (libavcodec/libswscale/libavutil), renders frames into `wxImage` (non-Windows) / + `wxBitmap` (Windows) drawn in a `paintEvent`, feeds via the `Bambu_*` C API + (`BambuTunnel.h`, `BAMBU_DYNAMIC`) dlsym'd from the network plugin through + `StaticBambuLib::get()` (`GUI/Printer/PrinterFileSystem.cpp`, compiled on all platforms). +- Old player (macOS): `GUI/wxMediaCtrl2.mm` uses the ObjC `BambuPlayer` class found via + `dlsym(module, "OBJC_CLASS_$_BambuPlayer")` in `libBambuSource.dylib`. +- The macOS network plugin `libBambuSource.dylib` already exports the full Bambu C API + (verified with `nm`), so the new player needs zero plugin changes. +- FFmpeg linking in `src/slic3r/CMakeLists.txt` is guarded by `if (NOT APPLE)` — + macOS currently does not link FFmpeg. +- `deps/FFMPEG/FFMPEG.cmake`: non-MSVC branch builds FFmpeg from source with + `--enable-shared`. The existing arm64 deps build on the dev machine happened to be + configured with both static and shared enabled, so `libavcodec.a` / `libswscale.a` / + `libavutil.a` are already present at + `deps/build/arm64/OrcaSlicer_dep/usr/local/lib/`. +- `EVT_MEDIA_CTRL_STAT` is `wxDEFINE_EVENT`'d in `wxMediaCtrl2.cpp` (Win/Linux) and + `wxMediaCtrl2.mm` (macOS); the define in `wxMediaCtrl3.cpp` is commented out. +- `wxMediaCtrl2` is never instantiated anywhere on any platform — dead code. +- `StatusPanel` already creates `wxMediaCtrl3`; `MediaPlayCtrl` only uses the + `wxMediaCtrl3` interface (`Load/Play/Stop/GetState/GetVideoSize/GetLastError/SetIdleImage`), + so no UI-side changes are needed. + +## Approach (approved) + +**Reuse the shared player on macOS.** Compile the existing `wxMediaCtrl3.cpp` + +`AVVideoDecoder.cpp` on macOS so all three platforms run one implementation. +Rendering uses the existing `wxImage` → `DrawBitmap` paint path, identical to Linux. +Known trade-off: frames are scaled to the widget's logical (1x) size, so Retina is +slightly soft compared to the old CALayer player. Accepted for now; a Retina-aware +scaling follow-up is possible later. + +Rejected alternative: a native CGImage/CALayer renderer for macOS — faster and +Retina-crisp, but adds a second render implementation to maintain. + +## Changes + +### 1. Enable the FFmpeg player on macOS (source) + +- `GUI/wxMediaCtrl3.h`: remove the `#ifdef __WXMAC__` branch (lines 18–22) that aliases + `wxMediaCtrl3` → `wxMediaCtrl2`. macOS then compiles the real `wxMediaCtrl3` class, + including the `BAMBU_DYNAMIC` BambuTunnel path used on Linux. +- Event symbol fix: move `wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent)` into + `wxMediaCtrl3.cpp` (uncomment the existing line) and remove it from + `wxMediaCtrl2.cpp`. One definition total in the lib; all three platforms resolve it. + +### 2. Static FFmpeg linking (deps + app) + +- `deps/FFMPEG/FFMPEG.cmake`: in the non-MSVC branch, pass + `--disable-shared --enable-static` when `APPLE`. Linux keeps `--enable-shared`; + Windows keeps its prebuilt shared DLL zips. Fresh macOS deps builds install only + `libavcodec.a` / `libswscale.a` / `libavutil.a` — no dylibs to bundle, no + rpath/install_name handling. (The existing local arm64 deps build already contains + the `.a` files, so no deps rebuild is strictly needed to try the change locally, + but a fresh CI deps build must produce them.) +- `src/slic3r/CMakeLists.txt`: + - APPLE branch of `SLIC3R_GUI_SOURCES`: add `GUI/wxMediaCtrl3.cpp`, + `GUI/wxMediaCtrl3.h`, `GUI/AVVideoDecoder.cpp`, `GUI/AVVideoDecoder.hpp`; + remove `GUI/wxMediaCtrl2.mm` and `GUI/wxMediaCtrl2.h` (the `.h` stays on + disk for the Win/Linux build of `wxMediaCtrl2.cpp`, but nothing on macOS + includes it after this change). + - Add an APPLE mirror of the `NOT APPLE` FFmpeg block: `find_library` for + `libavcodec.a`, `libswscale.a`, `libavutil.a` under `${CMAKE_PREFIX_PATH}/lib` + with `NO_DEFAULT_PATH`, link them (order avcodec → swscale → avutil), and add + `${CMAKE_PREFIX_PATH}/include` as a SYSTEM include directory. Deps are built with + `--disable-zlib` and no external codecs, so the three static libs link cleanly. + +### 3. Remove the old player + +- Delete `GUI/wxMediaCtrl2.mm` and `GUI/BambuPlayer/BambuPlayer.h` (header used only + by the `.mm`; the real `BambuPlayer` lives inside the network plugin). +- Remove the now-dead `__WXMAC__` section of `GUI/wxMediaCtrl2.h`. +- `wxMediaCtrl2.cpp` (Win/Linux) stays in the build as-is (dead but harmless; out of + scope to remove on this branch). + +### 4. Verification + +- Build on macOS: `cmake --build build_arm64` (or `build/arm64`). +- Confirm no dynamic FFmpeg dependency: `otool -L` on the app binary shows no `libav*` + dylib references. +- Runtime: with the network plugin loaded, the Device tab camera preview streams via + the FFmpeg player (check the device page / `MediaPlayCtrl`). +- macOS `ctest` still passes — static linking means no test-executable `.so` copying + hacks (unlike the Linux shared-lib setup). + +## Out of scope + +- Linux (shared libs, AppImage/flatpak bundling) and Windows (prebuilt DLL zips) + keep their current FFmpeg setup. +- Retina-aware frame scaling / native CGImage rendering (follow-up if visual quality + is judged insufficient). +- Audio streaming (neither player plays audio in this UI path). diff --git a/localization/i18n/list.txt b/localization/i18n/list.txt index 1614bc453b..5174679abf 100644 --- a/localization/i18n/list.txt +++ b/localization/i18n/list.txt @@ -193,7 +193,6 @@ src/slic3r/GUI/ObjColorDialog.cpp src/slic3r/GUI/SyncAmsInfoDialog.cpp src/slic3r/GUI/WipeTowerDialog.cpp src/slic3r/GUI/wxExtensions.cpp -src/slic3r/GUI/wxMediaCtrl2.cpp src/slic3r/GUI/WebUserLoginDialog.cpp src/slic3r/GUI/WebGuideDialog.cpp src/slic3r/GUI/KBShortcutsDialog.hpp diff --git a/resources/filament_mixing/standard_color_recipes.json b/resources/filament_mixing/standard_color_recipes.json new file mode 100644 index 0000000000..df03b49ac3 --- /dev/null +++ b/resources/filament_mixing/standard_color_recipes.json @@ -0,0 +1,14705 @@ +{ + "_comment": "Simulated values (source=filament_mixer) are generated by FilamentMixer, a degree-4 polynomial regression trained to approximate Mixbox behavior (Mean Delta-E ~2.07). This file does not use Mixbox source code, binaries, or data files. See src/libslic3r/FilamentMixerModel.hpp.", + "entries": [ + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 48.14, + 33.87, + -25.42 + ], + "measured_rgb": "#965E9E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 48.2, + 33.11, + -25.85 + ], + "measured_rgb": "#955F9E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 48.06, + 29.44, + -27.62 + ], + "measured_rgb": "#8D61A1", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 47.82, + 28.45, + -28.19 + ], + "measured_rgb": "#8A62A1", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 47.94, + 22.08, + -30.15 + ], + "measured_rgb": "#7D67A5", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 47.64, + 24.63, + -30.31 + ], + "measured_rgb": "#8164A4", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 48.28, + 17.77, + -31.76 + ], + "measured_rgb": "#746BA8", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 48.15, + 16.73, + -32.42 + ], + "measured_rgb": "#706BA9", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 48.57, + 17.11, + -32.94 + ], + "measured_rgb": "#716CAB", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 48.95, + 13.88, + -33.83 + ], + "measured_rgb": "#6A6FAE", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 49.1, + 13.76, + -34.39 + ], + "measured_rgb": "#6A70AF", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 49.58, + 11.74, + -35.13 + ], + "measured_rgb": "#6572B1", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 50.88, + 5.61, + -36.4 + ], + "measured_rgb": "#5679B7", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 70.0, + -35.0, + 56.27 + ], + "measured_rgb": "#8ABA3C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 69.5, + -36.46, + 55.41 + ], + "measured_rgb": "#85B93C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 67.44, + -38.62, + 50.18 + ], + "measured_rgb": "#78B443", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 66.34, + -39.72, + 47.6 + ], + "measured_rgb": "#71B246", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 65.4, + -40.42, + 42.8 + ], + "measured_rgb": "#6AB04E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 63.51, + -42.24, + 38.44 + ], + "measured_rgb": "#5DAB52", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 63.04, + -42.17, + 35.8 + ], + "measured_rgb": "#59AA56", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 62.21, + -43.03, + 32.64 + ], + "measured_rgb": "#51A85A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 60.68, + -43.94, + 27.49 + ], + "measured_rgb": "#44A560", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 60.36, + -43.76, + 23.75 + ], + "measured_rgb": "#3FA466", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 59.06, + -44.3, + 17.42 + ], + "measured_rgb": "#2CA16E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 58.47, + -44.1, + 14.26 + ], + "measured_rgb": "#219F72", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 58.09, + -43.7, + 10.53 + ], + "measured_rgb": "#139E78", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 73.74, + -14.74, + -21.41 + ], + "measured_rgb": "#78BFDC", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 71.76, + -15.4, + -24.59 + ], + "measured_rgb": "#69BADC", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 70.11, + -15.73, + -25.86 + ], + "measured_rgb": "#60B6DA", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 68.1, + -16.03, + -28.26 + ], + "measured_rgb": "#53B1D8", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 67.93, + -15.44, + -28.32 + ], + "measured_rgb": "#55B0D8", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 66.5, + -15.79, + -29.78 + ], + "measured_rgb": "#4AACD6", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 65.75, + -15.69, + -30.79 + ], + "measured_rgb": "#44AAD6", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 64.67, + -15.68, + -31.78 + ], + "measured_rgb": "#3DA8D5", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 62.88, + -16.04, + -34.06 + ], + "measured_rgb": "#27A3D4", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 62.86, + -15.71, + -34.68 + ], + "measured_rgb": "#26A3D5", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 62.09, + -15.68, + -35.76 + ], + "measured_rgb": "#19A1D5", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 60.73, + -15.52, + -36.7 + ], + "measured_rgb": "#009DD3", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 60.03, + -15.72, + -37.19 + ], + "measured_rgb": "#009CD1", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 65.7, + 19.56, + 48.15 + ], + "measured_rgb": "#D69148", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 63.26, + 25.13, + 42.51 + ], + "measured_rgb": "#D5864E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 60.24, + 30.06, + 32.69 + ], + "measured_rgb": "#D17B59", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 60.41, + 29.22, + 34.96 + ], + "measured_rgb": "#D17C55", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 57.97, + 35.04, + 26.17 + ], + "measured_rgb": "#CF7160", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 57.16, + 35.32, + 24.58 + ], + "measured_rgb": "#CC6F60", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 57.46, + 36.08, + 25.44 + ], + "measured_rgb": "#CE6F60", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 56.02, + 38.35, + 20.53 + ], + "measured_rgb": "#CC6965", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 55.1, + 39.69, + 16.16 + ], + "measured_rgb": "#C9666A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 54.88, + 41.31, + 15.93 + ], + "measured_rgb": "#CB646A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 53.45, + 45.1, + 7.0 + ], + "measured_rgb": "#C85D76", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 53.23, + 44.29, + 7.96 + ], + "measured_rgb": "#C75D73", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 53.17, + 45.44, + 5.5 + ], + "measured_rgb": "#C75C77", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 65.05, + 41.46, + -15.23 + ], + "measured_rgb": "#D981BA", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 64.76, + 41.42, + -14.99 + ], + "measured_rgb": "#D881B9", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 61.49, + 47.18, + -15.56 + ], + "measured_rgb": "#D773B1", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 62.17, + 44.07, + -15.56 + ], + "measured_rgb": "#D577B3", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 61.67, + 45.03, + -15.24 + ], + "measured_rgb": "#D575B1", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 60.38, + 47.58, + -15.12 + ], + "measured_rgb": "#D56FAD", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 57.67, + 51.19, + -15.52 + ], + "measured_rgb": "#D264A7", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 57.19, + 52.57, + -15.0 + ], + "measured_rgb": "#D361A5", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 57.75, + 52.16, + -14.62 + ], + "measured_rgb": "#D463A6", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 57.2, + 51.19, + -14.81 + ], + "measured_rgb": "#D163A4", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 55.57, + 53.66, + -14.82 + ], + "measured_rgb": "#D05BA0", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 55.51, + 53.08, + -14.46 + ], + "measured_rgb": "#CF5C9F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 54.62, + 54.16, + -14.51 + ], + "measured_rgb": "#CE589D", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 87.72, + -15.64, + 55.43 + ], + "measured_rgb": "#E1E26F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 87.51, + -15.48, + 58.85 + ], + "measured_rgb": "#E2E167", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 87.35, + -15.37, + 61.45 + ], + "measured_rgb": "#E3E161", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 87.0, + -14.73, + 63.73 + ], + "measured_rgb": "#E4DF5A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 86.76, + -14.25, + 65.47 + ], + "measured_rgb": "#E4DE56", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 86.6, + -13.9, + 67.58 + ], + "measured_rgb": "#E5DD50", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 86.23, + -14.03, + 72.5 + ], + "measured_rgb": "#E5DC42", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 86.91, + -12.84, + 74.11 + ], + "measured_rgb": "#EADE3F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 86.24, + -13.03, + 75.23 + ], + "measured_rgb": "#E8DC3A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 86.01, + -12.73, + 76.77 + ], + "measured_rgb": "#E8DB34", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 85.85, + -12.44, + 78.22 + ], + "measured_rgb": "#E8DA2E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 85.71, + -12.39, + 81.24 + ], + "measured_rgb": "#E9DA21", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 87.04, + -10.65, + 83.79 + ], + "measured_rgb": "#F0DC1A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 20, + 60 + ], + "measured_lab": [ + 57.59, + -7.31, + 27.59 + ], + "measured_rgb": "#8F8D5A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 25, + 55 + ], + "measured_lab": [ + 56.47, + -5.15, + 29.22 + ], + "measured_rgb": "#908954", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 30, + 50 + ], + "measured_lab": [ + 53.76, + 1.47, + 16.52 + ], + "measured_rgb": "#8E7F64", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 35, + 45 + ], + "measured_lab": [ + 53.89, + 1.26, + 22.63 + ], + "measured_rgb": "#917F5A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 40, + 40 + ], + "measured_lab": [ + 53.08, + 4.21, + 20.07 + ], + "measured_rgb": "#927B5D", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 45, + 35 + ], + "measured_lab": [ + 51.5, + 7.8, + 12.87 + ], + "measured_rgb": "#907565", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 50, + 30 + ], + "measured_lab": [ + 50.11, + 12.7, + 3.91 + ], + "measured_rgb": "#8F6F71", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 55, + 25 + ], + "measured_lab": [ + 49.99, + 13.42, + 6.09 + ], + "measured_rgb": "#916F6D", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 60, + 20 + ], + "measured_lab": [ + 49.64, + 14.45, + 6.31 + ], + "measured_rgb": "#926D6C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 20, + 55 + ], + "measured_lab": [ + 57.53, + -11.27, + 27.15 + ], + "measured_rgb": "#888F5A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 25, + 50 + ], + "measured_lab": [ + 54.99, + -7.03, + 22.56 + ], + "measured_rgb": "#86865C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 30, + 45 + ], + "measured_lab": [ + 54.25, + -4.47, + 21.25 + ], + "measured_rgb": "#88835D", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 35, + 40 + ], + "measured_lab": [ + 53.04, + -2.53, + 17.95 + ], + "measured_rgb": "#867F60", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 40, + 35 + ], + "measured_lab": [ + 51.71, + 2.72, + 11.95 + ], + "measured_rgb": "#887967", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 45, + 30 + ], + "measured_lab": [ + 50.85, + 5.89, + 10.93 + ], + "measured_rgb": "#8A7567", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 50, + 25 + ], + "measured_lab": [ + 49.74, + 8.68, + 4.66 + ], + "measured_rgb": "#88716F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 55, + 20 + ], + "measured_lab": [ + 49.3, + 9.76, + 2.9 + ], + "measured_rgb": "#886F71", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 20, + 50 + ], + "measured_lab": [ + 55.45, + -14.4, + 18.45 + ], + "measured_rgb": "#788B64", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 25, + 45 + ], + "measured_lab": [ + 55.07, + -10.24, + 24.11 + ], + "measured_rgb": "#82885A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 30, + 40 + ], + "measured_lab": [ + 53.44, + -6.68, + 18.83 + ], + "measured_rgb": "#81825F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 52.37, + -3.05, + 13.89 + ], + "measured_rgb": "#817E65", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 51.03, + 0.35, + 11.39 + ], + "measured_rgb": "#827966", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 50.12, + 4.03, + 7.42 + ], + "measured_rgb": "#83756B", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 49.36, + 7.35, + 3.42 + ], + "measured_rgb": "#847170", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 55.52, + -16.34, + 20.5 + ], + "measured_rgb": "#758C61", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 25, + 40 + ], + "measured_lab": [ + 53.19, + -11.0, + 14.49 + ], + "measured_rgb": "#768466", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 30, + 35 + ], + "measured_lab": [ + 53.8, + -8.48, + 16.8 + ], + "measured_rgb": "#7D8463", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 35, + 30 + ], + "measured_lab": [ + 51.71, + -4.82, + 12.37 + ], + "measured_rgb": "#7C7D66", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 40, + 25 + ], + "measured_lab": [ + 49.92, + 7.98, + 2.05 + ], + "measured_rgb": "#867274", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 45, + 20 + ], + "measured_lab": [ + 48.86, + 10.46, + 0.0 + ], + "measured_rgb": "#866E74", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 20, + 40 + ], + "measured_lab": [ + 54.4, + -10.2, + 19.17 + ], + "measured_rgb": "#7D8661", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 25, + 35 + ], + "measured_lab": [ + 52.52, + -4.36, + 12.99 + ], + "measured_rgb": "#7F7F67", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 30, + 30 + ], + "measured_lab": [ + 51.21, + -1.28, + 6.49 + ], + "measured_rgb": "#7D7A6F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 35, + 25 + ], + "measured_lab": [ + 50.18, + 4.0, + 4.31 + ], + "measured_rgb": "#817570", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 40, + 20 + ], + "measured_lab": [ + 48.97, + 7.15, + 1.93 + ], + "measured_rgb": "#827071", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 20, + 35 + ], + "measured_lab": [ + 52.71, + -11.69, + 14.25 + ], + "measured_rgb": "#738365", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 25, + 30 + ], + "measured_lab": [ + 51.33, + -3.15, + 5.87 + ], + "measured_rgb": "#797C70", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 30, + 25 + ], + "measured_lab": [ + 49.87, + -1.14, + 1.5 + ], + "measured_rgb": "#767774", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 35, + 20 + ], + "measured_lab": [ + 50.92, + -2.35, + 7.7 + ], + "measured_rgb": "#7B7A6C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 50, + 20, + 30 + ], + "measured_lab": [ + 52.42, + -12.64, + 11.74 + ], + "measured_rgb": "#6E8369", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 50, + 25, + 25 + ], + "measured_lab": [ + 50.67, + -4.67, + -2.82 + ], + "measured_rgb": "#6D7B7D", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 50, + 30, + 20 + ], + "measured_lab": [ + 49.65, + -0.75, + -0.75 + ], + "measured_rgb": "#747677", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 55, + 20, + 25 + ], + "measured_lab": [ + 52.24, + -12.06, + 7.79 + ], + "measured_rgb": "#6C826F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 55, + 25, + 20 + ], + "measured_lab": [ + 50.78, + -8.82, + 4.42 + ], + "measured_rgb": "#6C7D71", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 60, + 20, + 20 + ], + "measured_lab": [ + 52.04, + -14.16, + -0.63 + ], + "measured_rgb": "#5F837D", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 20, + 60 + ], + "measured_lab": [ + 60.62, + 16.41, + -27.19 + ], + "measured_rgb": "#978BC2", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 25, + 55 + ], + "measured_lab": [ + 58.416, + 18.423, + -27.936 + ], + "measured_rgb": "#9484BD", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 30, + 50 + ], + "measured_lab": [ + 56.19, + 22.63, + -28.47 + ], + "measured_rgb": "#967BB8", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 35, + 45 + ], + "measured_lab": [ + 55.817, + 22.511, + -27.942 + ], + "measured_rgb": "#957AB6", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 40, + 40 + ], + "measured_lab": [ + 56.48, + 23.36, + -26.2 + ], + "measured_rgb": "#9A7BB5", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 45, + 35 + ], + "measured_lab": [ + 54.683, + 24.228, + -27.087 + ], + "measured_rgb": "#9676B2", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 50, + 30 + ], + "measured_lab": [ + 53.86, + 25.93, + -26.83 + ], + "measured_rgb": "#9773AF", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 55, + 25 + ], + "measured_lab": [ + 52.924, + 27.03, + -27.166 + ], + "measured_rgb": "#966FAD", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 60, + 20 + ], + "measured_lab": [ + 51.71, + 29.47, + -27.22 + ], + "measured_rgb": "#976AAA", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 20, + 55 + ], + "measured_lab": [ + 60.566, + 13.416, + -27.029 + ], + "measured_rgb": "#918CC2", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 25, + 50 + ], + "measured_lab": [ + 58.437, + 16.227, + -28.148 + ], + "measured_rgb": "#9085BE", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 30, + 45 + ], + "measured_lab": [ + 55.09, + 20.811, + -29.603 + ], + "measured_rgb": "#8E7AB7", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 35, + 40 + ], + "measured_lab": [ + 54.78, + 21.542, + -29.157 + ], + "measured_rgb": "#8F78B6", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 40, + 35 + ], + "measured_lab": [ + 54.433, + 22.913, + -28.35 + ], + "measured_rgb": "#9276B3", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 45, + 30 + ], + "measured_lab": [ + 53.707, + 23.395, + -28.23 + ], + "measured_rgb": "#9174B1", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 50, + 25 + ], + "measured_lab": [ + 53.303, + 23.898, + -28.057 + ], + "measured_rgb": "#9173B0", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 55, + 20 + ], + "measured_lab": [ + 52.606, + 25.673, + -27.911 + ], + "measured_rgb": "#9270AE", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 20, + 50 + ], + "measured_lab": [ + 62.64, + 7.61, + -25.75 + ], + "measured_rgb": "#8C95C5", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 25, + 45 + ], + "measured_lab": [ + 57.776, + 12.715, + -29.283 + ], + "measured_rgb": "#8586BE", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 30, + 40 + ], + "measured_lab": [ + 54.3, + 18.26, + -31.18 + ], + "measured_rgb": "#857AB8", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 53.449, + 19.947, + -30.78 + ], + "measured_rgb": "#8776B5", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 52.15, + 21.92, + -30.78 + ], + "measured_rgb": "#8772B1", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 52.733, + 22.562, + -29.373 + ], + "measured_rgb": "#8B72B0", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 52.34, + 22.37, + -29.11 + ], + "measured_rgb": "#8A72AF", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 58.326, + 9.338, + -30.016 + ], + "measured_rgb": "#7E89C1", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 25, + 40 + ], + "measured_lab": [ + 56.387, + 12.275, + -30.918 + ], + "measured_rgb": "#7F83BD", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 30, + 35 + ], + "measured_lab": [ + 53.575, + 16.404, + -32.238 + ], + "measured_rgb": "#7E79B8", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 35, + 30 + ], + "measured_lab": [ + 52.46, + 18.41, + -32.043 + ], + "measured_rgb": "#7F75B4", + "source": "interpolated" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 40, + 25 + ], + "measured_lab": [ + 50.49, + 23.51, + -31.23 + ], + "measured_rgb": "#856CAE", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 45, + 20 + ], + "measured_lab": [ + 49.65, + 26.8, + -30.56 + ], + "measured_rgb": "#8A68AA", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 20, + 40 + ], + "measured_lab": [ + 55.49, + 12.01, + -31.53 + ], + "measured_rgb": "#7B81BB", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 25, + 35 + ], + "measured_lab": [ + 54.0, + 18.26, + -30.55 + ], + "measured_rgb": "#8579B6", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 30, + 30 + ], + "measured_lab": [ + 52.11, + 16.18, + -32.9 + ], + "measured_rgb": "#7976B5", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 35, + 25 + ], + "measured_lab": [ + 50.58, + 22.22, + -31.81 + ], + "measured_rgb": "#826EAF", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 40, + 20 + ], + "measured_lab": [ + 49.52, + 22.45, + -31.95 + ], + "measured_rgb": "#806BAC", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 20, + 35 + ], + "measured_lab": [ + 55.21, + 10.3, + -31.74 + ], + "measured_rgb": "#7681BB", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 25, + 30 + ], + "measured_lab": [ + 53.01, + 17.69, + -31.28 + ], + "measured_rgb": "#8077B4", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 30, + 25 + ], + "measured_lab": [ + 51.1, + 17.85, + -33.01 + ], + "measured_rgb": "#7972B2", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 35, + 20 + ], + "measured_lab": [ + 50.31, + 22.05, + -31.57 + ], + "measured_rgb": "#816DAE", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 20, + 30 + ], + "measured_lab": [ + 53.39, + 8.16, + -34.46 + ], + "measured_rgb": "#687EBB", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 25, + 25 + ], + "measured_lab": [ + 52.44, + 17.84, + -31.9 + ], + "measured_rgb": "#7E75B4", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 30, + 20 + ], + "measured_lab": [ + 50.6, + 16.89, + -33.72 + ], + "measured_rgb": "#7571B2", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 20, + 25 + ], + "measured_lab": [ + 53.48, + 9.63, + -33.29 + ], + "measured_rgb": "#6D7DB9", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 25, + 20 + ], + "measured_lab": [ + 51.33, + 13.07, + -34.41 + ], + "measured_rgb": "#6E76B5", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 20, + 20 + ], + "measured_lab": [ + 52.25, + 10.16, + -35.14 + ], + "measured_rgb": "#687AB9", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 20, + 60 + ], + "measured_lab": [ + 74.3, + -35.02, + 32.23 + ], + "measured_rgb": "#87C77A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 25, + 55 + ], + "measured_lab": [ + 73.72, + -36.25, + 35.28 + ], + "measured_rgb": "#85C572", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 30, + 50 + ], + "measured_lab": [ + 73.49, + -37.39, + 44.85 + ], + "measured_rgb": "#88C55E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 35, + 45 + ], + "measured_lab": [ + 73.18, + -37.11, + 44.81 + ], + "measured_rgb": "#88C45E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 40, + 40 + ], + "measured_lab": [ + 73.27, + -36.92, + 47.03 + ], + "measured_rgb": "#8AC459", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 45, + 35 + ], + "measured_lab": [ + 72.96, + -36.08, + 48.85 + ], + "measured_rgb": "#8CC355", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 50, + 30 + ], + "measured_lab": [ + 72.86, + -36.19, + 52.04 + ], + "measured_rgb": "#8DC24D", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 55, + 25 + ], + "measured_lab": [ + 72.69, + -36.31, + 54.47 + ], + "measured_rgb": "#8EC247", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 60, + 20 + ], + "measured_lab": [ + 73.02, + -34.99, + 57.46 + ], + "measured_rgb": "#93C241", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 20, + 55 + ], + "measured_lab": [ + 72.41, + -36.38, + 27.96 + ], + "measured_rgb": "#7BC27D", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 25, + 50 + ], + "measured_lab": [ + 71.93, + -38.31, + 37.24 + ], + "measured_rgb": "#7DC16A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 30, + 45 + ], + "measured_lab": [ + 72.08, + -39.6, + 45.03 + ], + "measured_rgb": "#7FC25A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 35, + 40 + ], + "measured_lab": [ + 71.28, + -39.24, + 43.36 + ], + "measured_rgb": "#7DC05C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 40, + 35 + ], + "measured_lab": [ + 71.07, + -38.65, + 44.35 + ], + "measured_rgb": "#7EBF59", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 45, + 30 + ], + "measured_lab": [ + 71.18, + -38.31, + 50.43 + ], + "measured_rgb": "#83BF4C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 50, + 25 + ], + "measured_lab": [ + 70.55, + -38.48, + 48.2 + ], + "measured_rgb": "#80BD50", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 55, + 20 + ], + "measured_lab": [ + 71.23, + -37.53, + 52.06 + ], + "measured_rgb": "#86BE49", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 20, + 50 + ], + "measured_lab": [ + 70.62, + -38.31, + 27.17 + ], + "measured_rgb": "#70BE7A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 25, + 45 + ], + "measured_lab": [ + 70.27, + -40.7, + 36.95 + ], + "measured_rgb": "#72BE66", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 30, + 40 + ], + "measured_lab": [ + 69.68, + -39.3, + 32.01 + ], + "measured_rgb": "#70BB6E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 69.43, + -40.46, + 39.0 + ], + "measured_rgb": "#72BB60", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 68.97, + -40.7, + 38.6 + ], + "measured_rgb": "#70BA5F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 69.63, + -40.15, + 47.16 + ], + "measured_rgb": "#79BB4F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 69.41, + -39.15, + 50.18 + ], + "measured_rgb": "#7CBA48", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 68.36, + -39.74, + 23.12 + ], + "measured_rgb": "#62B87B", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 25, + 40 + ], + "measured_lab": [ + 68.0, + -41.34, + 28.71 + ], + "measured_rgb": "#62B870", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 30, + 35 + ], + "measured_lab": [ + 67.59, + -41.06, + 29.34 + ], + "measured_rgb": "#63B76E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 35, + 30 + ], + "measured_lab": [ + 67.99, + -40.51, + 34.43 + ], + "measured_rgb": "#6AB765", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 40, + 25 + ], + "measured_lab": [ + 68.47, + -41.01, + 39.93 + ], + "measured_rgb": "#6EB95B", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 45, + 20 + ], + "measured_lab": [ + 68.39, + -40.84, + 44.81 + ], + "measured_rgb": "#72B851", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 20, + 40 + ], + "measured_lab": [ + 68.26, + -42.84, + 32.38 + ], + "measured_rgb": "#62B96A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 25, + 35 + ], + "measured_lab": [ + 67.53, + -43.05, + 33.52 + ], + "measured_rgb": "#61B765", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 30, + 30 + ], + "measured_lab": [ + 67.97, + -43.08, + 40.92 + ], + "measured_rgb": "#68B858", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 35, + 25 + ], + "measured_lab": [ + 67.12, + -42.36, + 35.29 + ], + "measured_rgb": "#63B661", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 40, + 20 + ], + "measured_lab": [ + 67.2, + -42.55, + 42.58 + ], + "measured_rgb": "#69B653", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 20, + 35 + ], + "measured_lab": [ + 66.46, + -43.54, + 30.78 + ], + "measured_rgb": "#5AB468", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 25, + 30 + ], + "measured_lab": [ + 67.09, + -43.25, + 40.33 + ], + "measured_rgb": "#65B657", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 30, + 25 + ], + "measured_lab": [ + 66.18, + -43.88, + 36.56 + ], + "measured_rgb": "#5EB35C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 35, + 20 + ], + "measured_lab": [ + 66.19, + -43.23, + 41.16 + ], + "measured_rgb": "#63B353", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 20, + 30 + ], + "measured_lab": [ + 64.97, + -43.99, + 23.68 + ], + "measured_rgb": "#4BB172", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 25, + 25 + ], + "measured_lab": [ + 65.56, + -44.13, + 36.03 + ], + "measured_rgb": "#5BB25C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 30, + 20 + ], + "measured_lab": [ + 64.72, + -44.5, + 33.55 + ], + "measured_rgb": "#55B05E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 20, + 25 + ], + "measured_lab": [ + 64.39, + -44.62, + 30.57 + ], + "measured_rgb": "#50AF63", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 25, + 20 + ], + "measured_lab": [ + 64.28, + -44.35, + 33.12 + ], + "measured_rgb": "#54AF5E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Cyan", + "rgb": "#0086D6" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 20, + 20 + ], + "measured_lab": [ + 63.99, + -44.5, + 31.36 + ], + "measured_rgb": "#50AE61", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 20, + 60 + ], + "measured_lab": [ + 69.61, + 16.79, + 31.35 + ], + "measured_rgb": "#D99E72", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 25, + 55 + ], + "measured_lab": [ + 68.82, + 19.72, + 31.9 + ], + "measured_rgb": "#DB996F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 30, + 50 + ], + "measured_lab": [ + 68.33, + 17.1, + 39.2 + ], + "measured_rgb": "#D89A60", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 35, + 45 + ], + "measured_lab": [ + 68.46, + 16.11, + 42.41 + ], + "measured_rgb": "#D89B5A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 40, + 40 + ], + "measured_lab": [ + 68.13, + 20.07, + 35.11 + ], + "measured_rgb": "#DA9768", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 45, + 35 + ], + "measured_lab": [ + 67.98, + 17.82, + 41.09 + ], + "measured_rgb": "#D9985C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 50, + 30 + ], + "measured_lab": [ + 67.42, + 17.87, + 44.12 + ], + "measured_rgb": "#D89654", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 55, + 25 + ], + "measured_lab": [ + 67.92, + 16.3, + 49.75 + ], + "measured_rgb": "#D9994A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 60, + 20 + ], + "measured_lab": [ + 67.28, + 19.02, + 46.35 + ], + "measured_rgb": "#DA9550", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 20, + 55 + ], + "measured_lab": [ + 67.5, + 21.75, + 22.94 + ], + "measured_rgb": "#D7957C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 25, + 50 + ], + "measured_lab": [ + 67.1, + 22.38, + 25.65 + ], + "measured_rgb": "#D79376", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 30, + 45 + ], + "measured_lab": [ + 66.59, + 19.58, + 37.69 + ], + "measured_rgb": "#D6935F", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 35, + 40 + ], + "measured_lab": [ + 65.65, + 23.82, + 31.58 + ], + "measured_rgb": "#D78E68", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 40, + 35 + ], + "measured_lab": [ + 65.85, + 22.45, + 35.34 + ], + "measured_rgb": "#D78F62", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 45, + 30 + ], + "measured_lab": [ + 66.33, + 18.54, + 46.19 + ], + "measured_rgb": "#D6934E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 50, + 25 + ], + "measured_lab": [ + 66.54, + 17.79, + 48.57 + ], + "measured_rgb": "#D69449", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 55, + 20 + ], + "measured_lab": [ + 66.05, + 22.67, + 41.69 + ], + "measured_rgb": "#DA8F56", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 20, + 50 + ], + "measured_lab": [ + 65.98, + 25.27, + 25.85 + ], + "measured_rgb": "#D98E73", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 25, + 45 + ], + "measured_lab": [ + 64.68, + 23.73, + 29.99 + ], + "measured_rgb": "#D48C69", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 30, + 40 + ], + "measured_lab": [ + 65.13, + 22.9, + 33.41 + ], + "measured_rgb": "#D58D63", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 65.09, + 25.37, + 33.28 + ], + "measured_rgb": "#D98B64", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 63.98, + 23.77, + 34.82 + ], + "measured_rgb": "#D48A5E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 63.93, + 23.89, + 38.63 + ], + "measured_rgb": "#D58957", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 63.96, + 22.94, + 39.59 + ], + "measured_rgb": "#D48A55", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 64.24, + 30.82, + 10.43 + ], + "measured_rgb": "#D5868B", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 25, + 40 + ], + "measured_lab": [ + 63.91, + 27.46, + 22.59 + ], + "measured_rgb": "#D48774", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 30, + 35 + ], + "measured_lab": [ + 62.99, + 27.33, + 27.98 + ], + "measured_rgb": "#D38568", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 35, + 30 + ], + "measured_lab": [ + 63.4, + 25.17, + 33.91 + ], + "measured_rgb": "#D4875E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 40, + 25 + ], + "measured_lab": [ + 61.18, + 29.42, + 27.72 + ], + "measured_rgb": "#D17E64", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 45, + 20 + ], + "measured_lab": [ + 59.89, + 31.27, + 25.11 + ], + "measured_rgb": "#CF7966", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 20, + 40 + ], + "measured_lab": [ + 59.5, + 33.72, + 17.49 + ], + "measured_rgb": "#CF7772", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 25, + 35 + ], + "measured_lab": [ + 59.49, + 33.87, + 17.34 + ], + "measured_rgb": "#CF7773", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 30, + 30 + ], + "measured_lab": [ + 59.88, + 32.77, + 22.0 + ], + "measured_rgb": "#D0786B", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 35, + 25 + ], + "measured_lab": [ + 58.58, + 33.89, + 21.05 + ], + "measured_rgb": "#CD746A", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 40, + 20 + ], + "measured_lab": [ + 59.5, + 31.18, + 26.51 + ], + "measured_rgb": "#CE7862", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 20, + 35 + ], + "measured_lab": [ + 58.96, + 35.14, + 15.3 + ], + "measured_rgb": "#CE7475", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 25, + 30 + ], + "measured_lab": [ + 58.15, + 35.31, + 18.11 + ], + "measured_rgb": "#CD726E", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 30, + 25 + ], + "measured_lab": [ + 58.1, + 36.43, + 15.98 + ], + "measured_rgb": "#CE7172", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 35, + 20 + ], + "measured_lab": [ + 57.38, + 34.94, + 21.31 + ], + "measured_rgb": "#CB7066", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 20, + 30 + ], + "measured_lab": [ + 57.08, + 37.78, + 13.15 + ], + "measured_rgb": "#CB6D74", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 25, + 25 + ], + "measured_lab": [ + 56.95, + 38.09, + 14.5 + ], + "measured_rgb": "#CC6D71", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 30, + 20 + ], + "measured_lab": [ + 57.03, + 37.09, + 17.89 + ], + "measured_rgb": "#CC6D6C", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 20, + 25 + ], + "measured_lab": [ + 56.59, + 40.06, + 11.45 + ], + "measured_rgb": "#CC6A76", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 25, + 20 + ], + "measured_lab": [ + 56.35, + 39.5, + 14.12 + ], + "measured_rgb": "#CC6A71", + "source": "measured" + }, + { + "mode": "CMYW", + "material": "PLA Basic", + "components": [ + { + "key": "Magenta", + "rgb": "#EC008C" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 20, + 20 + ], + "measured_lab": [ + 56.79, + 44.06, + 0.12 + ], + "measured_rgb": "#CE688A", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 60.61, + 34.14, + 50.34 + ], + "measured_rgb": "#DB7839", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 57.92, + 38.03, + 46.93 + ], + "measured_rgb": "#D86D39", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 54.45, + 45.01, + 43.39 + ], + "measured_rgb": "#D55D39", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 52.18, + 47.64, + 41.46 + ], + "measured_rgb": "#D15537", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 54.08, + 42.25, + 42.36 + ], + "measured_rgb": "#D05F3A", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 51.19, + 47.93, + 39.54 + ], + "measured_rgb": "#CE5239", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 49.49, + 50.76, + 38.06 + ], + "measured_rgb": "#CC4A38", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 50.15, + 48.98, + 38.57 + ], + "measured_rgb": "#CC4E38", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 49.79, + 47.23, + 37.84 + ], + "measured_rgb": "#C94F39", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 46.81, + 51.92, + 34.7 + ], + "measured_rgb": "#C54138", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 46.58, + 51.44, + 34.56 + ], + "measured_rgb": "#C34137", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 46.73, + 51.61, + 33.68 + ], + "measured_rgb": "#C44139", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 46.44, + 51.41, + 32.33 + ], + "measured_rgb": "#C3413B", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 28.39, + 4.73, + -17.78 + ], + "measured_rgb": "#3A425E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 27.99, + 5.7, + -13.75 + ], + "measured_rgb": "#404057", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 28.01, + 5.88, + -12.79 + ], + "measured_rgb": "#414056", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 28.36, + 7.17, + -7.97 + ], + "measured_rgb": "#48404F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 28.46, + 8.34, + -6.09 + ], + "measured_rgb": "#4C3F4D", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 29.18, + 7.99, + -6.06 + ], + "measured_rgb": "#4D414E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 29.47, + 9.68, + -3.27 + ], + "measured_rgb": "#52404B", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 29.14, + 11.96, + -0.91 + ], + "measured_rgb": "#563E46", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 29.79, + 10.84, + -2.53 + ], + "measured_rgb": "#55404A", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 29.84, + 13.65, + 0.54 + ], + "measured_rgb": "#5B3F46", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 30.46, + 16.09, + 2.93 + ], + "measured_rgb": "#613E44", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 31.4, + 20.19, + 7.06 + ], + "measured_rgb": "#6B3D40", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 32.0, + 21.78, + 8.02 + ], + "measured_rgb": "#6E3D40", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 61.58, + 37.37, + 13.91 + ], + "measured_rgb": "#D8797E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 60.1, + 40.18, + 16.87 + ], + "measured_rgb": "#D97375", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 59.12, + 40.19, + 17.09 + ], + "measured_rgb": "#D67072", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 56.42, + 42.46, + 18.74 + ], + "measured_rgb": "#D26769", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 54.17, + 44.16, + 20.38 + ], + "measured_rgb": "#CE5F61", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 53.53, + 44.14, + 20.39 + ], + "measured_rgb": "#CC5D5F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 55.16, + 41.55, + 17.38 + ], + "measured_rgb": "#CC6468", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 52.54, + 43.49, + 19.07 + ], + "measured_rgb": "#C85B5F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 52.43, + 43.2, + 19.01 + ], + "measured_rgb": "#C75B5F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 49.22, + 47.26, + 24.6 + ], + "measured_rgb": "#C44E4E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 48.94, + 47.01, + 23.99 + ], + "measured_rgb": "#C34E4E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 48.65, + 46.66, + 23.5 + ], + "measured_rgb": "#C14D4F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 46.24, + 48.73, + 26.28 + ], + "measured_rgb": "#BD4444", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 35.03, + -18.52, + -8.74 + ], + "measured_rgb": "#185B60", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 34.35, + -18.03, + -10.06 + ], + "measured_rgb": "#145960", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 36.58, + -19.91, + -5.37 + ], + "measured_rgb": "#205F5E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 40.4, + -23.93, + 5.19 + ], + "measured_rgb": "#306956", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 40.47, + -25.72, + 6.68 + ], + "measured_rgb": "#2D6A54", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 42.28, + -26.69, + 10.44 + ], + "measured_rgb": "#346F52", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 42.84, + -27.22, + 11.67 + ], + "measured_rgb": "#357051", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 45.25, + -27.6, + 15.74 + ], + "measured_rgb": "#3F7750", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 48.65, + -28.39, + 22.55 + ], + "measured_rgb": "#4C7F4C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 50.85, + -29.47, + 27.4 + ], + "measured_rgb": "#538549", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 54.25, + -29.09, + 32.92 + ], + "measured_rgb": "#608E46", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 49.95, + -30.96, + 23.7 + ], + "measured_rgb": "#4A844D", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 59.02, + -29.15, + 40.67 + ], + "measured_rgb": "#719A43", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 86.91, + -14.57, + 48.46 + ], + "measured_rgb": "#DDDF7B", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 86.88, + -15.04, + 54.18 + ], + "measured_rgb": "#DFDF6F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 86.41, + -15.7, + 60.31 + ], + "measured_rgb": "#DFDE61", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 86.06, + -14.91, + 59.87 + ], + "measured_rgb": "#DFDD61", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 85.89, + -14.7, + 63.29 + ], + "measured_rgb": "#E0DC58", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 86.91, + -13.69, + 66.56 + ], + "measured_rgb": "#E6DE53", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 85.49, + -13.71, + 65.56 + ], + "measured_rgb": "#E1DA52", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 86.77, + -12.65, + 71.25 + ], + "measured_rgb": "#E9DD47", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 85.39, + -13.63, + 72.72 + ], + "measured_rgb": "#E3DA3F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 85.21, + -13.5, + 76.71 + ], + "measured_rgb": "#E4D931", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 86.67, + -11.8, + 76.43 + ], + "measured_rgb": "#EBDC37", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 85.49, + -11.97, + 77.96 + ], + "measured_rgb": "#E8D92E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 85.33, + -12.02, + 80.1 + ], + "measured_rgb": "#E8D925", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 80 + ], + "measured_lab": [ + 59.26, + -2.93, + -35.61 + ], + "measured_rgb": "#5593CD", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 75 + ], + "measured_lab": [ + 59.41, + -2.35, + -34.23 + ], + "measured_rgb": "#5B93CB", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 70 + ], + "measured_lab": [ + 53.09, + -0.39, + -40.48 + ], + "measured_rgb": "#3E83C4", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 65 + ], + "measured_lab": [ + 52.81, + 0.36, + -39.81 + ], + "measured_rgb": "#4281C2", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 60 + ], + "measured_lab": [ + 48.06, + 2.47, + -43.46 + ], + "measured_rgb": "#2D75BB", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 55 + ], + "measured_lab": [ + 46.13, + 3.72, + -44.68 + ], + "measured_rgb": "#2670B8", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 50 + ], + "measured_lab": [ + 46.04, + 3.98, + -43.55 + ], + "measured_rgb": "#2D6FB6", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 45 + ], + "measured_lab": [ + 44.42, + 5.04, + -44.41 + ], + "measured_rgb": "#286BB3", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 40 + ], + "measured_lab": [ + 44.98, + 5.1, + -42.4 + ], + "measured_rgb": "#336CB1", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 65, + 35 + ], + "measured_lab": [ + 42.2, + 6.27, + -44.38 + ], + "measured_rgb": "#2664AD", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 70, + 30 + ], + "measured_lab": [ + 39.63, + 8.04, + -46.0 + ], + "measured_rgb": "#1C5EA9", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 75, + 25 + ], + "measured_lab": [ + 38.59, + 8.36, + -46.18 + ], + "measured_rgb": "#175BA6", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 80, + 20 + ], + "measured_lab": [ + 37.5, + 9.22, + -46.13 + ], + "measured_rgb": "#1858A3", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 20, + 60 + ], + "measured_lab": [ + 32.23, + -3.53, + -0.01 + ], + "measured_rgb": "#464E4C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 25, + 55 + ], + "measured_lab": [ + 32.81, + -4.24, + 0.56 + ], + "measured_rgb": "#464F4C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 30, + 50 + ], + "measured_lab": [ + 34.15, + -4.49, + 3.87 + ], + "measured_rgb": "#4B524A", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 35, + 45 + ], + "measured_lab": [ + 37.35, + -6.99, + 11.16 + ], + "measured_rgb": "#545B46", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 40, + 40 + ], + "measured_lab": [ + 36.99, + -4.61, + 9.94 + ], + "measured_rgb": "#565947", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 45, + 35 + ], + "measured_lab": [ + 37.25, + -1.03, + 10.47 + ], + "measured_rgb": "#5D5847", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 50, + 30 + ], + "measured_lab": [ + 42.82, + -4.42, + 20.44 + ], + "measured_rgb": "#6A6643", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 55, + 25 + ], + "measured_lab": [ + 44.71, + -2.87, + 24.85 + ], + "measured_rgb": "#736A40", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 20, + 60, + 20 + ], + "measured_lab": [ + 46.34, + -1.41, + 27.3 + ], + "measured_rgb": "#7B6D40", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 20, + 55 + ], + "measured_lab": [ + 30.83, + -1.07, + -2.8 + ], + "measured_rgb": "#45494D", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 25, + 50 + ], + "measured_lab": [ + 32.93, + -0.38, + 3.48 + ], + "measured_rgb": "#4F4D48", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 30, + 45 + ], + "measured_lab": [ + 34.58, + -2.45, + 6.26 + ], + "measured_rgb": "#525247", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 35, + 40 + ], + "measured_lab": [ + 36.48, + -2.69, + 10.74 + ], + "measured_rgb": "#585745", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 40, + 35 + ], + "measured_lab": [ + 36.81, + 0.18, + 11.24 + ], + "measured_rgb": "#5E5645", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 45, + 30 + ], + "measured_lab": [ + 40.56, + -1.97, + 18.21 + ], + "measured_rgb": "#676042", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 50, + 25 + ], + "measured_lab": [ + 44.1, + -1.72, + 24.33 + ], + "measured_rgb": "#736840", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 25, + 55, + 20 + ], + "measured_lab": [ + 45.28, + 1.15, + 26.65 + ], + "measured_rgb": "#7C693E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 20, + 50 + ], + "measured_lab": [ + 31.65, + 0.49, + 0.75 + ], + "measured_rgb": "#4C4A49", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 25, + 45 + ], + "measured_lab": [ + 34.96, + -1.74, + 8.49 + ], + "measured_rgb": "#555345", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 30, + 40 + ], + "measured_lab": [ + 35.0, + -0.46, + 8.13 + ], + "measured_rgb": "#575245", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 35.49, + 2.18, + 9.86 + ], + "measured_rgb": "#5D5244", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 37.72, + 0.55, + 13.56 + ], + "measured_rgb": "#625843", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 43.18, + 0.87, + 23.69 + ], + "measured_rgb": "#75643F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 43.6, + 2.7, + 23.97 + ], + "measured_rgb": "#78643F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 33.04, + 2.13, + 3.9 + ], + "measured_rgb": "#544C48", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 25, + 40 + ], + "measured_lab": [ + 33.94, + 2.25, + 7.42 + ], + "measured_rgb": "#584E44", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 30, + 35 + ], + "measured_lab": [ + 33.08, + 7.54, + 8.06 + ], + "measured_rgb": "#5E4941", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 35, + 30 + ], + "measured_lab": [ + 39.5, + -1.02, + 16.66 + ], + "measured_rgb": "#655D42", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 40, + 25 + ], + "measured_lab": [ + 40.42, + 3.78, + 19.13 + ], + "measured_rgb": "#705C40", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 35, + 45, + 20 + ], + "measured_lab": [ + 42.47, + 7.35, + 22.43 + ], + "measured_rgb": "#7C5F40", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 20, + 40 + ], + "measured_lab": [ + 32.03, + 5.65, + 4.57 + ], + "measured_rgb": "#574844", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 25, + 35 + ], + "measured_lab": [ + 32.62, + 7.5, + 6.94 + ], + "measured_rgb": "#5C4842", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 30, + 30 + ], + "measured_lab": [ + 33.68, + 8.82, + 9.29 + ], + "measured_rgb": "#624A41", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 35, + 25 + ], + "measured_lab": [ + 38.68, + 6.76, + 16.51 + ], + "measured_rgb": "#6F5641", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 40, + 40, + 20 + ], + "measured_lab": [ + 38.56, + 10.09, + 17.18 + ], + "measured_rgb": "#73543F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 45, + 20, + 35 + ], + "measured_lab": [ + 33.4, + 5.95, + 7.54 + ], + "measured_rgb": "#5C4B43", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 45, + 25, + 30 + ], + "measured_lab": [ + 33.27, + 11.92, + 8.78 + ], + "measured_rgb": "#654741", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 45, + 30, + 25 + ], + "measured_lab": [ + 39.06, + 7.04, + 17.32 + ], + "measured_rgb": "#705740", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 45, + 35, + 20 + ], + "measured_lab": [ + 38.92, + 11.63, + 18.79 + ], + "measured_rgb": "#77543E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 50, + 20, + 30 + ], + "measured_lab": [ + 32.56, + 13.56, + 9.0 + ], + "measured_rgb": "#66443F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 50, + 25, + 25 + ], + "measured_lab": [ + 33.92, + 11.71, + 10.22 + ], + "measured_rgb": "#674940", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 50, + 30, + 20 + ], + "measured_lab": [ + 35.68, + 13.35, + 13.16 + ], + "measured_rgb": "#6F4C40", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 55, + 20, + 25 + ], + "measured_lab": [ + 32.37, + 16.51, + 9.16 + ], + "measured_rgb": "#69423E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 55, + 25, + 20 + ], + "measured_lab": [ + 38.21, + 13.95, + 17.68 + ], + "measured_rgb": "#78513E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + } + ], + "ratios": [ + 60, + 20, + 20 + ], + "measured_lab": [ + 35.06, + 16.29, + 13.06 + ], + "measured_rgb": "#71483E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 20, + 60 + ], + "measured_lab": [ + 60.59, + 34.71, + 26.62 + ], + "measured_rgb": "#D67865", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 25, + 55 + ], + "measured_lab": [ + 59.65, + 36.54, + 32.53 + ], + "measured_rgb": "#D87458", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 30, + 50 + ], + "measured_lab": [ + 60.45, + 34.97, + 33.46 + ], + "measured_rgb": "#D87759", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 35, + 45 + ], + "measured_lab": [ + 60.48, + 35.03, + 35.45 + ], + "measured_rgb": "#D97755", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 40, + 40 + ], + "measured_lab": [ + 60.74, + 35.59, + 37.63 + ], + "measured_rgb": "#DB7752", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 45, + 35 + ], + "measured_lab": [ + 60.84, + 33.42, + 34.85 + ], + "measured_rgb": "#D87A57", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 50, + 30 + ], + "measured_lab": [ + 59.98, + 34.25, + 42.34 + ], + "measured_rgb": "#D87647", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 55, + 25 + ], + "measured_lab": [ + 59.5, + 34.06, + 45.9 + ], + "measured_rgb": "#D7753F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 60, + 20 + ], + "measured_lab": [ + 60.73, + 32.52, + 47.98 + ], + "measured_rgb": "#D97A3E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 20, + 55 + ], + "measured_lab": [ + 57.33, + 40.74, + 32.02 + ], + "measured_rgb": "#D66A54", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 25, + 50 + ], + "measured_lab": [ + 57.86, + 38.74, + 32.01 + ], + "measured_rgb": "#D56D55", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 30, + 45 + ], + "measured_lab": [ + 59.26, + 36.69, + 29.23 + ], + "measured_rgb": "#D6735D", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 35, + 40 + ], + "measured_lab": [ + 57.69, + 38.61, + 37.53 + ], + "measured_rgb": "#D66D4B", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 40, + 35 + ], + "measured_lab": [ + 58.84, + 37.53, + 35.54 + ], + "measured_rgb": "#D77151", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 45, + 30 + ], + "measured_lab": [ + 58.26, + 36.31, + 36.61 + ], + "measured_rgb": "#D4704E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 50, + 25 + ], + "measured_lab": [ + 58.63, + 36.73, + 43.16 + ], + "measured_rgb": "#D77142", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 55, + 20 + ], + "measured_lab": [ + 58.5, + 37.1, + 43.64 + ], + "measured_rgb": "#D87041", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 20, + 50 + ], + "measured_lab": [ + 56.37, + 41.44, + 30.12 + ], + "measured_rgb": "#D46755", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 25, + 45 + ], + "measured_lab": [ + 55.61, + 41.45, + 34.19 + ], + "measured_rgb": "#D2654C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 30, + 40 + ], + "measured_lab": [ + 55.53, + 41.59, + 37.12 + ], + "measured_rgb": "#D36447", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 56.91, + 39.02, + 32.21 + ], + "measured_rgb": "#D36A53", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 57.16, + 38.42, + 33.48 + ], + "measured_rgb": "#D36C51", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 56.26, + 39.43, + 40.55 + ], + "measured_rgb": "#D36842", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 56.61, + 38.72, + 43.85 + ], + "measured_rgb": "#D4693C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 54.0, + 44.02, + 32.55 + ], + "measured_rgb": "#D05E4B", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 25, + 40 + ], + "measured_lab": [ + 53.03, + 44.01, + 34.64 + ], + "measured_rgb": "#CE5B45", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 30, + 35 + ], + "measured_lab": [ + 53.36, + 44.2, + 37.81 + ], + "measured_rgb": "#D05C41", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 35, + 30 + ], + "measured_lab": [ + 55.03, + 40.44, + 32.66 + ], + "measured_rgb": "#CF644D", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 40, + 25 + ], + "measured_lab": [ + 54.828, + 42.215, + 34.274 + ], + "measured_rgb": "#D1624A", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 45, + 20 + ], + "measured_lab": [ + 55.474, + 41.194, + 34.226 + ], + "measured_rgb": "#D2654C", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 20, + 40 + ], + "measured_lab": [ + 52.99, + 45.97, + 30.69 + ], + "measured_rgb": "#CF594C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 25, + 35 + ], + "measured_lab": [ + 54.026, + 44.398, + 31.429 + ], + "measured_rgb": "#D15E4D", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 30, + 30 + ], + "measured_lab": [ + 53.48, + 44.5, + 33.71 + ], + "measured_rgb": "#D05C48", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 35, + 25 + ], + "measured_lab": [ + 53.875, + 43.798, + 34.292 + ], + "measured_rgb": "#D05E48", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 40, + 20 + ], + "measured_lab": [ + 53.05, + 44.39, + 35.65 + ], + "measured_rgb": "#CF5B44", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 20, + 35 + ], + "measured_lab": [ + 52.053, + 47.001, + 31.923 + ], + "measured_rgb": "#CE5548", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 25, + 30 + ], + "measured_lab": [ + 51.938, + 46.902, + 33.508 + ], + "measured_rgb": "#CE5545", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 30, + 25 + ], + "measured_lab": [ + 51.974, + 46.493, + 35.433 + ], + "measured_rgb": "#CE5642", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 35, + 20 + ], + "measured_lab": [ + 52.243, + 45.967, + 35.487 + ], + "measured_rgb": "#CE5742", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 20, + 30 + ], + "measured_lab": [ + 51.23, + 48.13, + 31.57 + ], + "measured_rgb": "#CD5247", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 25, + 25 + ], + "measured_lab": [ + 51.072, + 48.014, + 34.379 + ], + "measured_rgb": "#CD5242", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 30, + 20 + ], + "measured_lab": [ + 50.05, + 49.01, + 38.06 + ], + "measured_rgb": "#CC4D39", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 20, + 25 + ], + "measured_lab": [ + 50.014, + 48.998, + 33.907 + ], + "measured_rgb": "#CB4E40", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 25, + 20 + ], + "measured_lab": [ + 50.106, + 48.917, + 34.856 + ], + "measured_rgb": "#CB4E3F", + "source": "interpolated" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 20, + 20 + ], + "measured_lab": [ + 48.46, + 50.2, + 35.77 + ], + "measured_rgb": "#C84839", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 20, + 60 + ], + "measured_lab": [ + 43.95, + 12.77, + -5.1 + ], + "measured_rgb": "#796171", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 25, + 55 + ], + "measured_lab": [ + 42.04, + 9.64, + -8.16 + ], + "measured_rgb": "#6D5E71", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 30, + 50 + ], + "measured_lab": [ + 41.99, + 8.57, + -9.11 + ], + "measured_rgb": "#6B5F72", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 35, + 45 + ], + "measured_lab": [ + 37.82, + 7.98, + -12.1 + ], + "measured_rgb": "#5D566D", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 40, + 40 + ], + "measured_lab": [ + 37.04, + 10.17, + -6.18 + ], + "measured_rgb": "#635261", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 45, + 35 + ], + "measured_lab": [ + 35.88, + 6.85, + -11.4 + ], + "measured_rgb": "#575267", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 50, + 30 + ], + "measured_lab": [ + 34.82, + 8.38, + -8.59 + ], + "measured_rgb": "#594E60", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 55, + 25 + ], + "measured_lab": [ + 33.2, + 9.06, + -8.11 + ], + "measured_rgb": "#574A5B", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 60, + 20 + ], + "measured_lab": [ + 32.07, + 8.43, + -9.28 + ], + "measured_rgb": "#52485A", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 20, + 55 + ], + "measured_lab": [ + 43.13, + 13.68, + -2.83 + ], + "measured_rgb": "#7A5E6B", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 25, + 50 + ], + "measured_lab": [ + 40.14, + 12.29, + -4.61 + ], + "measured_rgb": "#6F5866", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 30, + 45 + ], + "measured_lab": [ + 37.63, + 10.55, + -7.27 + ], + "measured_rgb": "#645364", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 35, + 40 + ], + "measured_lab": [ + 38.38, + 9.16, + -8.16 + ], + "measured_rgb": "#635668", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 40, + 35 + ], + "measured_lab": [ + 35.95, + 10.58, + -5.28 + ], + "measured_rgb": "#624F5D", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 45, + 30 + ], + "measured_lab": [ + 34.42, + 9.15, + -7.21 + ], + "measured_rgb": "#5A4C5C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 50, + 25 + ], + "measured_lab": [ + 34.05, + 8.82, + -7.67 + ], + "measured_rgb": "#594C5C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 55, + 20 + ], + "measured_lab": [ + 31.57, + 8.0, + -9.3 + ], + "measured_rgb": "#504759", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 20, + 50 + ], + "measured_lab": [ + 42.67, + 15.13, + -1.45 + ], + "measured_rgb": "#7C5C68", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 25, + 45 + ], + "measured_lab": [ + 38.88, + 13.65, + -2.95 + ], + "measured_rgb": "#6F5461", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 30, + 40 + ], + "measured_lab": [ + 36.38, + 12.23, + -5.02 + ], + "measured_rgb": "#664F5E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 36.56, + 10.6, + -6.32 + ], + "measured_rgb": "#635160", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 33.78, + 10.4, + -5.72 + ], + "measured_rgb": "#5C4A59", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 33.68, + 9.66, + -5.71 + ], + "measured_rgb": "#5B4A58", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 31.46, + 8.64, + -7.9 + ], + "measured_rgb": "#524656", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 37.64, + 16.45, + -0.45 + ], + "measured_rgb": "#724F5A", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 25, + 40 + ], + "measured_lab": [ + 35.97, + 14.23, + -2.54 + ], + "measured_rgb": "#694D59", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 30, + 35 + ], + "measured_lab": [ + 33.12, + 14.21, + -3.07 + ], + "measured_rgb": "#624653", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 35, + 30 + ], + "measured_lab": [ + 34.92, + 11.2, + -4.87 + ], + "measured_rgb": "#614C5A", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 40, + 25 + ], + "measured_lab": [ + 32.77, + 11.87, + -3.19 + ], + "measured_rgb": "#5D4752", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 45, + 20 + ], + "measured_lab": [ + 30.53, + 13.08, + -1.32 + ], + "measured_rgb": "#5B414A", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 20, + 40 + ], + "measured_lab": [ + 39.18, + 16.47, + 0.67 + ], + "measured_rgb": "#76535C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 25, + 35 + ], + "measured_lab": [ + 33.52, + 16.44, + 0.11 + ], + "measured_rgb": "#68454F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 30, + 30 + ], + "measured_lab": [ + 33.8, + 14.01, + -1.98 + ], + "measured_rgb": "#644853", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 35, + 25 + ], + "measured_lab": [ + 32.67, + 16.81, + 2.7 + ], + "measured_rgb": "#674349", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 40, + 20 + ], + "measured_lab": [ + 31.2, + 13.76, + -0.59 + ], + "measured_rgb": "#5E424B", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 20, + 35 + ], + "measured_lab": [ + 37.08, + 19.55, + 3.74 + ], + "measured_rgb": "#774B52", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 25, + 30 + ], + "measured_lab": [ + 32.33, + 16.27, + -0.27 + ], + "measured_rgb": "#64434D", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 30, + 25 + ], + "measured_lab": [ + 32.15, + 14.65, + -1.47 + ], + "measured_rgb": "#61434E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 35, + 20 + ], + "measured_lab": [ + 31.51, + 13.89, + -1.21 + ], + "measured_rgb": "#5E424C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 20, + 30 + ], + "measured_lab": [ + 36.64, + 20.21, + 4.79 + ], + "measured_rgb": "#774A4F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 25, + 25 + ], + "measured_lab": [ + 33.48, + 17.53, + 2.16 + ], + "measured_rgb": "#6A444C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 30, + 20 + ], + "measured_lab": [ + 32.33, + 15.16, + 0.23 + ], + "measured_rgb": "#63434C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 20, + 25 + ], + "measured_lab": [ + 34.44, + 20.68, + 5.02 + ], + "measured_rgb": "#72444A", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 25, + 20 + ], + "measured_lab": [ + 31.82, + 18.04, + 1.96 + ], + "measured_rgb": "#674048", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Red", + "rgb": "#C12E1F" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 20, + 20 + ], + "measured_lab": [ + 33.8, + 20.81, + 5.55 + ], + "measured_rgb": "#714248", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 20, + 60 + ], + "measured_lab": [ + 61.95, + -24.93, + 11.39 + ], + "measured_rgb": "#6CA181", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 25, + 55 + ], + "measured_lab": [ + 58.64, + -24.28, + 6.71 + ], + "measured_rgb": "#609881", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 30, + 50 + ], + "measured_lab": [ + 53.18, + -26.38, + 5.24 + ], + "measured_rgb": "#4A8B75", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 35, + 45 + ], + "measured_lab": [ + 48.85, + -26.53, + 1.34 + ], + "measured_rgb": "#388071", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 40, + 40 + ], + "measured_lab": [ + 47.76, + -25.67, + 0.94 + ], + "measured_rgb": "#377D6F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 45, + 35 + ], + "measured_lab": [ + 47.71, + -23.33, + -0.88 + ], + "measured_rgb": "#3B7C72", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 50, + 30 + ], + "measured_lab": [ + 45.3, + -23.42, + -3.16 + ], + "measured_rgb": "#307670", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 55, + 25 + ], + "measured_lab": [ + 42.32, + -22.68, + -5.37 + ], + "measured_rgb": "#256E6C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 20, + 60, + 20 + ], + "measured_lab": [ + 40.66, + -22.75, + -6.54 + ], + "measured_rgb": "#1C6A6A", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 20, + 55 + ], + "measured_lab": [ + 58.19, + -28.22, + 13.86 + ], + "measured_rgb": "#5C9973", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 25, + 50 + ], + "measured_lab": [ + 57.54, + -26.75, + 11.39 + ], + "measured_rgb": "#5C9675", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 30, + 45 + ], + "measured_lab": [ + 54.04, + -25.84, + 7.96 + ], + "measured_rgb": "#518D73", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 35, + 40 + ], + "measured_lab": [ + 49.15, + -27.67, + 5.27 + ], + "measured_rgb": "#3B816B", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 40, + 35 + ], + "measured_lab": [ + 50.78, + -24.99, + 5.57 + ], + "measured_rgb": "#48846F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 45, + 30 + ], + "measured_lab": [ + 46.19, + -27.43, + 6.2 + ], + "measured_rgb": "#367962", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 50, + 25 + ], + "measured_lab": [ + 44.25, + -26.25, + 2.19 + ], + "measured_rgb": "#2D7464", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 25, + 55, + 20 + ], + "measured_lab": [ + 43.05, + -25.29, + -0.35 + ], + "measured_rgb": "#297166", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 20, + 50 + ], + "measured_lab": [ + 60.51, + -27.72, + 13.78 + ], + "measured_rgb": "#649F79", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 25, + 45 + ], + "measured_lab": [ + 56.39, + -27.38, + 13.6 + ], + "measured_rgb": "#5A936F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 30, + 40 + ], + "measured_lab": [ + 55.04, + -26.57, + 11.27 + ], + "measured_rgb": "#56906F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 35, + 35 + ], + "measured_lab": [ + 50.22, + -27.61, + 7.99 + ], + "measured_rgb": "#428469", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 40, + 30 + ], + "measured_lab": [ + 48.43, + -28.67, + 10.95 + ], + "measured_rgb": "#3F7F60", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 45, + 25 + ], + "measured_lab": [ + 46.49, + -27.7, + 8.65 + ], + "measured_rgb": "#397A5F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 30, + 50, + 20 + ], + "measured_lab": [ + 45.03, + -26.36, + 4.91 + ], + "measured_rgb": "#347662", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 20, + 45 + ], + "measured_lab": [ + 60.7, + -28.15, + 21.36 + ], + "measured_rgb": "#6A9F6C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 25, + 40 + ], + "measured_lab": [ + 58.01, + -27.55, + 16.4 + ], + "measured_rgb": "#60986E", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 30, + 35 + ], + "measured_lab": [ + 53.3, + -28.62, + 14.7 + ], + "measured_rgb": "#508C65", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 35, + 30 + ], + "measured_lab": [ + 51.14, + -28.08, + 12.6 + ], + "measured_rgb": "#498663", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 40, + 25 + ], + "measured_lab": [ + 48.27, + -28.82, + 13.08 + ], + "measured_rgb": "#407F5C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 35, + 45, + 20 + ], + "measured_lab": [ + 44.97, + -27.91, + 6.3 + ], + "measured_rgb": "#31765F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 20, + 40 + ], + "measured_lab": [ + 59.26, + -29.58, + 23.49 + ], + "measured_rgb": "#659C64", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 25, + 35 + ], + "measured_lab": [ + 55.03, + -29.9, + 20.19 + ], + "measured_rgb": "#569160", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 30, + 30 + ], + "measured_lab": [ + 52.18, + -28.85, + 16.83 + ], + "measured_rgb": "#4F895F", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 35, + 25 + ], + "measured_lab": [ + 51.03, + -30.5, + 17.18 + ], + "measured_rgb": "#48865B", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 40, + 40, + 20 + ], + "measured_lab": [ + 50.23, + -27.11, + 13.19 + ], + "measured_rgb": "#4A8360", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 20, + 35 + ], + "measured_lab": [ + 59.6, + -28.48, + 25.96 + ], + "measured_rgb": "#6A9C61", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 25, + 30 + ], + "measured_lab": [ + 56.1, + -28.9, + 21.55 + ], + "measured_rgb": "#5D9360", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 30, + 25 + ], + "measured_lab": [ + 52.36, + -29.7, + 18.82 + ], + "measured_rgb": "#4F8A5C", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 45, + 35, + 20 + ], + "measured_lab": [ + 50.32, + -30.46, + 18.03 + ], + "measured_rgb": "#478558", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 20, + 30 + ], + "measured_lab": [ + 58.9, + -29.55, + 28.26 + ], + "measured_rgb": "#689A5B", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 25, + 25 + ], + "measured_lab": [ + 55.52, + -29.56, + 25.37 + ], + "measured_rgb": "#5D9258", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 50, + 30, + 20 + ], + "measured_lab": [ + 51.61, + -30.92, + 22.57 + ], + "measured_rgb": "#4D8853", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 20, + 25 + ], + "measured_lab": [ + 57.98, + -30.58, + 31.16 + ], + "measured_rgb": "#659853", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 55, + 25, + 20 + ], + "measured_lab": [ + 55.14, + -30.46, + 27.15 + ], + "measured_rgb": "#5B9153", + "source": "measured" + }, + { + "mode": "RYBW", + "material": "PLA Basic", + "components": [ + { + "key": "Yellow", + "rgb": "#F4EE2A" + }, + { + "key": "Blue", + "rgb": "#0A2989" + }, + { + "key": "White", + "rgb": "#FFFFFF" + } + ], + "ratios": [ + 60, + 20, + 20 + ], + "measured_lab": [ + 57.58, + -31.03, + 33.45 + ], + "measured_rgb": "#65974D", + "source": "measured" + } + ] +} diff --git a/resources/images/live_stream_default.png b/resources/images/live_stream_default.png index 326ceaf994..c1aa90bf17 100644 Binary files a/resources/images/live_stream_default.png and b/resources/images/live_stream_default.png differ diff --git a/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml b/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml index 94c98121ec..a3efaced8c 100644 --- a/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml +++ b/scripts/flatpak/com.orcaslicer.OrcaSlicer.yml @@ -276,6 +276,12 @@ modules: sha256: 27b72ba2d5ff3d0a9814ad40d4cb88f8dc89a35491c0866d952473f8f9416b77 dest: external-packages/Draco + # Assimp 5.4.3 + - type: file + url: https://github.com/assimp/assimp/archive/refs/tags/v5.4.3.tar.gz + sha256: 66dfbaee288f2bc43172440a55d0235dfc7bf885dda6435c038e8000e79582cb + dest: external-packages/Assimp + # OpenSSL 1.1.1w (GNOME SDK has 3.x; OrcaSlicer requires 1.1.x) - type: file url: https://github.com/openssl/openssl/archive/OpenSSL_1_1_1w.tar.gz @@ -312,6 +318,12 @@ modules: sha256: 0ba163956f2d468b19a91b96c5aba66ee9610843ea41dda628ea44cdafde7db7 dest: external-packages/wxInspector + # FFmpeg n7.0.3 + - type: file + url: https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n7.0.3.tar.gz + sha256: deedcabe339165214a3637df4c86a507aef0d793cf8774ff68735f4737e8ddbc + dest: external-packages/FFMPEG + # --------------------------------------------------------------- # Fallback archives for deps normally provided by the GNOME SDK. # These are only used if find_package() fails to locate them. diff --git a/scripts/linux.d/arch b/scripts/linux.d/arch index ead963e9a6..dc554b10b5 100644 --- a/scripts/linux.d/arch +++ b/scripts/linux.d/arch @@ -25,6 +25,9 @@ export REQUIRED_DEV_PACKAGES=( wayland-protocols webkit2gtk-4.1 wget + pkgconf + yasm + nasm ) if [[ -n "$UPDATE_LIB" ]] diff --git a/scripts/linux.d/cachyos b/scripts/linux.d/cachyos index d491747ace..6137a3ef5c 100644 --- a/scripts/linux.d/cachyos +++ b/scripts/linux.d/cachyos @@ -25,6 +25,9 @@ export REQUIRED_DEV_PACKAGES=( wayland-protocols webkit2gtk wget + pkgconf + yasm + nasm ) if [[ -n "$UPDATE_LIB" ]] diff --git a/scripts/linux.d/clear-linux-os b/scripts/linux.d/clear-linux-os index 149e805549..41ca68e835 100644 --- a/scripts/linux.d/clear-linux-os +++ b/scripts/linux.d/clear-linux-os @@ -20,6 +20,7 @@ export REQUIRED_BUNDLES=( perl-basic texinfo wget + nasm ) if [[ -n "$UPDATE_LIB" ]] diff --git a/scripts/linux.d/debian b/scripts/linux.d/debian index f4d500760e..c33b24d2b7 100644 --- a/scripts/linux.d/debian +++ b/scripts/linux.d/debian @@ -27,6 +27,9 @@ REQUIRED_DEV_PACKAGES=( ninja-build texinfo wget + pkgconf + yasm + nasm ) if [[ -n "$UPDATE_LIB" ]] diff --git a/scripts/linux.d/fedora b/scripts/linux.d/fedora index ca4eceaee3..993ea0b10a 100644 --- a/scripts/linux.d/fedora +++ b/scripts/linux.d/fedora @@ -31,6 +31,9 @@ REQUIRED_DEV_PACKAGES=( webkit2gtk4.1-devel wget libcurl-devel + pkgconf + yasm + nasm ) if [[ -n "$UPDATE_LIB" ]] diff --git a/scripts/linux.d/gentoo b/scripts/linux.d/gentoo index f172ac92c3..f8554542ff 100644 --- a/scripts/linux.d/gentoo +++ b/scripts/linux.d/gentoo @@ -32,6 +32,9 @@ REQUIRED_DEV_PACKAGES=( sys-devel/m4 virtual/libudev x11-libs/gtk+:3 + dev-util/pkgconf + dev-lang/yasm + dev-lang/nasm ) if [[ -n "$UPDATE_LIB" ]] diff --git a/scripts/linux.d/suse b/scripts/linux.d/suse index 1f4db45f46..682960650b 100644 --- a/scripts/linux.d/suse +++ b/scripts/linux.d/suse @@ -30,6 +30,9 @@ REQUIRED_DEV_PACKAGES=( webkit2gtk4-devel wget libcurl-devel + pkgconf + yasm + nasm ) if [[ -n "$UPDATE_LIB" ]] diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0082b29831..0036af0a8c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -75,7 +75,7 @@ if (SLIC3R_GUI) list(FILTER wxWidgets_LIBRARIES EXCLUDE REGEX expat) list(APPEND wxWidgets_LIBRARIES ${EXPAT_LIBRARIES}) endif () - + # This is an issue in the new wxWidgets cmake build, doesn't deal with librt find_library(LIBRT rt) if(LIBRT) @@ -294,6 +294,16 @@ if (WIN32) endif() else () + if (NOT APPLE) + set(output_sos_Release "") + set(output_sos_Debug "") + add_custom_target(OrcaSlicerSosCopy ALL DEPENDS OrcaSlicer) + if ("${CMAKE_BUILD_TYPE}" STREQUAL "Debug") + orcaslicer_copy_sos(OrcaSlicerSosCopy "Debug" "d" output_sos_Debug) + else() + orcaslicer_copy_sos(OrcaSlicerSosCopy "Release" "" output_sos_Release) + endif() + endif() if (APPLE AND NOT CMAKE_MACOSX_BUNDLE) # On OSX, the name of the binary matches the name of the Application. add_custom_command(TARGET OrcaSlicer POST_BUILD @@ -372,5 +382,9 @@ if (WIN32) install(FILES ${output_dlls_${build_type}} DESTINATION ".") install(DIRECTORY "${CMAKE_PREFIX_PATH}/libpython/" DESTINATION "python") else () + if (APPLE) + else() + install(FILES ${output_sos_${build_type}} DESTINATION "${CMAKE_INSTALL_PREFIX}") + endif() install(TARGETS OrcaSlicer RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" BUNDLE DESTINATION ${CMAKE_INSTALL_BINDIR}) endif () diff --git a/src/dev-utils/platform/unix/build_linux_image.sh.in b/src/dev-utils/platform/unix/build_linux_image.sh.in index 85134b764d..873cf2e1b1 100755 --- a/src/dev-utils/platform/unix/build_linux_image.sh.in +++ b/src/dev-utils/platform/unix/build_linux_image.sh.in @@ -83,6 +83,10 @@ copy_shared_object_to_dir() { src_real="$(readlink -f "$src")" dst_name="$(basename "$src_real")" mkdir -p "$dst_dir" + if [ "$src_real" = "$dst_dir/$dst_name" ]; then + # Already bundled; the dependency resolved from the bundle directory. + return 0 + fi cp -fL "$src_real" "$dst_dir/$dst_name" if [ -L "$src" ]; then @@ -96,12 +100,23 @@ copy_shared_object_to_dir() { } bundle_dependency_closure() { - local dst_dir="$1" + local dst_dir + dst_dir="$(cd -- "$1" && pwd)" shift local -a queue=("$@") - local target dep dep_real copied_path + local target dep dep_real dep_key copied_path declare -A seen=() + # Dependencies are resolved with ldd, which only searches the default + # loader path. Deps-built shared libraries (e.g. the FFmpeg stack) are not + # installed there and carry no RUNPATH of their own, so once copied into + # the bundle ldd can no longer resolve one sibling from another + # (libavcodec -> libavutil) and reports it as missing. Extend the loader + # path with the bundle directory plus the source directories of files + # already bundled, so every library that was resolved once keeps resolving + # for its own dependencies. The audit script does the same + # (scripts/check_appimage_libs.sh). + local -a search_dirs=("$dst_dir") while [ ${#queue[@]} -gt 0 ]; do target="${queue[0]}" @@ -122,17 +137,24 @@ bundle_dependency_closure() { continue fi - if [ -n "${seen[$dep_real]}" ]; then + # Key dedup on the bundled file rather than the source path: once + # ldd resolves a library from the bundle directory (via the + # LD_LIBRARY_PATH above) its path is a dst_dir path, which differs + # from the source path the first resolution returned. Keying on + # the source path would re-copy the file onto itself. + dep_key="$dst_dir/$(basename "$dep_real")" + if [ -n "${seen[$dep_key]}" ]; then continue fi - seen[$dep_real]=1 + seen[$dep_key]=1 copy_shared_object_to_dir "$dep" "$dst_dir" + search_dirs+=("$(dirname "$dep_real")") copied_path="$dst_dir/$(basename "$dep_real")" if [ -e "$copied_path" ]; then queue+=("$copied_path") fi - done < <(appimage_list_direct_dependencies "$target") + done < <(LD_LIBRARY_PATH="$(IFS=:; printf '%s' "${search_dirs[*]}")${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" appimage_list_direct_dependencies "$target") done } diff --git a/src/libslic3r/AABBTreeIndirect.hpp b/src/libslic3r/AABBTreeIndirect.hpp index 035982d876..483ca8efc4 100644 --- a/src/libslic3r/AABBTreeIndirect.hpp +++ b/src/libslic3r/AABBTreeIndirect.hpp @@ -229,7 +229,7 @@ public: m_bbox(bbox.min - Point(SCALED_EPSILON, SCALED_EPSILON), bbox.max + Point(SCALED_EPSILON, SCALED_EPSILON)) {} size_t idx() const { return m_idx; } const BoundingBox& bbox() const { return m_bbox; } - Point centroid() const { return (m_bbox.min() + m_bbox.max() / 2); } + Point centroid() const { return (m_bbox.min() + m_bbox.max()) / 2; } private: size_t m_idx; BoundingBox m_bbox; diff --git a/src/libslic3r/ArcFitter.cpp b/src/libslic3r/ArcFitter.cpp index cdfd708b10..46dd12931e 100644 --- a/src/libslic3r/ArcFitter.cpp +++ b/src/libslic3r/ArcFitter.cpp @@ -57,24 +57,24 @@ void ArcFitter::do_arc_fitting(const Points& points, std::vector 2) { //BBS: althought current point_stack can't be fit as arc, //but previous must can be fit if removing the top in stack, so save last arc - result.emplace_back(std::move(PathFittingData{ front_index, + result.emplace_back(PathFittingData{ front_index, back_index - 1, last_arc.direction == ArcDirection::Arc_Dir_CCW ? EMovePathType::Arc_move_ccw : EMovePathType::Arc_move_cw, - last_arc })); + last_arc }); } else { //BBS: save the first segment as line move when 3 point-line can't be fit as arc move if (result.empty() || result.back().path_type != EMovePathType::Linear_move) - result.emplace_back(std::move(PathFittingData{front_index, front_index + 1, EMovePathType::Linear_move, ArcSegment()})); + result.emplace_back(PathFittingData{front_index, front_index + 1, EMovePathType::Linear_move, ArcSegment()}); else if(result.back().path_type == EMovePathType::Linear_move) result.back().end_point_index = front_index + 1; } @@ -87,7 +87,7 @@ void ArcFitter::do_arc_fitting(const Points& points, std::vector:SLIC3R_CONSOLE_LOG>) endif() -target_include_directories(libslic3r PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} PUBLIC ${CMAKE_CURRENT_BINARY_DIR}) +target_include_directories(libslic3r PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/TextureToColor PUBLIC ${CMAKE_CURRENT_BINARY_DIR}) target_include_directories(libslic3r SYSTEM PUBLIC ${EXPAT_INCLUDE_DIRS}) # Find the OCCT and related libraries @@ -597,6 +612,7 @@ target_link_libraries(libslic3r libnest2d miniz opencv_world + assimp::assimp PRIVATE ${CMAKE_DL_LIBS} ${EXPAT_LIBRARIES} diff --git a/src/libslic3r/Clipper2Utils.cpp b/src/libslic3r/Clipper2Utils.cpp index d389d25209..f119fc3924 100644 --- a/src/libslic3r/Clipper2Utils.cpp +++ b/src/libslic3r/Clipper2Utils.cpp @@ -13,8 +13,8 @@ Slic3r::Polylines Paths64_to_polylines(const Clipper2Lib::Paths64& in) Slic3r::Points points; points.reserve(path64.size()); for (const Clipper2Lib::Point64& point64 : path64) - points.emplace_back(std::move(Slic3r::Point(point64.x, point64.y))); - out.emplace_back(std::move(Slic3r::Polyline(points))); + points.emplace_back(Slic3r::Point(point64.x, point64.y)); + out.emplace_back(Slic3r::Polyline(points)); } return out; } @@ -29,7 +29,7 @@ Clipper2Lib::Paths64 Slic3rPoints_to_Paths64(const Container& in) Clipper2Lib::Path64 path; path.reserve(item.size()); for (const Slic3r::Point& point : item.points) - path.emplace_back(std::move(Clipper2Lib::Point64(point.x(), point.y()))); + path.emplace_back(Clipper2Lib::Point64(point.x(), point.y())); out.emplace_back(std::move(path)); } return out; @@ -44,7 +44,7 @@ Points Path64ToPoints(const Clipper2Lib::Path64& path64) { Points points; points.reserve(path64.size()); - for (const Clipper2Lib::Point64 &point64 : path64) points.emplace_back(std::move(Slic3r::Point(point64.x, point64.y))); + for (const Clipper2Lib::Point64 &point64 : path64) points.emplace_back(Slic3r::Point(point64.x, point64.y)); return points; } @@ -99,7 +99,7 @@ Clipper2Lib::Paths64 Slic3rPolygons_to_Paths64(const Polygons &in) for (const Polygon &poly : in) { Clipper2Lib::Path64 path; path.reserve(poly.points.size()); - for (const Slic3r::Point &point : poly.points) path.emplace_back(std::move(Clipper2Lib::Point64(point.x(), point.y()))); + for (const Slic3r::Point &point : poly.points) path.emplace_back(Clipper2Lib::Point64(point.x(), point.y())); out.emplace_back(std::move(path)); } return out; @@ -114,7 +114,7 @@ Clipper2Lib::Paths64 Slic3rExPolygons_to_Paths64(const ExPolygons& in) const auto &poly = expolygon.contour_or_hole(i); Clipper2Lib::Path64 path; path.reserve(poly.points.size()); - for (const Slic3r::Point &point : poly.points) path.emplace_back(std::move(Clipper2Lib::Point64(point.x(), point.y()))); + for (const Slic3r::Point &point : poly.points) path.emplace_back(Clipper2Lib::Point64(point.x(), point.y())); out.emplace_back(std::move(path)); } } @@ -134,8 +134,8 @@ Polylines _clipper2_pl_open(Clipper2Lib::ClipType clipType, const Slic3r::Polyli Slic3r::Polylines out; out.reserve(solution.size() + solution_open.size()); - polylines_append(out, std::move(Paths64_to_polylines(solution))); - polylines_append(out, std::move(Paths64_to_polylines(solution_open))); + polylines_append(out, Paths64_to_polylines(solution)); + polylines_append(out, Paths64_to_polylines(solution_open)); return out; } diff --git a/src/libslic3r/ColorDecomposeRecipe.cpp b/src/libslic3r/ColorDecomposeRecipe.cpp new file mode 100644 index 0000000000..f2ceb1860a --- /dev/null +++ b/src/libslic3r/ColorDecomposeRecipe.cpp @@ -0,0 +1,530 @@ +#include "ColorDecomposeRecipe.hpp" + +#include "FilamentMixer.hpp" +#include "Utils.hpp" +#include "nlohmann/json.hpp" + +#include +#include +#include +#include +#include +#include + +namespace Slic3r { +namespace { + +struct LabColor { + double l{0.0}; + double a{0.0}; + double b{0.0}; +}; + +struct StandardRecipeEntry { + ColorDecomposeRecipeMode mode{ColorDecomposeRecipeMode::CMYW}; + std::string material; + std::string source; + std::vector component_keys; + std::vector component_hexes; + std::vector ratios; + std::string measured_hex; + LabColor measured_lab; +}; + +static double srgb_to_linear(double v) +{ + v /= 255.0; + return v <= 0.04045 ? v / 12.92 : std::pow((v + 0.055) / 1.055, 2.4); +} + +static double xyz_to_lab_component(double v) +{ + constexpr double eps = 216.0 / 24389.0; + constexpr double kappa = 24389.0 / 27.0; + return v > eps ? std::cbrt(v) : (kappa * v + 16.0) / 116.0; +} + +static LabColor rgb_to_lab(const ColorDecomposeRgb& rgb) +{ + const double r = srgb_to_linear(rgb.r); + const double g = srgb_to_linear(rgb.g); + const double b = srgb_to_linear(rgb.b); + + const double x = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047; + const double y = (0.2126729 * r + 0.7151522 * g + 0.0721750 * b); + const double z = (0.0193339 * r + 0.1191920 * g + 0.9503041 * b) / 1.08883; + + const double fx = xyz_to_lab_component(x); + const double fy = xyz_to_lab_component(y); + const double fz = xyz_to_lab_component(z); + + return {116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)}; +} + +static std::string lab_to_srgb_hex(const LabColor& lab) +{ + constexpr double Xn = 0.95047, Yn = 1.0, Zn = 1.08883; + + auto f_inv = [](double t) -> double { + constexpr double eps = 216.0 / 24389.0; + constexpr double kappa = 24389.0 / 27.0; + const double t3 = t * t * t; + return t3 > eps ? t3 : (t * 116.0 - 16.0) / kappa; + }; + + const double fy = (lab.l + 16.0) / 116.0; + const double fx = lab.a / 500.0 + fy; + const double fz = fy - lab.b / 200.0; + + const double X = Xn * f_inv(fx); + const double Y = Yn * f_inv(fy); + const double Z = Zn * f_inv(fz); + + double r = 3.2406 * X - 1.5372 * Y - 0.4986 * Z; + double g = -0.9689 * X + 1.8758 * Y + 0.0415 * Z; + double b = 0.0557 * X - 0.2040 * Y + 1.0570 * Z; + + auto gamma = [](double c) -> double { + c = std::max(0.0, std::min(1.0, c)); + return c <= 0.0031308 ? 12.92 * c : 1.055 * std::pow(c, 1.0 / 2.4) - 0.055; + }; + auto u8 = [&](double c) -> int { + return std::max(0, std::min(255, static_cast(std::lround(gamma(c) * 255.0)))); + }; + + char buf[8]; + std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", u8(r), u8(g), u8(b)); + return std::string(buf); +} + +static double delta_e76(const LabColor& a, const LabColor& b) +{ + return std::sqrt(std::pow(a.l - b.l, 2.0) + std::pow(a.a - b.a, 2.0) + std::pow(a.b - b.b, 2.0)); +} + +static bool material_matches(const std::string& a, const std::string& b) +{ + if (a.empty() || b.empty()) + return false; + return a == b || a == b + " Basic" || b == a + " Basic"; +} + +static std::vector> ratio_grid(size_t n) +{ + std::vector> out; + if (n == 2) { + for (int a = 20; a <= 80; a += 5) + out.push_back({a, 100 - a}); + } else if (n == 3) { + for (int a = 20; a <= 60; a += 5) + for (int b = 20; b <= 80 - a; b += 5) { + const int c = 100 - a - b; + if (c >= 20) + out.push_back({a, b, c}); + } + } + return out; +} + +static ColorDecomposeRecipeMode parse_mode(const std::string& s) +{ + if (s == "RYBW" || s == "RGBY") + return ColorDecomposeRecipeMode::RYBW; + return ColorDecomposeRecipeMode::CMYW; +} + +static std::vector load_standard_entries() +{ + std::vector entries; + const std::string path = resources_dir() + "/filament_mixing/standard_color_recipes.json"; + std::ifstream ifs(path); + if (!ifs) + return entries; + + nlohmann::json root = nlohmann::json::parse(ifs, nullptr, false); + if (root.is_discarded() || !root.contains("entries") || !root["entries"].is_array()) + return entries; + + for (const auto& item : root["entries"]) { + if (!item.is_object()) + continue; + StandardRecipeEntry entry; + entry.mode = parse_mode(item.value("mode", "CMYW")); + entry.material = item.value("material", ""); + entry.source = item.value("source", ""); + entry.measured_hex = item.value("measured_rgb", ""); + + if (item.contains("components") && item["components"].is_array()) { + for (const auto& comp : item["components"]) { + if (comp.is_object()) { + entry.component_keys.push_back(comp.value("key", "")); + entry.component_hexes.push_back(comp.value("rgb", "")); + } + } + } + if (item.contains("ratios") && item["ratios"].is_array()) { + for (const auto& ratio : item["ratios"]) { + if (ratio.is_number_integer()) + entry.ratios.push_back(ratio.get()); + } + } + if (item.contains("measured_lab") && item["measured_lab"].is_array() && item["measured_lab"].size() >= 3) { + entry.measured_lab = { + item["measured_lab"][0].get(), + item["measured_lab"][1].get(), + item["measured_lab"][2].get() + }; + } else { + ColorDecomposeRgb measured_rgb; + if (!color_decompose_hex_to_rgb(entry.measured_hex, measured_rgb)) + continue; + entry.measured_lab = rgb_to_lab(measured_rgb); + } + + if (entry.component_hexes.size() >= 2 && entry.component_hexes.size() == entry.ratios.size() && + !entry.measured_hex.empty()) + entries.push_back(std::move(entry)); + } + return entries; +} + +static const std::vector& standard_entries() +{ + static const std::vector entries = load_standard_entries(); + return entries; +} + +static void evaluate_candidate(const ColorDecomposeRgb& target, + const std::vector& hexes, + const std::vector& ratios, + const std::vector& indices, + ColorDecomposeRecipeMode mode, + double& best_score, + ColorDecomposeRecipeResult& best) +{ + const std::string mixed = blend_color_multi(hexes, ratios); + ColorDecomposeRgb mixed_rgb; + if (!color_decompose_hex_to_rgb(mixed, mixed_rgb)) + return; + + const double score = delta_e76(rgb_to_lab(target), rgb_to_lab(mixed_rgb)); + if (score >= best_score) + return; + + best_score = score; + best.valid = true; + best.mode = mode; + best.matched_color_hex = mixed; + best.components.clear(); + for (size_t i = 0; i < hexes.size(); ++i) { + ColorDecomposeRecipeComponent comp; + comp.color_hex = hexes[i]; + comp.ratio = ratios[i]; + comp.filament_index = i < indices.size() ? indices[i] : 0; + best.components.push_back(comp); + } +} + +} // namespace + +std::string color_decompose_rgb_to_hex(const ColorDecomposeRgb& rgb) +{ + char buf[8]; + std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", rgb.r, rgb.g, rgb.b); + return std::string(buf); +} + +bool color_decompose_hex_to_rgb(const std::string& hex, ColorDecomposeRgb& out) +{ + if (hex.size() < 7 || hex[0] != '#') + return false; + unsigned r = 0, g = 0, b = 0; + if (std::sscanf(hex.c_str(), "#%02x%02x%02x", &r, &g, &b) != 3) + return false; + out = {static_cast(r), static_cast(g), static_cast(b)}; + return true; +} + +ColorDecomposeRecipeResult recommend_from_physical_filaments( + const ColorDecomposeRgb& target, + const std::vector& physical_filaments, + const std::string& preferred_material_type) +{ + std::vector candidates; + for (const auto& filament : physical_filaments) { + if (filament.is_mixed) + continue; + ColorDecomposeRgb ignored; + if (!color_decompose_hex_to_rgb(filament.color_hex, ignored)) + continue; + if (preferred_material_type.empty() || material_matches(filament.type, preferred_material_type)) + candidates.push_back(filament); + } + + // Early exit: if a material-matched candidate has the exact target color, + // return it as 100%. Downstream rejects single-component results (no mixed + // slot created), which is correct -- the color already exists. + const std::string target_hex = color_decompose_rgb_to_hex(target); + for (const auto& cand : candidates) { + ColorDecomposeRgb cand_rgb; + if (!color_decompose_hex_to_rgb(cand.color_hex, cand_rgb)) + continue; + if (color_decompose_rgb_to_hex(cand_rgb) == target_hex) { + ColorDecomposeRecipeResult exact; + exact.valid = true; + exact.mode = ColorDecomposeRecipeMode::MaterialList; + exact.matched_color_hex = cand.color_hex; + ColorDecomposeRecipeComponent comp; + comp.color_hex = cand.color_hex; + comp.ratio = 100; + comp.filament_index = cand.filament_index; + exact.components.push_back(comp); + return exact; + } + } + + if (candidates.size() < 2) + candidates = physical_filaments; + candidates.erase(std::remove_if(candidates.begin(), candidates.end(), [](const auto& filament) { + if (filament.is_mixed) + return true; + ColorDecomposeRgb ignored; + return !color_decompose_hex_to_rgb(filament.color_hex, ignored); + }), candidates.end()); + + constexpr size_t kMaxCandidates = 8; + if (candidates.size() > kMaxCandidates) { + const LabColor target_lab = rgb_to_lab(target); + std::sort(candidates.begin(), candidates.end(), + [&target_lab](const ColorDecomposePhysicalFilament& a, const ColorDecomposePhysicalFilament& b) { + ColorDecomposeRgb rgb_a, rgb_b; + color_decompose_hex_to_rgb(a.color_hex, rgb_a); + color_decompose_hex_to_rgb(b.color_hex, rgb_b); + return delta_e76(target_lab, rgb_to_lab(rgb_a)) + < delta_e76(target_lab, rgb_to_lab(rgb_b)); + }); + candidates.resize(kMaxCandidates); + } + + ColorDecomposeRecipeResult best; + double best_score = std::numeric_limits::max(); + + for (size_t i = 0; i < candidates.size(); ++i) { + for (size_t j = i + 1; j < candidates.size(); ++j) { + const std::vector hexes = {candidates[i].color_hex, candidates[j].color_hex}; + const std::vector indices = {candidates[i].filament_index, candidates[j].filament_index}; + for (const auto& ratios : ratio_grid(2)) + evaluate_candidate(target, hexes, ratios, indices, ColorDecomposeRecipeMode::MaterialList, best_score, best); + + for (size_t k = j + 1; k < candidates.size(); ++k) { + const std::vector hexes3 = {candidates[i].color_hex, candidates[j].color_hex, candidates[k].color_hex}; + const std::vector indices3 = {candidates[i].filament_index, candidates[j].filament_index, candidates[k].filament_index}; + for (const auto& ratios : ratio_grid(3)) + evaluate_candidate(target, hexes3, ratios, indices3, ColorDecomposeRecipeMode::MaterialList, best_score, best); + } + } + } + + return best; +} + +ColorDecomposeRecipeResult lookup_standard_recipe( + const ColorDecomposeRgb& target, + ColorDecomposeRecipeMode mode, + const std::string& preferred_material_type) +{ + const LabColor target_lab = rgb_to_lab(target); + ColorDecomposeRecipeResult best; + double best_score = std::numeric_limits::max(); + + auto consider = [&](bool require_material_match) { + for (const StandardRecipeEntry& entry : standard_entries()) { + if (entry.mode != mode) + continue; + if (require_material_match && !material_matches(entry.material, preferred_material_type)) + continue; + if (!require_material_match && !preferred_material_type.empty() && material_matches(entry.material, preferred_material_type)) + continue; + + const double score = delta_e76(target_lab, entry.measured_lab); + if (score >= best_score) + continue; + + best_score = score; + best.valid = true; + best.mode = mode; + best.matched_color_hex = entry.measured_hex; + best.components.clear(); + for (size_t i = 0; i < entry.component_hexes.size(); ++i) { + ColorDecomposeRecipeComponent comp; + comp.color_hex = entry.component_hexes[i]; + comp.base_color = i < entry.component_keys.size() ? entry.component_keys[i] : ""; + comp.ratio = entry.ratios[i]; + comp.filament_index = 0; + best.components.push_back(comp); + } + } + }; + + consider(true); + if (!best.valid) + consider(false); + return best; +} + +std::string lookup_measured_blend_color(const std::vector& component_hexes, + const std::vector& ratios) +{ + if (component_hexes.size() < 2 || component_hexes.size() != ratios.size()) + return {}; + + auto normalize_hex = [](const std::string& hex) -> std::string { + ColorDecomposeRgb rgb; + if (!color_decompose_hex_to_rgb(hex, rgb)) + return {}; + char buf[8]; + std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", rgb.r, rgb.g, rgb.b); + return std::string(buf); + }; + + // Stage 1: canonicalize input by sorting (hex, ratio) pairs so matching + // is independent of the caller's component order. + const size_t n = component_hexes.size(); + std::vector> in_pairs; + in_pairs.reserve(n); + for (size_t i = 0; i < n; ++i) { + std::string nh = normalize_hex(component_hexes[i]); + if (nh.empty()) + return {}; + in_pairs.emplace_back(std::move(nh), ratios[i]); + } + std::sort(in_pairs.begin(), in_pairs.end()); + + std::vector in_hexes; + std::vector in_ratios; + in_hexes.reserve(n); + in_ratios.reserve(n); + for (const auto& p : in_pairs) { + in_hexes.push_back(p.first); + in_ratios.push_back(p.second); + } + + // Normalize ratios to sum=100 (callers may pass arbitrary weights, + // e.g. MixedFilamentDialog uses ratio*10000). + { + int sum = 0; + for (int r : in_ratios) sum += r; + if (sum > 0 && sum != 100) { + int new_sum = 0; + for (size_t i = 0; i < in_ratios.size(); ++i) { + in_ratios[i] = static_cast(std::lround( + static_cast(in_ratios[i]) * 100.0 / static_cast(sum))); + new_sum += in_ratios[i]; + } + if (new_sum != 100) { + auto it = std::max_element(in_ratios.begin(), in_ratios.end()); + *it += (100 - new_sum); + } + } + } + + // Fall back to polynomial model for ratios outside the measured range. + { + bool out_of_range = false; + if (n == 2) { + for (int r : in_ratios) + if (r < 20 || r > 80) { out_of_range = true; break; } + } else { + for (int r : in_ratios) + if (r < 20) { out_of_range = true; break; } + } + if (out_of_range) + return {}; + } + + // Stage 2: collect anchors with the same component hex set; try exact match. + struct Anchor { + std::vector ratios; + LabColor lab; + std::string hex; + }; + std::vector anchors; + + for (const StandardRecipeEntry& entry : standard_entries()) { + if (entry.source != "measured" && entry.source != "interpolated") + continue; + if (entry.component_hexes.size() != n) + continue; + + std::vector> e_pairs; + e_pairs.reserve(n); + for (size_t i = 0; i < n; ++i) + e_pairs.emplace_back(normalize_hex(entry.component_hexes[i]), entry.ratios[i]); + std::sort(e_pairs.begin(), e_pairs.end()); + + bool same_set = true; + for (size_t i = 0; i < n; ++i) + if (e_pairs[i].first != in_hexes[i]) { same_set = false; break; } + if (!same_set) + continue; + + Anchor a; + a.ratios.reserve(n); + for (const auto& p : e_pairs) a.ratios.push_back(p.second); + a.lab = entry.measured_lab; + a.hex = entry.measured_hex; + + if (a.ratios == in_ratios) + return a.hex; + + anchors.push_back(std::move(a)); + } + + if (anchors.size() < 2) + return {}; + + // Stage 3: interpolation in Lab space. + if (n == 2) { + // 1D linear interpolation along ratio[0]. + std::sort(anchors.begin(), anchors.end(), + [](const Anchor& a, const Anchor& b) { return a.ratios[0] < b.ratios[0]; }); + const double x = static_cast(in_ratios[0]); + size_t lo = 0; + while (lo + 2 < anchors.size() && static_cast(anchors[lo + 1].ratios[0]) <= x) + ++lo; + const Anchor& a0 = anchors[lo]; + const Anchor& a1 = anchors[lo + 1]; + const double span = static_cast(a1.ratios[0] - a0.ratios[0]); + const double t = span > 0.0 ? (x - static_cast(a0.ratios[0])) / span : 0.0; + return lab_to_srgb_hex({a0.lab.l + t * (a1.lab.l - a0.lab.l), + a0.lab.a + t * (a1.lab.a - a0.lab.a), + a0.lab.b + t * (a1.lab.b - a0.lab.b)}); + } + + // 3+ color: IDW (p=2) with 3 nearest anchors in the (ratio[0], ratio[1]) plane. + const double ra = static_cast(in_ratios[0]); + const double rb = static_cast(in_ratios[1]); + std::vector> dists; + dists.reserve(anchors.size()); + for (const Anchor& a : anchors) { + const double d = std::sqrt(std::pow(ra - static_cast(a.ratios[0]), 2.0) + + std::pow(rb - static_cast(a.ratios[1]), 2.0)); + if (d == 0.0) + return a.hex; + dists.emplace_back(d, &a); + } + const size_t k = std::min(static_cast(3), dists.size()); + std::partial_sort(dists.begin(), dists.begin() + k, dists.end(), + [](const auto& a, const auto& b) { return a.first < b.first; }); + double num_l = 0.0, num_a = 0.0, num_b = 0.0, den = 0.0; + for (size_t j = 0; j < k; ++j) { + const double w = 1.0 / (dists[j].first * dists[j].first); + num_l += w * dists[j].second->lab.l; + num_a += w * dists[j].second->lab.a; + num_b += w * dists[j].second->lab.b; + den += w; + } + return lab_to_srgb_hex({num_l / den, num_a / den, num_b / den}); +} + +} // namespace Slic3r diff --git a/src/libslic3r/ColorDecomposeRecipe.hpp b/src/libslic3r/ColorDecomposeRecipe.hpp new file mode 100644 index 0000000000..146bf322a2 --- /dev/null +++ b/src/libslic3r/ColorDecomposeRecipe.hpp @@ -0,0 +1,64 @@ +#ifndef SLIC3R_COLOR_DECOMPOSE_RECIPE_HPP +#define SLIC3R_COLOR_DECOMPOSE_RECIPE_HPP + +#include +#include + +namespace Slic3r { + +enum class ColorDecomposeRecipeMode { + MaterialList, + CMYW, + RYBW +}; + +struct ColorDecomposeRgb { + unsigned char r{0}; + unsigned char g{0}; + unsigned char b{0}; +}; + +struct ColorDecomposePhysicalFilament { + std::string color_hex; + std::string name; + std::string type; + bool is_mixed{false}; + unsigned int filament_index{0}; // 1-based physical filament index +}; + +struct ColorDecomposeRecipeComponent { + std::string color_hex; + std::string base_color; + int ratio{0}; + unsigned int filament_index{0}; // 1-based for physical filaments, 0 for standard base colors +}; + +struct ColorDecomposeRecipeResult { + bool valid{false}; + ColorDecomposeRecipeMode mode{ColorDecomposeRecipeMode::MaterialList}; + std::string matched_color_hex; + std::vector components; +}; + +std::string color_decompose_rgb_to_hex(const ColorDecomposeRgb& rgb); +bool color_decompose_hex_to_rgb(const std::string& hex, ColorDecomposeRgb& out); + +ColorDecomposeRecipeResult recommend_from_physical_filaments( + const ColorDecomposeRgb& target, + const std::vector& physical_filaments, + const std::string& preferred_material_type); + +ColorDecomposeRecipeResult lookup_standard_recipe( + const ColorDecomposeRgb& target, + ColorDecomposeRecipeMode mode, + const std::string& preferred_material_type); + +// Look up the measured blend color for an exact (component_hexes, ratios) match +// in the standard color recipe table. Returns the measured hex color if found +// with reliable source data ("measured" or "interpolated"), empty string otherwise. +std::string lookup_measured_blend_color(const std::vector& component_hexes, + const std::vector& ratios); + +} // namespace Slic3r + +#endif // SLIC3R_COLOR_DECOMPOSE_RECIPE_HPP diff --git a/src/libslic3r/FilamentMixer.cpp b/src/libslic3r/FilamentMixer.cpp new file mode 100644 index 0000000000..66640498e6 --- /dev/null +++ b/src/libslic3r/FilamentMixer.cpp @@ -0,0 +1,829 @@ +#include "FilamentMixer.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "ColorDecomposeRecipe.hpp" +#include "FilamentMixerModel.hpp" +#include "LocalesUtils.hpp" + +namespace Slic3r { +namespace { + +inline float clamp01(float x) +{ + return std::max(0.0f, std::min(1.0f, x)); +} + +inline float srgb_to_linear(float x) +{ + return (x >= 0.04045f) ? std::pow((x + 0.055f) / 1.055f, 2.4f) : x / 12.92f; +} + +inline float linear_to_srgb(float x) +{ + return (x >= 0.0031308f) ? (1.055f * std::pow(x, 1.0f / 2.4f) - 0.055f) : (12.92f * x); +} + +inline unsigned char to_u8(float x) +{ + const float clamped = clamp01(x); + return static_cast(clamped * 255.0f + 0.5f); +} + +inline float to_f01(unsigned char x) +{ + return static_cast(x) / 255.0f; +} + +} // namespace + +void filament_mixer_lerp(unsigned char r1, unsigned char g1, unsigned char b1, + unsigned char r2, unsigned char g2, unsigned char b2, + float t, + unsigned char* out_r, unsigned char* out_g, unsigned char* out_b) +{ + ::filament_mixer::lerp(r1, g1, b1, r2, g2, b2, t, out_r, out_g, out_b); +} + +void filament_mixer_lerp_float(float r1, float g1, float b1, + float r2, float g2, float b2, + float t, + float* out_r, float* out_g, float* out_b) +{ + unsigned char ur = 0, ug = 0, ub = 0; + filament_mixer_lerp(to_u8(r1), to_u8(g1), to_u8(b1), + to_u8(r2), to_u8(g2), to_u8(b2), + t, &ur, &ug, &ub); + *out_r = to_f01(ur); + *out_g = to_f01(ug); + *out_b = to_f01(ub); +} + +void filament_mixer_lerp_linear_float(float r1, float g1, float b1, + float r2, float g2, float b2, + float t, + float* out_r, float* out_g, float* out_b) +{ + const float sr1 = linear_to_srgb(clamp01(r1)); + const float sg1 = linear_to_srgb(clamp01(g1)); + const float sb1 = linear_to_srgb(clamp01(b1)); + const float sr2 = linear_to_srgb(clamp01(r2)); + const float sg2 = linear_to_srgb(clamp01(g2)); + const float sb2 = linear_to_srgb(clamp01(b2)); + + float out_sr = 0.0f, out_sg = 0.0f, out_sb = 0.0f; + filament_mixer_lerp_float(sr1, sg1, sb1, sr2, sg2, sb2, t, &out_sr, &out_sg, &out_sb); + + *out_r = srgb_to_linear(clamp01(out_sr)); + *out_g = srgb_to_linear(clamp01(out_sg)); + *out_b = srgb_to_linear(clamp01(out_sb)); +} + +static bool parse_hex(const std::string &hex, unsigned char &r, unsigned char &g, unsigned char &b) +{ + if (hex.size() < 7 || hex[0] != '#') return false; + unsigned rv = 0, gv = 0, bv = 0; + if (std::sscanf(hex.c_str(), "#%02x%02x%02x", &rv, &gv, &bv) != 3) return false; + r = (unsigned char)rv; g = (unsigned char)gv; b = (unsigned char)bv; + return true; +} + +std::string blend_color(const std::string& hex_a, const std::string& hex_b, float ratio_b) +{ + unsigned char r1 = 128, g1 = 128, b1 = 128; + unsigned char r2 = 128, g2 = 128, b2 = 128; + parse_hex(hex_a, r1, g1, b1); + parse_hex(hex_b, r2, g2, b2); + + unsigned char mr = 0, mg = 0, mb = 0; + filament_mixer_lerp(r1, g1, b1, r2, g2, b2, ratio_b, &mr, &mg, &mb); + + char buf[8]; + std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", mr, mg, mb); + return std::string(buf); +} + +std::string blend_color_multi(const std::vector &hex_colors, + const std::vector &weights) +{ + if (hex_colors.size() >= 2 && hex_colors.size() == weights.size()) { + std::string measured = lookup_measured_blend_color(hex_colors, weights); + if (!measured.empty()) + return measured; + } + + if (hex_colors.empty()) + return "#000000"; + if (hex_colors.size() == 1) { + unsigned char cr = 128, cg = 128, cb = 128; + parse_hex(hex_colors.front(), cr, cg, cb); + char buf[8]; + std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", cr, cg, cb); + return std::string(buf); + } + + assert(hex_colors.size() == weights.size()); + + unsigned char r = 128, g = 128, b = 128; + int accumulated = 0; + + for (size_t i = 0; i < hex_colors.size() && i < weights.size(); ++i) { + if (weights[i] <= 0) + continue; + unsigned char cr = 128, cg = 128, cb = 128; + parse_hex(hex_colors[i], cr, cg, cb); + if (accumulated == 0) { + r = cr; g = cg; b = cb; + accumulated = weights[i]; + } else { + const int new_total = accumulated + weights[i]; + const float t = static_cast(weights[i]) / static_cast(new_total); + filament_mixer_lerp(r, g, b, cr, cg, cb, t, &r, &g, &b); + accumulated = new_total; + } + } + + if (accumulated == 0) + return "#000000"; + + char buf[8]; + std::snprintf(buf, sizeof(buf), "#%02X%02X%02X", r, g, b); + return std::string(buf); +} + +std::vector parse_mixed_components(const std::string &str) +{ + std::vector components; + if (str.empty()) + return components; + std::istringstream ss(str); + std::string token; + while (std::getline(ss, token, ',')) { + try { + int val = std::stoi(token); + if (val >= 0) + components.push_back(static_cast(val)); + } catch (...) {} + } + return components; +} + +namespace { + +// Parse a token that may represent a finite double or "use default" (empty / "nan"). +// Returns NaN on either explicit sentinel or any parse error. +inline double parse_tangent_token(const std::string& tok) +{ + if (tok.empty()) return std::numeric_limits::quiet_NaN(); + std::string lower(tok.size(), '\0'); + std::transform(tok.begin(), tok.end(), lower.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (lower == "nan") return std::numeric_limits::quiet_NaN(); + try { + const double v = std::stod(tok); + if (!std::isfinite(v)) return std::numeric_limits::quiet_NaN(); + return v; + } catch (...) { + return std::numeric_limits::quiet_NaN(); + } +} + +// Split a "a,b,c,d" segment on commas, preserving empty tokens (so "0.5,0.4,," yields +// {"0.5","0.4","",""}). Used by the gradient-curve parser to distinguish NaN tangents +// from a malformed segment. +inline std::vector split_commas(const std::string& seg) +{ + std::vector out; + size_t start = 0; + while (true) { + const size_t comma = seg.find(',', start); + if (comma == std::string::npos) { + out.emplace_back(seg.substr(start)); + return out; + } + out.emplace_back(seg.substr(start, comma - start)); + start = comma + 1; + } +} + +} // namespace + +// Default Fritsch-Carlson PCHIP tangents for a sorted-by-x anchor list. m has size n +// matching the anchor count; for n == 1 the tangent is 0; for n == 2 both endpoint +// tangents equal the single secant (degenerates to linear). +std::vector compute_pchip_default_tangents(const std::vector& pts) +{ + const size_t n = pts.size(); + std::vector m(n, 0.0); + if (n < 2) return m; + + std::vector d(n - 1); + for (size_t i = 0; i + 1 < n; ++i) { + const double h = std::max(1e-12, pts[i + 1].x - pts[i].x); + d[i] = (pts[i + 1].y - pts[i].y) / h; + } + + m[0] = d[0]; + m[n - 1] = d[n - 2]; + for (size_t i = 1; i + 1 < n; ++i) + m[i] = 0.5 * (d[i - 1] + d[i]); + + // Fritsch-Carlson monotonic guard: kill flats then rescale steep tangents so the + // resulting cubic never overshoots [min, max] of the surrounding anchors. + for (size_t i = 0; i + 1 < n; ++i) { + if (d[i] == 0.0) { + m[i] = 0.0; + m[i + 1] = 0.0; + continue; + } + const double a = m[i] / d[i]; + const double b = m[i + 1] / d[i]; + const double s = a * a + b * b; + if (s > 9.0) { + const double tau = 3.0 / std::sqrt(s); + m[i] = tau * a * d[i]; + m[i + 1] = tau * b * d[i]; + } + } + return m; +} + +GradientCurve parse_gradient_curve(const std::string& s) +{ + GradientCurve curve; + if (s.empty()) + return curve; + + CNumericLocalesSetter c_locale_setter; + std::istringstream ss(s); + std::string segment; + while (std::getline(ss, segment, '|')) { + if (segment.empty()) + continue; + const auto fields = split_commas(segment); + // 2-field legacy form -> (x, y), tangents stay NaN. + // 4-field form -> (x, y, m_in, m_out), empty / "nan" tokens preserved as NaN. + if (fields.size() != 2 && fields.size() != 4) { + BOOST_LOG_TRIVIAL(warning) << "parse_gradient_curve: ignoring malformed segment \"" + << segment << "\" (expected 2 or 4 comma-separated fields, got " + << fields.size() << ")"; + continue; + } + try { + double x = std::stod(fields[0]); + double y = std::stod(fields[1]); + x = std::max(0.0, std::min(1.0, x)); + y = std::max(kGradientMinRatio, std::min(kGradientMaxRatio, y)); + GradientAnchor a; + a.x = x; + a.y = y; + if (fields.size() == 4) { + a.m_in = parse_tangent_token(fields[2]); + a.m_out = parse_tangent_token(fields[3]); + } + curve.points.push_back(a); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "parse_gradient_curve: ignoring unparseable segment \"" + << segment << "\": " << e.what(); + } + } + + if (curve.points.size() < 2) { + if (!curve.points.empty()) + BOOST_LOG_TRIVIAL(warning) << "parse_gradient_curve: only " + << curve.points.size() << " valid point(s), need at least 2; discarding"; + curve.points.clear(); + return curve; + } + + std::sort(curve.points.begin(), curve.points.end(), + [](const GradientAnchor& a, const GradientAnchor& b) { + return a.x < b.x; + }); + return curve; +} + +std::string serialize_gradient_curve(const GradientCurve& c) +{ + if (c.points.empty()) + return std::string{}; + + CNumericLocalesSetter c_locale_setter; + std::string out; + char buf[128]; + for (size_t i = 0; i < c.points.size(); ++i) { + if (i > 0) out += '|'; + const auto& a = c.points[i]; + const bool has_in = std::isfinite(a.m_in); + const bool has_out = std::isfinite(a.m_out); + if (has_in || has_out) { + // Emit empty tokens for NaN slots so the legacy parser would still split + // four fields; the new parser interprets empty tokens as "use PCHIP default". + char in_buf[32] = {0}; + char out_buf[32] = {0}; + if (has_in) std::snprintf(in_buf, sizeof(in_buf), "%.4f", a.m_in); + if (has_out) std::snprintf(out_buf, sizeof(out_buf), "%.4f", a.m_out); + std::snprintf(buf, sizeof(buf), "%.4f,%.4f,%s,%s", + a.x, a.y, in_buf, out_buf); + } else { + // 4-field form is only emitted when at least one tangent is finite; the + // 2-field form is emitted otherwise so the JSON payload stays minimal + // and remains readable by older clients that only know (x, y) pairs. + std::snprintf(buf, sizeof(buf), "%.4f,%.4f", a.x, a.y); + } + out += buf; + } + return out; +} + +double sample_gradient_curve(const GradientCurve& c, double t) +{ + const auto& pts = c.points; + if (pts.size() < 2) + return 0.5; + if (t <= pts.front().x) + return pts.front().y; + if (t >= pts.back().x) + return pts.back().y; + + // PCHIP defaults are computed for every call; control point counts are typically + // tiny (< 16) so the allocation cost is negligible compared to any actual rendering + // or G-code work that drives the sampler. + const std::vector m_def = compute_pchip_default_tangents(pts); + const size_t n = pts.size(); + + // Linear scan to locate the interval [pts[i].x, pts[i+1].x] containing t. Cheap + // and avoids the upper_bound boilerplate; n is small. + for (size_t i = 1; i < n; ++i) { + const double x0 = pts[i - 1].x; + const double x1 = pts[i].x; + if (t > x1) continue; + + const double y0 = pts[i - 1].y; + const double y1 = pts[i].y; + const double h = std::max(1e-12, x1 - x0); + const double m_left = std::isfinite(pts[i - 1].m_out) ? pts[i - 1].m_out : m_def[i - 1]; + const double m_right = std::isfinite(pts[i].m_in) ? pts[i].m_in : m_def[i]; + + const double u = (t - x0) / h; + const double u2 = u * u; + const double u3 = u2 * u; + const double h00 = 2.0 * u3 - 3.0 * u2 + 1.0; + const double h10 = u3 - 2.0 * u2 + u; + const double h01 = -2.0 * u3 + 3.0 * u2; + const double h11 = u3 - u2; + double y = h00 * y0 + h10 * h * m_left + + h01 * y1 + h11 * h * m_right; + // Defensive clamp in case tangent overrides on legacy curves push the + // single-segment Hermite slightly outside the anchor band. + if (y < kGradientMinRatio) y = kGradientMinRatio; + if (y > kGradientMaxRatio) y = kGradientMaxRatio; + return y; + } + return pts.back().y; +} + +std::vector parse_mixed_ratios(const std::string &str, size_t n_components) +{ + CNumericLocalesSetter c_locale_setter; + std::vector ratios; + if (!str.empty()) { + std::istringstream ss(str); + std::string token; + while (std::getline(ss, token, ',')) { + try { + double val = std::stod(token); + if (val > 0.0) + ratios.push_back(val); + } catch (...) {} + } + } + + if (ratios.size() != n_components || n_components == 0) { + ratios.assign(n_components, n_components > 0 ? 1.0 / n_components : 0.0); + return ratios; + } + + double sum = std::accumulate(ratios.begin(), ratios.end(), 0.0); + if (sum > 0.0 && std::abs(sum - 1.0) > 1e-6) { + for (double &r : ratios) + r /= sum; + } + return ratios; +} + +bool has_any_mixed_filament(const std::vector &is_mixed) +{ + for (unsigned char v : is_mixed) + if (v) return true; + return false; +} + +std::vector check_mixed_filament_integrity( + const std::vector &is_mixed, + const std::vector &comp_strs, + size_t num_physical) +{ + std::vector broken; + for (size_t i = 0; i < is_mixed.size(); ++i) { + if (!is_mixed[i]) continue; + if (i >= comp_strs.size() || comp_strs[i].empty()) { + broken.push_back(i); + continue; + } + auto comps = parse_mixed_components(comp_strs[i]); + if (comps.size() < 2) { + broken.push_back(i); + continue; + } + for (unsigned int c : comps) { + if (c < 1 || c > num_physical) { + broken.push_back(i); + break; + } + } + } + return broken; +} + +std::vector expand_mixed_filaments( + const std::vector &extruders_0based, + const std::vector &is_mixed, + const std::vector &comp_strs) +{ + std::vector result; + for (unsigned int ext : extruders_0based) { + if (ext < is_mixed.size() && is_mixed[ext] && ext < comp_strs.size()) { + auto comps = parse_mixed_components(comp_strs[ext]); + for (unsigned int c : comps) + if (c >= 1) result.push_back(c - 1); + } else { + result.push_back(ext); + } + } + std::sort(result.begin(), result.end()); + result.erase(std::unique(result.begin(), result.end()), result.end()); + return result; +} + +void remap_mixed_components_on_delete( + const std::vector &is_mixed, + std::vector &comp_strs, + unsigned int del_1based) +{ + for (size_t i = 0; i < is_mixed.size(); ++i) { + if (!is_mixed[i]) continue; + if (i >= comp_strs.size() || comp_strs[i].empty()) continue; + + auto comps = parse_mixed_components(comp_strs[i]); + std::ostringstream ss; + for (size_t j = 0; j < comps.size(); ++j) { + if (j > 0) ss << ','; + if (comps[j] == del_1based) + ss << 0; + else if (comps[j] > del_1based) + ss << (comps[j] - 1); + else + ss << comps[j]; + } + comp_strs[i] = ss.str(); + } +} + +std::vector check_mixed_filament_type_consistency( + const std::vector &is_mixed, + const std::vector &comp_strs, + const std::vector &filament_types) +{ + std::vector result; + for (size_t i = 0; i < is_mixed.size(); ++i) { + if (!is_mixed[i]) continue; + if (i >= comp_strs.size() || comp_strs[i].empty()) continue; + auto comps = parse_mixed_components(comp_strs[i]); + if (comps.size() < 2) continue; + + std::string ref_type; + bool mismatch = false; + for (unsigned int c : comps) { + if (c == 0) continue; // sentinel for deleted component + size_t idx = static_cast(c) - 1; // 1-based -> 0-based + if (idx >= filament_types.size()) continue; + if (ref_type.empty()) + ref_type = filament_types[idx]; + else if (filament_types[idx] != ref_type) { + mismatch = true; + break; + } + } + if (mismatch) + result.push_back(i); + } + return result; +} + +void expand_mixed_slots_in_unprintables( + std::vector> &unprintables, + const std::vector &is_mixed, + const std::vector &comp_strs) +{ + for (auto &unprintable_set : unprintables) { + std::set expanded; + for (int fid : unprintable_set) { + if (fid >= 0 && (size_t)fid < is_mixed.size() && is_mixed[fid] + && (size_t)fid < comp_strs.size()) { + auto comps = parse_mixed_components(comp_strs[fid]); + for (unsigned int c : comps) + if (c >= 1) expanded.insert((int)(c - 1)); + } else { + expanded.insert(fid); + } + } + unprintable_set = std::move(expanded); + } +} + +void sanitize_mixed_gradient_curve_array(std::vector& vals) +{ + for (size_t i = 0; i < vals.size(); ++i) { + if (vals[i].empty()) + continue; + // parse_gradient_curve returns empty for both "empty input" and "<2 valid points"; + // we already skipped empty, so an empty result means a corrupted single-point slot. + if (parse_gradient_curve(vals[i]).empty()) { + BOOST_LOG_TRIVIAL(warning) << "sanitize_mixed_gradient_curve_array: slot " + << i << " curve \"" << vals[i] + << "\" has fewer than 2 valid points; clearing to linear"; + vals[i].clear(); + } + } +} + +bool try_parse_mixed_components_strict(const std::string &str, + std::vector &components, + std::string &err) +{ + components.clear(); + if (str.empty()) { + err = "empty component list"; + return false; + } + std::istringstream ss(str); + std::string token; + while (std::getline(ss, token, ',')) { + if (token.empty()) { + err = "empty component index"; + return false; + } + try { + const long val = std::stol(token); + if (val < 1) { + err = "component index must be >= 1 (got " + token + ")"; + return false; + } + components.push_back(static_cast(val)); + } catch (...) { + err = "invalid component index \"" + token + "\""; + return false; + } + } + if (components.size() < 2) { + err = "at least 2 components required (got " + std::to_string(components.size()) + ")"; + return false; + } + std::set seen; + for (unsigned int c : components) { + if (!seen.insert(c).second) { + err = "duplicate component index " + std::to_string(c); + return false; + } + } + return true; +} + +bool try_parse_mixed_ratios_strict(const std::string &str, + size_t n_components, + std::string &err) +{ + if (str.empty()) + return true; + + CNumericLocalesSetter c_locale_setter; + std::vector ratios; + std::istringstream ss(str); + std::string token; + while (std::getline(ss, token, ',')) { + if (token.empty()) { + err = "empty ratio value"; + return false; + } + try { + const double val = std::stod(token); + if (!(val > 0.0)) { + err = "ratio must be positive (got " + token + ")"; + return false; + } + ratios.push_back(val); + } catch (...) { + err = "invalid ratio \"" + token + "\""; + return false; + } + } + if (ratios.size() != n_components) { + err = "expected " + std::to_string(n_components) + " ratio(s), got " + + std::to_string(ratios.size()); + return false; + } + return true; +} + +bool validate_gradient_range_strict(const std::string &str, std::string &err) +{ + if (str.empty()) + return true; + + CNumericLocalesSetter c_locale_setter; + float v0 = 0.f, v1 = 0.f; + if (std::sscanf(str.c_str(), "%f,%f", &v0, &v1) != 2) { + err = "expected two comma-separated floats, e.g. \"0.10,0.90\""; + return false; + } + if (!(v0 > 0.f && v0 < 1.f && v1 > 0.f && v1 < 1.f)) { + err = "start and end ratios must be in (0, 1)"; + return false; + } + return true; +} + +static void append_error(std::map &errors, + const std::string &key, + const std::string &msg) +{ + auto it = errors.find(key); + if (it == errors.end()) + errors.emplace(key, msg); + else + it->second += "; " + msg; +} + +static bool has_mixed_sub_params_specified( + const std::vector &comp_strs, + const std::vector &ratio_strs, + const std::vector &gradient_flags) +{ + for (const std::string &s : comp_strs) + if (!s.empty()) return true; + for (const std::string &s : ratio_strs) + if (!s.empty()) return true; + for (unsigned char g : gradient_flags) + if (g) return true; + return false; +} + +static bool mixed_string_array_was_specified(const std::vector &vals) +{ + for (const std::string &s : vals) + if (!s.empty()) + return true; + return false; +} + +static bool mixed_bool_array_was_specified(const std::vector &vals) +{ + for (unsigned char v : vals) + if (v) + return true; + return false; +} + +static void check_mixed_array_size_required(std::map &errors, + const std::string &opt_key, + size_t actual_size, + size_t expected_size) +{ + if (actual_size != expected_size) { + append_error(errors, opt_key, + "array size " + std::to_string(actual_size) + + " does not match filament slot count " + std::to_string(expected_size)); + } +} + +std::map validate_mixed_filament_params( + const std::vector &is_mixed, + const std::vector &comp_strs, + const std::vector &ratio_strs, + const std::vector &gradient_flags, + const std::vector &gradient_range_strs, + const std::vector &gradient_curve_strs) +{ + std::map errors; + + if (has_mixed_sub_params_specified(comp_strs, ratio_strs, gradient_flags) + && !has_any_mixed_filament(is_mixed)) { + append_error(errors, "filament_is_mixed", + "must be set when mixed filament parameters are specified"); + return errors; + } + + if (!has_any_mixed_filament(is_mixed)) + return errors; + + const size_t slot_count = is_mixed.size(); + + // Rule 1: mixed filament model → components & ratios arrays must cover every slot. + check_mixed_array_size_required(errors, "filament_mixed_components", comp_strs.size(), slot_count); + check_mixed_array_size_required(errors, "filament_mixed_sublayer_ratios", ratio_strs.size(), slot_count); + + // Rule 2: gradient passed (any slot true) → gradient & range arrays must cover every slot. + const bool gradient_specified = mixed_bool_array_was_specified(gradient_flags); + if (gradient_specified) { + check_mixed_array_size_required(errors, "filament_mixed_gradient", gradient_flags.size(), slot_count); + check_mixed_array_size_required(errors, "filament_mixed_gradient_range", gradient_range_strs.size(), slot_count); + } + + // Rule 3: curve passed (any non-empty entry) → curve array must cover every slot. + const bool curve_specified = mixed_string_array_was_specified(gradient_curve_strs); + if (curve_specified) + check_mixed_array_size_required(errors, "filament_mixed_gradient_curve", gradient_curve_strs.size(), slot_count); + + size_t num_physical = 0; + for (unsigned char v : is_mixed) + if (!v) ++num_physical; + + for (size_t i = 0; i < is_mixed.size(); ++i) { + if (!is_mixed[i]) + continue; + + const std::string slot = "slot " + std::to_string(i + 1); + const std::string comp_str = i < comp_strs.size() ? comp_strs[i] : ""; + + std::vector components; + std::string comp_err; + if (!try_parse_mixed_components_strict(comp_str, components, comp_err)) { + append_error(errors, "filament_mixed_components", slot + ": " + comp_err); + continue; + } + + for (unsigned int c : components) { + if (c > num_physical) { + append_error(errors, "filament_mixed_components", + slot + ": component " + std::to_string(c) + + " out of range (max physical filament index is " + + std::to_string(num_physical) + ")"); + break; + } + if (c == i + 1) { + append_error(errors, "filament_mixed_components", + slot + ": cannot reference itself as a component"); + break; + } + const size_t idx0 = static_cast(c - 1); + if (idx0 < is_mixed.size() && is_mixed[idx0]) { + append_error(errors, "filament_mixed_components", + slot + ": component " + std::to_string(c) + + " references a mixed filament slot"); + break; + } + } + + std::string ratio_err; + const std::string ratio_str = i < ratio_strs.size() ? ratio_strs[i] : ""; + if (!try_parse_mixed_ratios_strict(ratio_str, components.size(), ratio_err)) + append_error(errors, "filament_mixed_sublayer_ratios", slot + ": " + ratio_err); + + const bool gradient_on = i < gradient_flags.size() && gradient_flags[i]; + if (gradient_on) { + if (components.size() != 2) { + append_error(errors, "filament_mixed_gradient", + slot + ": gradient requires exactly 2 components"); + } + + if (gradient_specified) { + std::string range_err; + const std::string range_str = i < gradient_range_strs.size() ? gradient_range_strs[i] : ""; + if (!validate_gradient_range_strict(range_str, range_err)) + append_error(errors, "filament_mixed_gradient_range", slot + ": " + range_err); + } + + if (curve_specified) { + const std::string curve_str = i < gradient_curve_strs.size() ? gradient_curve_strs[i] : ""; + if (!curve_str.empty() && parse_gradient_curve(curve_str).empty()) + append_error(errors, "filament_mixed_gradient_curve", + slot + ": invalid curve (need at least 2 valid control points)"); + } + } + } + + return errors; +} + +} // namespace Slic3r diff --git a/src/libslic3r/FilamentMixer.hpp b/src/libslic3r/FilamentMixer.hpp new file mode 100644 index 0000000000..81ddd29e46 --- /dev/null +++ b/src/libslic3r/FilamentMixer.hpp @@ -0,0 +1,164 @@ +#ifndef SLIC3R_FILAMENT_MIXER_HPP +#define SLIC3R_FILAMENT_MIXER_HPP + +#include +#include +#include +#include +#include +#include + +namespace Slic3r { + +// Photoshop-style gradient curve control point in [0,1] x [0,1]. +// (x, y) is the anchor position; (m_in, m_out) are optional cubic Hermite tangent +// overrides. NaN means "use the PCHIP-computed default", which is the case for plain +// anchors loaded from old 2-field 3MF projects or freshly added via a quick click. +// A press-and-drag on a curve segment populates m_out of its left anchor and m_in of +// its right anchor so the segment bends without inserting a new anchor. +struct GradientAnchor { + double x = 0.0; + double y = 0.0; + double m_in = std::numeric_limits::quiet_NaN(); + double m_out = std::numeric_limits::quiet_NaN(); +}; + +// Sorted list of GradientAnchor; x in [0,1], y in [kGradientMinRatio, kGradientMaxRatio]. +// Empty means "no custom curve" (callers should fall back to the linear range). +struct GradientCurve { + std::vector points; + bool empty() const { return points.empty(); } +}; + +// Reserved blend ratio range. Anchor y values (= component 0's ratio) are constrained +// to this band so the mixed filament never reaches pure 0% / 100% of either physical +// component, which keeps both extruders flowing and avoids degenerate transitions. +// Both the editor and the sampler enforce this clamp. +constexpr double kGradientMinRatio = 0.1; +constexpr double kGradientMaxRatio = 0.9; + +// Parse "x0,y0[,m_in0,m_out0]|x1,y1[,m_in1,m_out1]|..." into a GradientCurve. +// (Anchors are pipe-separated; the fields within an anchor are comma-separated.) +// Accepts both the legacy 2-field form (tangents -> NaN) and the new 4-field form +// (empty token or "nan" preserved as NaN). Returns an empty curve when the input is +// empty or unparsable. Points are clamped to [0,1] for (x, y) and re-sorted by x. +GradientCurve parse_gradient_curve(const std::string& s); + +// Serialize a GradientCurve back to a string. Emits 4 fields per anchor when any +// tangent override is finite; emits 2 fields when both tangents are NaN so unchanged +// projects stay byte-identical with the legacy format. Returns "" when empty. +std::string serialize_gradient_curve(const GradientCurve& c); + +// Sample the curve at t in [0,1] using cubic Hermite with Fritsch-Carlson PCHIP +// default tangents, optionally overridden per anchor via m_in / m_out. Returns the +// clamped end values when t is outside the control point range. Returns 0.5 when the +// curve has fewer than 2 points (a safety fallback; callers should check empty()). +double sample_gradient_curve(const GradientCurve& c, double t); + +// Compute Fritsch-Carlson PCHIP default tangents for a sorted-by-x anchor list. +// Result size == pts.size(). Useful for callers that need to know what tangent the +// sampler would synthesize when m_in / m_out are NaN (e.g. the GUI's segment-bend +// interaction that inserts a virtual anchor and reads back the surrounding tangents). +std::vector compute_pchip_default_tangents(const std::vector& pts); + +void filament_mixer_lerp(unsigned char r1, unsigned char g1, unsigned char b1, + unsigned char r2, unsigned char g2, unsigned char b2, + float t, + unsigned char* out_r, unsigned char* out_g, unsigned char* out_b); + +void filament_mixer_lerp_float(float r1, float g1, float b1, + float r2, float g2, float b2, + float t, + float* out_r, float* out_g, float* out_b); + +void filament_mixer_lerp_linear_float(float r1, float g1, float b1, + float r2, float g2, float b2, + float t, + float* out_r, float* out_g, float* out_b); + +// Blend two hex colors ("#RRGGBB") by ratio (0.0 ~ 1.0 for color_b). +// Returns "#RRGGBB" string. +std::string blend_color(const std::string& hex_a, const std::string& hex_b, float ratio_b); + +// Blend N hex colors by integer weights using polynomial pigment mixing. +// Pairwise accumulation via filament_mixer_lerp. Returns "#RRGGBB". +std::string blend_color_multi(const std::vector &hex_colors, + const std::vector &weights); + +// Parse comma-separated 1-based component IDs, e.g. "1,3" → {1, 3}. +std::vector parse_mixed_components(const std::string &str); + +// Parse comma-separated ratio values, e.g. "0.7,0.3" → {0.7, 0.3}. +// Returns equal ratios (1/n each) when str is empty or invalid. +// Normalizes so the sum equals 1.0. +std::vector parse_mixed_ratios(const std::string &str, size_t n_components); + +// Returns true if any element in is_mixed is true. +// ConfigOptionBools stores values as std::vector. +bool has_any_mixed_filament(const std::vector &is_mixed); + +// Check which mixed filament slots have broken component references. +// Returns 0-based indices of mixed slots whose components reference +// filaments beyond num_physical (i.e., deleted filaments). +std::vector check_mixed_filament_integrity( + const std::vector &is_mixed, + const std::vector &comp_strs, + size_t num_physical); + +// Expand mixed filament slots in an extruder list to their physical components. +// Input/output are 0-based indices. Non-mixed slots pass through unchanged. +// Result is sorted and deduplicated. +std::vector expand_mixed_filaments( + const std::vector &extruders_0based, + const std::vector &is_mixed, + const std::vector &comp_strs); + +// Remap mixed filament component references after a physical filament is deleted. +// del_1based: the 1-based index of the deleted physical filament. +// For each mixed slot: +// - if component == del_1based -> replace with 0 (sentinel for deleted/unselected) +// - if component > del_1based -> decrement by 1 +void remap_mixed_components_on_delete( + const std::vector &is_mixed, + std::vector &comp_strs, + unsigned int del_1based); + +// Check which mixed filament slots have type-mismatched components. +// filament_types: type strings for physical filaments (0-based, size == num_physical). +// Component IDs in comp_strs are 1-based; the function converts to 0-based to look up types. +// Returns 0-based config indices of mixed slots with mismatched component types. +std::vector check_mixed_filament_type_consistency( + const std::vector &is_mixed, + const std::vector &comp_strs, + const std::vector &filament_types); + +// Expand mixed-slot IDs in geometric unprintable sets to their physical component IDs. +// Each set entry that corresponds to a mixed slot is replaced by the slot's component +// IDs (0-based). Non-mixed entries pass through unchanged. +void expand_mixed_slots_in_unprintables( + std::vector> &unprintables, + const std::vector &is_mixed, + const std::vector &comp_strs); + +// Clear any non-empty gradient-curve slot that parses to fewer than 2 control points. +// Heals per-slot arrays corrupted by the legacy "|" separator collision between +// PresetBundle::export_selections / load_selections (which used "|" as the inter-slot +// delimiter) and serialize_gradient_curve / parse_gradient_curve (which use "|" as the +// intra-slot control-point delimiter). Such a round-trip splits a multi-point curve +// across adjacent slots, leaving single-point entries that fail MakerWorld's strict +// "curve needs >= 2 points" check. Clearing them falls back to the linear range. +void sanitize_mixed_gradient_curve_array(std::vector& vals); + +// Validate mixed-color (混色) parameters. Returns error messages keyed by option name. +// Slot details are included in the message text (1-based slot index). +std::map validate_mixed_filament_params( + const std::vector &is_mixed, + const std::vector &comp_strs, + const std::vector &ratio_strs, + const std::vector &gradient_flags, + const std::vector &gradient_range_strs, + const std::vector &gradient_curve_strs); + +} // namespace Slic3r + +#endif // SLIC3R_FILAMENT_MIXER_HPP diff --git a/src/libslic3r/FilamentMixerModel.hpp b/src/libslic3r/FilamentMixerModel.hpp new file mode 100644 index 0000000000..89b299471b --- /dev/null +++ b/src/libslic3r/FilamentMixerModel.hpp @@ -0,0 +1,819 @@ +/* + * FilamentMixer — Header-only C++ pigment color mixer + * + * Filament mixer implementation using a degree-4 polynomial regression + * trained to approximate Mixbox behavior (Mean Delta-E ~2.07). + * This library does not include Mixbox source code, binaries, or data files. + * + * Usage: + * #include "FilamentMixerModel.hpp" + * + * unsigned char r, g, b; + * filament_mixer::lerp(0, 33, 133, 252, 211, 0, 0.5f, &r, &g, &b); + * // r=47, g=141, b=56 (blue + yellow → green) + * + * No dependencies beyond the C++ standard library. + * + * MIT License + * + * Copyright (c) 2026 Justin Hayes + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +#ifndef FILAMENT_MIXER_MODEL_HPP +#define FILAMENT_MIXER_MODEL_HPP + +#include +#include +#include + +namespace filament_mixer { +namespace detail { + +// BEGIN AUTO-GENERATED COEFFICIENTS +// Auto-generated by scripts/export_poly_coefficients.py +// Do not edit manually. +// Degree-4 polynomial, 330 features, 7 inputs + +static const int POLY_DEGREE = 4; +static const int N_FEATURES = 330; +static const int N_INPUTS = 7; + +static const int POWERS[330][7] = { + {0, 0, 0, 0, 0, 0, 0}, + {1, 0, 0, 0, 0, 0, 0}, + {0, 1, 0, 0, 0, 0, 0}, + {0, 0, 1, 0, 0, 0, 0}, + {0, 0, 0, 1, 0, 0, 0}, + {0, 0, 0, 0, 1, 0, 0}, + {0, 0, 0, 0, 0, 1, 0}, + {0, 0, 0, 0, 0, 0, 1}, + {2, 0, 0, 0, 0, 0, 0}, + {1, 1, 0, 0, 0, 0, 0}, + {1, 0, 1, 0, 0, 0, 0}, + {1, 0, 0, 1, 0, 0, 0}, + {1, 0, 0, 0, 1, 0, 0}, + {1, 0, 0, 0, 0, 1, 0}, + {1, 0, 0, 0, 0, 0, 1}, + {0, 2, 0, 0, 0, 0, 0}, + {0, 1, 1, 0, 0, 0, 0}, + {0, 1, 0, 1, 0, 0, 0}, + {0, 1, 0, 0, 1, 0, 0}, + {0, 1, 0, 0, 0, 1, 0}, + {0, 1, 0, 0, 0, 0, 1}, + {0, 0, 2, 0, 0, 0, 0}, + {0, 0, 1, 1, 0, 0, 0}, + {0, 0, 1, 0, 1, 0, 0}, + {0, 0, 1, 0, 0, 1, 0}, + {0, 0, 1, 0, 0, 0, 1}, + {0, 0, 0, 2, 0, 0, 0}, + {0, 0, 0, 1, 1, 0, 0}, + {0, 0, 0, 1, 0, 1, 0}, + {0, 0, 0, 1, 0, 0, 1}, + {0, 0, 0, 0, 2, 0, 0}, + {0, 0, 0, 0, 1, 1, 0}, + {0, 0, 0, 0, 1, 0, 1}, + {0, 0, 0, 0, 0, 2, 0}, + {0, 0, 0, 0, 0, 1, 1}, + {0, 0, 0, 0, 0, 0, 2}, + {3, 0, 0, 0, 0, 0, 0}, + {2, 1, 0, 0, 0, 0, 0}, + {2, 0, 1, 0, 0, 0, 0}, + {2, 0, 0, 1, 0, 0, 0}, + {2, 0, 0, 0, 1, 0, 0}, + {2, 0, 0, 0, 0, 1, 0}, + {2, 0, 0, 0, 0, 0, 1}, + {1, 2, 0, 0, 0, 0, 0}, + {1, 1, 1, 0, 0, 0, 0}, + {1, 1, 0, 1, 0, 0, 0}, + {1, 1, 0, 0, 1, 0, 0}, + {1, 1, 0, 0, 0, 1, 0}, + {1, 1, 0, 0, 0, 0, 1}, + {1, 0, 2, 0, 0, 0, 0}, + {1, 0, 1, 1, 0, 0, 0}, + {1, 0, 1, 0, 1, 0, 0}, + {1, 0, 1, 0, 0, 1, 0}, + {1, 0, 1, 0, 0, 0, 1}, + {1, 0, 0, 2, 0, 0, 0}, + {1, 0, 0, 1, 1, 0, 0}, + {1, 0, 0, 1, 0, 1, 0}, + {1, 0, 0, 1, 0, 0, 1}, + {1, 0, 0, 0, 2, 0, 0}, + {1, 0, 0, 0, 1, 1, 0}, + {1, 0, 0, 0, 1, 0, 1}, + {1, 0, 0, 0, 0, 2, 0}, + {1, 0, 0, 0, 0, 1, 1}, + {1, 0, 0, 0, 0, 0, 2}, + {0, 3, 0, 0, 0, 0, 0}, + {0, 2, 1, 0, 0, 0, 0}, + {0, 2, 0, 1, 0, 0, 0}, + {0, 2, 0, 0, 1, 0, 0}, + {0, 2, 0, 0, 0, 1, 0}, + {0, 2, 0, 0, 0, 0, 1}, + {0, 1, 2, 0, 0, 0, 0}, + {0, 1, 1, 1, 0, 0, 0}, + {0, 1, 1, 0, 1, 0, 0}, + {0, 1, 1, 0, 0, 1, 0}, + {0, 1, 1, 0, 0, 0, 1}, + {0, 1, 0, 2, 0, 0, 0}, + {0, 1, 0, 1, 1, 0, 0}, + {0, 1, 0, 1, 0, 1, 0}, + {0, 1, 0, 1, 0, 0, 1}, + {0, 1, 0, 0, 2, 0, 0}, + {0, 1, 0, 0, 1, 1, 0}, + {0, 1, 0, 0, 1, 0, 1}, + {0, 1, 0, 0, 0, 2, 0}, + {0, 1, 0, 0, 0, 1, 1}, + {0, 1, 0, 0, 0, 0, 2}, + {0, 0, 3, 0, 0, 0, 0}, + {0, 0, 2, 1, 0, 0, 0}, + {0, 0, 2, 0, 1, 0, 0}, + {0, 0, 2, 0, 0, 1, 0}, + {0, 0, 2, 0, 0, 0, 1}, + {0, 0, 1, 2, 0, 0, 0}, + {0, 0, 1, 1, 1, 0, 0}, + {0, 0, 1, 1, 0, 1, 0}, + {0, 0, 1, 1, 0, 0, 1}, + {0, 0, 1, 0, 2, 0, 0}, + {0, 0, 1, 0, 1, 1, 0}, + {0, 0, 1, 0, 1, 0, 1}, + {0, 0, 1, 0, 0, 2, 0}, + {0, 0, 1, 0, 0, 1, 1}, + {0, 0, 1, 0, 0, 0, 2}, + {0, 0, 0, 3, 0, 0, 0}, + {0, 0, 0, 2, 1, 0, 0}, + {0, 0, 0, 2, 0, 1, 0}, + {0, 0, 0, 2, 0, 0, 1}, + {0, 0, 0, 1, 2, 0, 0}, + {0, 0, 0, 1, 1, 1, 0}, + {0, 0, 0, 1, 1, 0, 1}, + {0, 0, 0, 1, 0, 2, 0}, + {0, 0, 0, 1, 0, 1, 1}, + {0, 0, 0, 1, 0, 0, 2}, + {0, 0, 0, 0, 3, 0, 0}, + {0, 0, 0, 0, 2, 1, 0}, + {0, 0, 0, 0, 2, 0, 1}, + {0, 0, 0, 0, 1, 2, 0}, + {0, 0, 0, 0, 1, 1, 1}, + {0, 0, 0, 0, 1, 0, 2}, + {0, 0, 0, 0, 0, 3, 0}, + {0, 0, 0, 0, 0, 2, 1}, + {0, 0, 0, 0, 0, 1, 2}, + {0, 0, 0, 0, 0, 0, 3}, + {4, 0, 0, 0, 0, 0, 0}, + {3, 1, 0, 0, 0, 0, 0}, + {3, 0, 1, 0, 0, 0, 0}, + {3, 0, 0, 1, 0, 0, 0}, + {3, 0, 0, 0, 1, 0, 0}, + {3, 0, 0, 0, 0, 1, 0}, + {3, 0, 0, 0, 0, 0, 1}, + {2, 2, 0, 0, 0, 0, 0}, + {2, 1, 1, 0, 0, 0, 0}, + {2, 1, 0, 1, 0, 0, 0}, + {2, 1, 0, 0, 1, 0, 0}, + {2, 1, 0, 0, 0, 1, 0}, + {2, 1, 0, 0, 0, 0, 1}, + {2, 0, 2, 0, 0, 0, 0}, + {2, 0, 1, 1, 0, 0, 0}, + {2, 0, 1, 0, 1, 0, 0}, + {2, 0, 1, 0, 0, 1, 0}, + {2, 0, 1, 0, 0, 0, 1}, + {2, 0, 0, 2, 0, 0, 0}, + {2, 0, 0, 1, 1, 0, 0}, + {2, 0, 0, 1, 0, 1, 0}, + {2, 0, 0, 1, 0, 0, 1}, + {2, 0, 0, 0, 2, 0, 0}, + {2, 0, 0, 0, 1, 1, 0}, + {2, 0, 0, 0, 1, 0, 1}, + {2, 0, 0, 0, 0, 2, 0}, + {2, 0, 0, 0, 0, 1, 1}, + {2, 0, 0, 0, 0, 0, 2}, + {1, 3, 0, 0, 0, 0, 0}, + {1, 2, 1, 0, 0, 0, 0}, + {1, 2, 0, 1, 0, 0, 0}, + {1, 2, 0, 0, 1, 0, 0}, + {1, 2, 0, 0, 0, 1, 0}, + {1, 2, 0, 0, 0, 0, 1}, + {1, 1, 2, 0, 0, 0, 0}, + {1, 1, 1, 1, 0, 0, 0}, + {1, 1, 1, 0, 1, 0, 0}, + {1, 1, 1, 0, 0, 1, 0}, + {1, 1, 1, 0, 0, 0, 1}, + {1, 1, 0, 2, 0, 0, 0}, + {1, 1, 0, 1, 1, 0, 0}, + {1, 1, 0, 1, 0, 1, 0}, + {1, 1, 0, 1, 0, 0, 1}, + {1, 1, 0, 0, 2, 0, 0}, + {1, 1, 0, 0, 1, 1, 0}, + {1, 1, 0, 0, 1, 0, 1}, + {1, 1, 0, 0, 0, 2, 0}, + {1, 1, 0, 0, 0, 1, 1}, + {1, 1, 0, 0, 0, 0, 2}, + {1, 0, 3, 0, 0, 0, 0}, + {1, 0, 2, 1, 0, 0, 0}, + {1, 0, 2, 0, 1, 0, 0}, + {1, 0, 2, 0, 0, 1, 0}, + {1, 0, 2, 0, 0, 0, 1}, + {1, 0, 1, 2, 0, 0, 0}, + {1, 0, 1, 1, 1, 0, 0}, + {1, 0, 1, 1, 0, 1, 0}, + {1, 0, 1, 1, 0, 0, 1}, + {1, 0, 1, 0, 2, 0, 0}, + {1, 0, 1, 0, 1, 1, 0}, + {1, 0, 1, 0, 1, 0, 1}, + {1, 0, 1, 0, 0, 2, 0}, + {1, 0, 1, 0, 0, 1, 1}, + {1, 0, 1, 0, 0, 0, 2}, + {1, 0, 0, 3, 0, 0, 0}, + {1, 0, 0, 2, 1, 0, 0}, + {1, 0, 0, 2, 0, 1, 0}, + {1, 0, 0, 2, 0, 0, 1}, + {1, 0, 0, 1, 2, 0, 0}, + {1, 0, 0, 1, 1, 1, 0}, + {1, 0, 0, 1, 1, 0, 1}, + {1, 0, 0, 1, 0, 2, 0}, + {1, 0, 0, 1, 0, 1, 1}, + {1, 0, 0, 1, 0, 0, 2}, + {1, 0, 0, 0, 3, 0, 0}, + {1, 0, 0, 0, 2, 1, 0}, + {1, 0, 0, 0, 2, 0, 1}, + {1, 0, 0, 0, 1, 2, 0}, + {1, 0, 0, 0, 1, 1, 1}, + {1, 0, 0, 0, 1, 0, 2}, + {1, 0, 0, 0, 0, 3, 0}, + {1, 0, 0, 0, 0, 2, 1}, + {1, 0, 0, 0, 0, 1, 2}, + {1, 0, 0, 0, 0, 0, 3}, + {0, 4, 0, 0, 0, 0, 0}, + {0, 3, 1, 0, 0, 0, 0}, + {0, 3, 0, 1, 0, 0, 0}, + {0, 3, 0, 0, 1, 0, 0}, + {0, 3, 0, 0, 0, 1, 0}, + {0, 3, 0, 0, 0, 0, 1}, + {0, 2, 2, 0, 0, 0, 0}, + {0, 2, 1, 1, 0, 0, 0}, + {0, 2, 1, 0, 1, 0, 0}, + {0, 2, 1, 0, 0, 1, 0}, + {0, 2, 1, 0, 0, 0, 1}, + {0, 2, 0, 2, 0, 0, 0}, + {0, 2, 0, 1, 1, 0, 0}, + {0, 2, 0, 1, 0, 1, 0}, + {0, 2, 0, 1, 0, 0, 1}, + {0, 2, 0, 0, 2, 0, 0}, + {0, 2, 0, 0, 1, 1, 0}, + {0, 2, 0, 0, 1, 0, 1}, + {0, 2, 0, 0, 0, 2, 0}, + {0, 2, 0, 0, 0, 1, 1}, + {0, 2, 0, 0, 0, 0, 2}, + {0, 1, 3, 0, 0, 0, 0}, + {0, 1, 2, 1, 0, 0, 0}, + {0, 1, 2, 0, 1, 0, 0}, + {0, 1, 2, 0, 0, 1, 0}, + {0, 1, 2, 0, 0, 0, 1}, + {0, 1, 1, 2, 0, 0, 0}, + {0, 1, 1, 1, 1, 0, 0}, + {0, 1, 1, 1, 0, 1, 0}, + {0, 1, 1, 1, 0, 0, 1}, + {0, 1, 1, 0, 2, 0, 0}, + {0, 1, 1, 0, 1, 1, 0}, + {0, 1, 1, 0, 1, 0, 1}, + {0, 1, 1, 0, 0, 2, 0}, + {0, 1, 1, 0, 0, 1, 1}, + {0, 1, 1, 0, 0, 0, 2}, + {0, 1, 0, 3, 0, 0, 0}, + {0, 1, 0, 2, 1, 0, 0}, + {0, 1, 0, 2, 0, 1, 0}, + {0, 1, 0, 2, 0, 0, 1}, + {0, 1, 0, 1, 2, 0, 0}, + {0, 1, 0, 1, 1, 1, 0}, + {0, 1, 0, 1, 1, 0, 1}, + {0, 1, 0, 1, 0, 2, 0}, + {0, 1, 0, 1, 0, 1, 1}, + {0, 1, 0, 1, 0, 0, 2}, + {0, 1, 0, 0, 3, 0, 0}, + {0, 1, 0, 0, 2, 1, 0}, + {0, 1, 0, 0, 2, 0, 1}, + {0, 1, 0, 0, 1, 2, 0}, + {0, 1, 0, 0, 1, 1, 1}, + {0, 1, 0, 0, 1, 0, 2}, + {0, 1, 0, 0, 0, 3, 0}, + {0, 1, 0, 0, 0, 2, 1}, + {0, 1, 0, 0, 0, 1, 2}, + {0, 1, 0, 0, 0, 0, 3}, + {0, 0, 4, 0, 0, 0, 0}, + {0, 0, 3, 1, 0, 0, 0}, + {0, 0, 3, 0, 1, 0, 0}, + {0, 0, 3, 0, 0, 1, 0}, + {0, 0, 3, 0, 0, 0, 1}, + {0, 0, 2, 2, 0, 0, 0}, + {0, 0, 2, 1, 1, 0, 0}, + {0, 0, 2, 1, 0, 1, 0}, + {0, 0, 2, 1, 0, 0, 1}, + {0, 0, 2, 0, 2, 0, 0}, + {0, 0, 2, 0, 1, 1, 0}, + {0, 0, 2, 0, 1, 0, 1}, + {0, 0, 2, 0, 0, 2, 0}, + {0, 0, 2, 0, 0, 1, 1}, + {0, 0, 2, 0, 0, 0, 2}, + {0, 0, 1, 3, 0, 0, 0}, + {0, 0, 1, 2, 1, 0, 0}, + {0, 0, 1, 2, 0, 1, 0}, + {0, 0, 1, 2, 0, 0, 1}, + {0, 0, 1, 1, 2, 0, 0}, + {0, 0, 1, 1, 1, 1, 0}, + {0, 0, 1, 1, 1, 0, 1}, + {0, 0, 1, 1, 0, 2, 0}, + {0, 0, 1, 1, 0, 1, 1}, + {0, 0, 1, 1, 0, 0, 2}, + {0, 0, 1, 0, 3, 0, 0}, + {0, 0, 1, 0, 2, 1, 0}, + {0, 0, 1, 0, 2, 0, 1}, + {0, 0, 1, 0, 1, 2, 0}, + {0, 0, 1, 0, 1, 1, 1}, + {0, 0, 1, 0, 1, 0, 2}, + {0, 0, 1, 0, 0, 3, 0}, + {0, 0, 1, 0, 0, 2, 1}, + {0, 0, 1, 0, 0, 1, 2}, + {0, 0, 1, 0, 0, 0, 3}, + {0, 0, 0, 4, 0, 0, 0}, + {0, 0, 0, 3, 1, 0, 0}, + {0, 0, 0, 3, 0, 1, 0}, + {0, 0, 0, 3, 0, 0, 1}, + {0, 0, 0, 2, 2, 0, 0}, + {0, 0, 0, 2, 1, 1, 0}, + {0, 0, 0, 2, 1, 0, 1}, + {0, 0, 0, 2, 0, 2, 0}, + {0, 0, 0, 2, 0, 1, 1}, + {0, 0, 0, 2, 0, 0, 2}, + {0, 0, 0, 1, 3, 0, 0}, + {0, 0, 0, 1, 2, 1, 0}, + {0, 0, 0, 1, 2, 0, 1}, + {0, 0, 0, 1, 1, 2, 0}, + {0, 0, 0, 1, 1, 1, 1}, + {0, 0, 0, 1, 1, 0, 2}, + {0, 0, 0, 1, 0, 3, 0}, + {0, 0, 0, 1, 0, 2, 1}, + {0, 0, 0, 1, 0, 1, 2}, + {0, 0, 0, 1, 0, 0, 3}, + {0, 0, 0, 0, 4, 0, 0}, + {0, 0, 0, 0, 3, 1, 0}, + {0, 0, 0, 0, 3, 0, 1}, + {0, 0, 0, 0, 2, 2, 0}, + {0, 0, 0, 0, 2, 1, 1}, + {0, 0, 0, 0, 2, 0, 2}, + {0, 0, 0, 0, 1, 3, 0}, + {0, 0, 0, 0, 1, 2, 1}, + {0, 0, 0, 0, 1, 1, 2}, + {0, 0, 0, 0, 1, 0, 3}, + {0, 0, 0, 0, 0, 4, 0}, + {0, 0, 0, 0, 0, 3, 1}, + {0, 0, 0, 0, 0, 2, 2}, + {0, 0, 0, 0, 0, 1, 3}, + {0, 0, 0, 0, 0, 0, 4} +}; + +static const double COEF[330][3] = { + {8.70954844857314666e-12, 1.27926950848359881e-09, -2.06865474316332923e-09}, + {1.05783308354771544e+00, -8.02119209663359686e-03, -7.88705651445470723e-02}, + {1.35905954452774837e-02, 8.71267975138422468e-01, 1.04898760410704936e-01}, + {-4.16452026099768252e-02, 1.75465381596434100e-02, 1.00224594702931546e+00}, + {4.50321316661211821e-02, -7.11409155427628892e-02, 3.91232300778902690e-03}, + {1.76675507851922452e-02, -1.32709276116036640e-01, 6.36935270589509828e-02}, + {-5.23434830565911030e-02, 3.77681739012521722e-02, -2.08691145087504179e-02}, + {-2.33722556520224792e-03, -1.57542611462692145e-03, -3.05158628452478807e-03}, + {-8.87678609044812990e-04, 3.83194388837734693e-04, 1.37779212442523083e-03}, + {-2.11519042076831979e-03, 5.82337362515735358e-04, 2.24055108941204821e-04}, + {4.61545125563611917e-04, 7.72869451707915893e-04, -1.10800630143346882e-03}, + {1.05937484157345879e-03, -3.14448681732842211e-04, -1.75129182446198098e-03}, + {1.49045689016363055e-03, -2.09220860101674106e-04, 5.93100338908187697e-04}, + {-3.51246656293852696e-04, -8.20743017485394289e-04, 5.71854064480802862e-04}, + {-9.18204643629581319e-01, -2.27788122702773155e-01, 6.39980793022790623e-02}, + {9.24243491377523679e-05, 7.32841332381495400e-04, -1.55219718415109450e-03}, + {7.13695056804217989e-04, -8.46467621879685712e-05, 6.50202947442505750e-04}, + {1.66640864747485983e-03, -1.24492362771216523e-04, 2.68236502346156410e-04}, + {-7.20253644860527516e-04, 7.81434220384157334e-04, 1.12661089007361367e-03}, + {-6.83033334365238206e-05, 7.27742627159490762e-04, -1.78048843835204584e-03}, + {-3.13431571993316588e-02, -8.57604034845650287e-01, -2.57225920656276863e-01}, + {-6.47867200595898341e-05, -1.16688982572457655e-03, 1.14174511750260031e-03}, + {-5.00713925613324338e-04, -6.87598082111323477e-04, 6.20598069880440176e-04}, + {-8.56716727659588957e-05, 9.74478786593559361e-04, -1.65892838405139512e-03}, + {6.53468478750158263e-04, 7.51662000672516676e-04, -6.73196326298856570e-04}, + {-4.42539011000103941e-02, -2.01965359697350230e-02, -9.94663493761314355e-01}, + {-7.39107395392403087e-04, 5.28870828612476996e-04, 1.00947183860234540e-03}, + {-2.06577300933763214e-03, 9.60215813758718011e-04, -3.27993888180819421e-04}, + {3.47783280638377555e-04, 8.41824316850705743e-04, -8.87458944147930993e-04}, + {1.20960551709587905e+00, -7.07660818059813873e-02, -8.56332806008946491e-03}, + {2.11116509318935269e-04, 7.68490846994171776e-04, -1.63228995491542417e-03}, + {6.47698075356516103e-04, -4.20589129268072884e-04, 1.18354001300614896e-03}, + {-2.78795945253848716e-02, 1.22199201000304547e+00, -2.07383075858847743e-01}, + {-5.32457386680677347e-05, -9.58027320315790677e-04, 9.89667309649038679e-04}, + {-9.03932426306289782e-02, -4.00969232187064692e-02, 1.26285611182120072e+00}, + {-2.19453630740322871e-03, -1.21893190049422620e-03, -1.92293368093085417e-03}, + {1.72950845415964505e-06, -8.93952511560151819e-09, -6.14874900641340649e-06}, + {8.02644554976326974e-06, -6.42543741723487294e-06, -6.07103419227907060e-06}, + {3.20307552755319525e-06, -4.83533743093466500e-06, 9.13563764113473065e-07}, + {-2.18105804067510178e-06, 6.19595552598436322e-07, 5.21392855381760945e-06}, + {-2.43310123604345563e-06, 2.17201813434465818e-06, 1.94098874242362718e-07}, + {-1.56293672065252465e-06, 3.95256011818110372e-06, 1.68792962079201969e-06}, + {-1.37567295252127852e-03, 3.59746071987262106e-04, 7.38927139000157259e-05}, + {4.27822004137219658e-06, -8.80187479967658548e-07, 2.29453131891411977e-06}, + {7.68758937964332534e-06, 2.40909410585557829e-07, 4.69351234070854509e-06}, + {-2.87166709944317033e-06, 7.60223902901142716e-07, 4.57864913314467992e-06}, + {-4.01295140267654560e-06, 2.65929275888376483e-06, -2.36575067819565221e-06}, + {2.32693030513910805e-07, 2.28814396769890308e-06, 1.83526107699893970e-07}, + {-2.18213927011287265e-03, 1.65013083920367864e-03, 2.31992998847323087e-04}, + {-7.70829764693697905e-06, 4.23888841240673345e-07, 7.30018322002944087e-06}, + {-1.23111329452911533e-06, 1.50076529718910084e-06, -1.91139744928209288e-06}, + {-1.68872756433485760e-06, 1.03254236824697979e-06, -1.72081108163607555e-06}, + {1.64276928199709460e-06, -4.96350219553231067e-07, -1.46349385185670297e-06}, + {1.12731767057843682e-03, 5.03104281148445223e-04, 1.36398977654308994e-03}, + {-1.05449609518089293e-06, -4.06952115309007489e-07, 3.53062441379482783e-06}, + {-1.98745923822574166e-06, 4.98021943693208180e-07, 3.92645061370218429e-06}, + {-1.55569377977005097e-07, -4.00262856484093037e-07, -2.49609122397048688e-06}, + {2.18005022830924673e-03, -4.10275057064835439e-05, -2.59776311836759947e-04}, + {5.41337439827552225e-07, -1.88603932528607146e-06, -2.06428606152470051e-06}, + {-6.03243799807140491e-06, -3.75067864464502022e-06, -3.05702776851046742e-06}, + {2.30038011634901016e-03, -1.32581161861259635e-03, -1.07680096899188406e-03}, + {4.46773877910556887e-06, 1.85008408528524772e-08, -2.72851357570281713e-06}, + {-1.49177636513049289e-03, -1.91426739654176659e-04, -1.71206384332753194e-03}, + {2.31661325589237743e-02, 2.26540538563063554e-01, 5.42330337046266139e-02}, + {-1.40563059963100256e-06, -4.50551806294901061e-06, 8.87542894832671347e-06}, + {-1.66780916452391459e-06, 4.12065434881171526e-06, -3.55865035776836702e-06}, + {2.71536622051954390e-07, -3.08564858926584692e-06, -1.52164363662402047e-06}, + {2.66659632027280158e-06, -1.19436686895073481e-06, -3.25738306279285683e-06}, + {-1.43666282346327501e-06, -2.51923473623639690e-06, 5.21205120344175876e-06}, + {2.82954522469612199e-04, -1.59147454710008968e-03, 1.27685773978167098e-03}, + {-3.99471240294241303e-06, 9.97323772325767188e-08, -5.28196823261495307e-06}, + {-6.39858432699424995e-06, -4.59897864440506933e-06, -2.39736149785715891e-06}, + {2.89457420106498109e-06, -3.10427512149489757e-06, 9.75553221437691631e-07}, + {-8.96518259720091581e-07, -5.53996694461914366e-06, 1.03733964032237669e-05}, + {8.82130497168875905e-04, -2.33618402105562365e-03, 1.35100410641244379e-03}, + {-2.14088521029685841e-06, 2.59005410360388117e-06, -9.78713171504927426e-08}, + {-4.50668337071552516e-06, 3.58808570076458002e-06, -1.56159349007541082e-06}, + {-1.52345101244247272e-06, 2.21066768791959578e-06, -2.19555898547246775e-06}, + {2.07334042074768356e-03, -1.56333498489329517e-03, -5.53762940364141767e-04}, + {2.22151748134440108e-06, -4.74729938900429749e-07, -3.46744150304684889e-06}, + {2.95389009221172505e-06, -2.96312023445686329e-06, -9.00385068308695580e-07}, + {-6.47780848348620771e-04, 2.38772263398574292e-03, -8.93908589731968019e-04}, + {9.69501567645025819e-07, 2.41432205872957328e-06, 5.56908291093893837e-07}, + {-6.33392066185247586e-04, 2.38613844267241120e-03, -1.05383725637261472e-03}, + {6.76250135616376785e-02, -5.57799579151454852e-02, 1.83393652374666566e-01}, + {3.53986894266120067e-06, 5.92996717102502093e-06, -7.32378536156402804e-06}, + {5.69667193362453916e-06, 1.20219201908705218e-06, -4.56663805956276925e-06}, + {7.11494218295222192e-07, 2.93069858359131137e-06, 1.23210839732268429e-07}, + {-3.41917893741799928e-06, -1.47435291776966751e-06, 1.07397354370819542e-06}, + {7.30931882734254710e-04, 1.15433149094644884e-03, -2.40026982569019722e-03}, + {-1.22780859907432871e-06, 2.29287908084027789e-06, 1.84270754640877832e-06}, + {7.71579140080615178e-07, 2.92378122615943208e-06, -1.91800935486416413e-07}, + {-3.76107279903559188e-07, -1.83159743461489867e-06, 8.17089655984204466e-07}, + {-1.10830882430058061e-03, -5.10908079549339251e-04, -1.77835176235151705e-03}, + {-1.26839781743699406e-06, -2.86942252006448415e-06, 4.47464983859263005e-06}, + {-1.44518716284694482e-06, -7.03360635528004451e-06, 1.04898109513258675e-05}, + {-4.98687888007460470e-04, 1.86990180752567262e-03, -1.24341018156770089e-03}, + {-2.90479801332704790e-06, -9.24272269110706229e-07, 7.56354222045119151e-07}, + {-1.16451534008294149e-03, -2.34216801827852273e-03, 4.91479264672447288e-03}, + {-7.70970926241258958e-02, 9.35855573900774423e-02, 1.50623807158846906e-01}, + {1.14039905307547484e-06, -1.80664235182388840e-07, -5.15527441317074897e-06}, + {7.50559587697416375e-06, -6.23982034686780714e-06, -5.01245198064126721e-06}, + {2.37840954889385892e-06, -4.15663063190341991e-06, 1.93118829429697603e-06}, + {-1.54903048110950777e-03, 2.65832194444263125e-04, 5.34401520444913940e-04}, + {4.00040634507183718e-06, -2.43965474694277443e-06, 2.88683251413283937e-06}, + {7.72301916160400559e-06, -9.54300275625495457e-07, 5.50777546561020959e-06}, + {-2.28103126593574368e-03, 1.02658341009706066e-03, 1.22010567464172614e-03}, + {-6.32818026002207601e-06, 9.83088209200334157e-07, 5.24316808343458507e-06}, + {1.37175660779395581e-03, 4.01188715721313943e-04, 7.59370199245276625e-04}, + {-3.33184694847917573e-01, 7.82846225823195241e-02, -9.94270054263078074e-02}, + {-1.70108770909324636e-06, -5.10749831734438279e-06, 9.80267482880020635e-06}, + {-1.79301365419055891e-06, 4.44839673308561508e-06, -3.83837422072638712e-06}, + {1.71911692904483371e-04, -1.56077480341044431e-03, 1.30725115579017584e-03}, + {-3.55763938679129477e-06, 1.20558966207589408e-06, -5.94340114624253291e-06}, + {1.02325453537648178e-03, -1.52640960762801372e-03, 3.10973117856692537e-04}, + {3.81842873295820109e-03, -3.02114884453467680e-01, 2.78264587142456665e-01}, + {3.46123498726202961e-06, 5.05929187103208375e-06, -6.85764673719752027e-06}, + {4.47228353489932293e-04, 9.60672217798415784e-04, -2.19382758010531077e-03}, + {2.22711833124298791e-01, -4.14141995162802465e-02, -4.27998216564745015e-01}, + {-1.78271151817048783e-03, -9.81039111371464307e-04, -1.37513011841553174e-03}, + {3.35305394560947434e-10, -1.26710751613412498e-09, 3.54248685940916630e-09}, + {-9.26917423371698135e-09, 6.21190912597491263e-09, 1.86942252233812667e-08}, + {-1.56687696151180944e-09, -5.44315731376698864e-09, 1.93822974337010123e-09}, + {7.52897716393974292e-10, -3.48923168136394679e-10, -5.94217786087369859e-10}, + {2.52116855170569920e-10, -2.48216903975251313e-09, 1.01699001303634518e-09}, + {3.72215577457146729e-09, 4.51910314724912610e-10, -6.15361639422218332e-09}, + {-2.62088816666700142e-07, 3.23631086683010168e-07, 8.85302852722882894e-07}, + {-1.30537319842360944e-08, 1.46808588619151692e-08, 2.67574040702101001e-09}, + {-1.23991327621864045e-08, 2.61298349069072344e-08, -4.58919307373337193e-09}, + {5.03079244928983371e-09, -6.73783119575777079e-10, -1.13935871848269699e-08}, + {9.09065785148488459e-09, -1.04304054004966673e-08, -3.23123813816827976e-09}, + {9.55627910137479830e-10, -1.41129563591135820e-08, -1.75594400131373618e-09}, + {-1.05549669436946769e-07, 8.47284096194811896e-08, 6.70761880091491625e-07}, + {-5.92079330008488114e-10, 6.31702118392141188e-09, -4.51534448719925763e-09}, + {-1.04033970327321867e-09, 4.67775485013532943e-09, 2.79348504744758586e-09}, + {5.38758108958869997e-09, -9.55380699552144108e-09, 6.16488249338686956e-11}, + {1.12057409185073453e-09, -3.00645183748393663e-09, -2.14940637510707688e-09}, + {-6.27004681934967278e-07, 8.59159786402940127e-07, 2.73192537668387470e-07}, + {7.36784189214745311e-10, -8.12761968838060511e-10, -2.43226564583531868e-09}, + {1.25546123497244366e-09, -6.98609614602219153e-10, -5.29894812750786315e-09}, + {-8.88351475714088679e-10, 1.37132565025677167e-09, 1.92497813869541012e-09}, + {6.10992637326349119e-07, -6.13496367368217277e-07, -2.19901889726877020e-06}, + {-8.59090437677068053e-11, 2.72772732179404898e-09, 1.54554039011323141e-09}, + {-4.58798915525804318e-10, 4.54384851966693759e-09, 3.63189350816028877e-09}, + {9.93115786933340683e-08, 1.63700862245048928e-07, -1.71397937400244449e-07}, + {-1.62985361318312982e-09, -3.10762126448649312e-09, 1.76193495557419588e-09}, + {6.27207737564569601e-07, -1.49343052365004934e-06, 8.16168870109573730e-08}, + {1.42518738380244172e-03, -3.47531891583186285e-04, -2.98661838800559913e-04}, + {8.98157254125564464e-09, -8.24242643235328920e-09, -5.34769730234363472e-09}, + {-2.17776999489327494e-08, -4.47141107473569832e-09, -1.10218517090920898e-08}, + {3.19614509858290319e-09, -3.32861183754973311e-09, 9.92016746526047655e-11}, + {-2.91660393059167689e-09, 5.59829099744391101e-09, 1.70080685646389895e-09}, + {1.22479524179014421e-09, 9.20737683318684219e-09, -1.10618757209746121e-10}, + {7.70594587548882257e-09, -1.33267446898667659e-06, 4.52812675308736368e-07}, + {9.46080642993951670e-09, -1.95483249032513129e-08, -1.23592694620255905e-08}, + {-2.02330094345448686e-09, 1.18198534293512125e-10, 2.34746776184291406e-09}, + {4.00839940406516604e-09, -4.80716730311137042e-09, 5.25802457129742606e-09}, + {-2.53115202408782380e-09, 2.05563177591017165e-10, 5.46003270374129102e-09}, + {3.24841319972028232e-08, -1.24284705839720552e-06, 4.97326549863015555e-07}, + {1.37729661009444726e-09, -1.67903983772088594e-09, -5.62083748989472554e-09}, + {-3.53256937590806785e-10, 4.49320892992322030e-09, -4.02300486673778934e-09}, + {2.48976475547557641e-09, -6.97256366533061112e-09, 1.43185084622299286e-09}, + {-4.38617299338556199e-09, 9.45081248826811111e-08, -2.91197460585562728e-07}, + {3.24429103026879773e-09, -1.71647943601749287e-09, 2.71076100455402980e-09}, + {3.86933235105302309e-09, -2.82628156988984358e-09, 8.24455756442965537e-09}, + {-7.46614068323353530e-07, 1.27696340529665289e-06, 6.88413034833322557e-07}, + {-5.78118683480788320e-09, 1.34319005917760137e-09, -1.15898873831454807e-09}, + {4.42686972671260670e-07, 6.41810588767341775e-07, -1.16058405342719939e-08}, + {2.24399192788231686e-03, -1.35129336477888174e-03, -7.39944244498236844e-04}, + {7.47869199901884940e-09, -2.68762612165573955e-09, -7.41584788022109365e-09}, + {1.80867308283150230e-09, -2.21500551234043996e-09, 1.86995768869380186e-09}, + {-5.05514829302056157e-09, 4.74048706539109688e-09, 2.52998993977016085e-09}, + {1.32441967115592973e-09, 5.70339246663831290e-09, 7.13448300437846683e-10}, + {1.19767475292940212e-06, 6.72445227582811568e-07, -1.97500319605841551e-06}, + {-1.70612399208458498e-09, 1.07145120553653328e-09, 1.73225882249550267e-09}, + {1.15369127445807962e-09, -5.80362996549510513e-09, 9.33515653667171819e-10}, + {3.38692740520230018e-09, 3.72531013675958533e-09, -3.18062756687886861e-09}, + {1.14787653780236421e-06, -1.84917201319622368e-06, -2.44834286920736499e-07}, + {1.45558928799083276e-09, 1.12720083267348059e-09, 9.00940544390493869e-10}, + {2.09654001104286891e-09, 4.92913422578400429e-09, 3.04938074791039071e-10}, + {3.54033623213741155e-07, 1.07259516691213860e-06, -6.03027205987524684e-07}, + {-2.72038239157446071e-09, -1.60070143945256760e-09, 6.03853855807301443e-10}, + {-2.03235662485238069e-06, -1.03151962834260348e-06, 1.99637918628457062e-06}, + {-1.26261175077493210e-03, -4.98503988506484859e-04, -1.03875859619143593e-03}, + {6.43182729298530376e-10, 8.01776645076301975e-10, -1.83589794755523172e-09}, + {4.01805119037978997e-09, -5.63673552278487477e-10, -1.09102650663883693e-08}, + {-1.48648961195707585e-09, 5.01067861508053269e-09, 2.99132781045319263e-09}, + {-8.91404754824534629e-07, 7.49163968581634775e-07, 2.12542215183124383e-06}, + {2.38642574451608525e-09, -3.47605810802065207e-09, 3.86935566920598717e-10}, + {-2.80031986488182838e-09, -4.25160427697246490e-11, 2.24182921879090280e-09}, + {-1.26991357818351247e-07, -1.45348284568834647e-07, 5.68792533226815389e-07}, + {1.39227229745131353e-09, -1.84849578699353145e-09, 2.24967258190267305e-09}, + {-1.15462500328497586e-06, 1.84347590761761086e-06, 3.64918716654494962e-07}, + {-2.09357112083411985e-03, 1.60820400301404873e-05, 2.27418117008655948e-04}, + {-1.04484803378768198e-08, 4.86043558178828050e-09, 2.00996588123336650e-09}, + {1.44040971927772432e-08, 1.42223015309195233e-09, 1.99778974613318283e-09}, + {-1.62414574166394599e-07, -1.31976785339561840e-06, 4.43918084507000099e-07}, + {3.73061943836905385e-09, 1.00036822436866402e-08, -1.05450977117005351e-09}, + {-2.06551932971539565e-07, -9.72167971235462190e-07, 4.28861904300768815e-07}, + {-2.16051814014425313e-03, 1.48780488507118812e-03, 7.79940397419977911e-04}, + {-4.80544204428667854e-09, -1.09870773590259319e-09, 6.58876991984844174e-09}, + {1.31575045692056136e-06, 4.32430764481131318e-07, -1.55255090541518703e-06}, + {1.28823975640215602e-03, 4.04521283440268135e-04, 1.76186984141882253e-03}, + {-1.09767251093991436e-01, -4.94112205838347640e-02, -5.43102978164306804e-02}, + {7.93691223854864347e-10, 1.54639511196208446e-08, -1.71518303448969789e-08}, + {2.56523843833456056e-09, -2.31047392329486456e-09, -4.29758133398648601e-09}, + {-9.87725901069325118e-09, 4.28127375218245732e-09, 2.02888056355376989e-09}, + {3.21762172461603768e-10, -5.82937505211322815e-09, 3.88293127512318037e-09}, + {1.63250610252241302e-09, -7.02161705168347083e-09, 3.46592492032893329e-09}, + {-1.44272117683086343e-07, -4.40408510988914148e-07, 5.92746408872857344e-07}, + {2.71961467235293242e-09, -1.47466668633244868e-08, 2.89637452632884873e-08}, + {1.47637712476396399e-08, 1.16406781783262581e-09, 2.04904540557215853e-09}, + {-5.53709807865621073e-09, 7.05512286092169205e-09, 1.56159114805820565e-09}, + {5.29268649740455288e-09, 2.10616986628942016e-08, -3.03219004488264332e-08}, + {1.79978890693655025e-07, 7.95085399132693105e-07, -4.78366567607801940e-07}, + {-4.03847393894152251e-10, 2.90357085597214848e-09, 1.12992165623992946e-09}, + {2.99031871486832301e-09, -1.37951879780606745e-09, 2.41048263988075107e-09}, + {1.26882357398550027e-09, 1.30631467101793852e-09, 7.99574240151201820e-10}, + {-1.41169562567489137e-08, 1.27148955713198356e-06, -2.89386439707162157e-07}, + {-2.68794415198003733e-09, 8.73673404455654889e-10, 2.89557382238125882e-09}, + {-4.90264437380538709e-09, 1.89207244316591527e-09, 2.25393465003165261e-09}, + {-3.58274654665979853e-08, 2.91386646529383231e-07, -4.98477764412919022e-08}, + {1.65722165851311942e-09, -1.11673743863338615e-09, -4.14131162695952071e-09}, + {-1.47751280626939874e-07, -2.41471865000848773e-07, -8.53552350049691100e-07}, + {-2.24352957583577790e-04, 1.60900273524284708e-03, -1.32260753549593617e-03}, + {2.05497643901431104e-09, 1.38702982710459111e-08, -3.09887516689033582e-09}, + {3.39770491949997755e-09, 9.41613393506957053e-09, -7.09844738544518350e-10}, + {7.86209687630989862e-10, 1.93556837224662104e-10, -6.58630930350234678e-09}, + {-6.86841181152253455e-10, -5.57194149153339424e-09, 1.41214109156129197e-09}, + {2.59516074158083754e-07, 1.30703181255419770e-06, -4.02454784192984860e-07}, + {-5.79425202262839889e-10, 4.05071760856134944e-09, 3.02384985106929349e-09}, + {4.00677924866643664e-09, -2.25614611715219127e-09, 7.52819043214891792e-09}, + {2.34003759425061020e-09, 5.27462258592681366e-09, -2.05723854618256041e-10}, + {2.29340174767722615e-07, 1.05507868574435809e-06, -4.45904844964539748e-07}, + {-3.91634245866523401e-09, 1.07849931763048801e-09, 1.85542686770290288e-09}, + {-6.62166513287765213e-09, 3.86355018811013196e-09, -1.87861701195224384e-09}, + {1.32112240848469842e-07, 4.39339645861430705e-08, -1.59384598983486336e-06}, + {2.02488462108796341e-09, -1.48427112267590644e-09, -4.32055485832805175e-09}, + {-4.27701540045566375e-07, -1.46229443391283215e-06, -2.38186369433401879e-07}, + {-9.86744509368740232e-04, 1.91104095070606826e-03, -8.17774843405986713e-04}, + {2.06891823117949514e-10, -2.64060942556376688e-09, 1.86419366055012858e-09}, + {8.33785634979378187e-09, -1.00697171434571686e-08, -2.84106664583116952e-09}, + {5.07057938692323518e-09, -9.56246298811080919e-09, -6.33399999117045809e-11}, + {-6.78808357162941078e-08, -2.21612941845184680e-07, 9.42031624998063144e-08}, + {-3.04300065007145903e-09, 5.64120231083542478e-09, 1.65718606892628628e-09}, + {3.76240642807612602e-09, -4.58941407446844529e-09, 5.06162500801821125e-09}, + {7.25149885354159363e-07, -1.18149759075966698e-06, -6.82406347277120240e-07}, + {-4.84358128605144600e-09, 4.56893046833772853e-09, 2.67044331092591847e-09}, + {-2.54939737986958903e-07, -1.06106228658746360e-06, 5.04013386790069795e-07}, + {-2.17097468872509735e-03, 1.41624400187313607e-03, 8.11305605779899562e-04}, + {2.24635331169675823e-10, -6.02144184513875302e-09, 4.15827878380570226e-09}, + {-4.55408258326350790e-09, 6.20319154376325343e-09, 2.08760821823750220e-09}, + {2.10871853867367065e-07, -4.29346688506603014e-07, 1.15683623843482186e-07}, + {1.00732072683129559e-09, 3.88267751283422058e-11, -6.73798626615873530e-09}, + {5.34506627847264326e-09, -8.01262819982717645e-08, 1.60888846226225901e-06}, + {5.83419066552946048e-04, -2.36474094848551555e-03, 8.79373865688287898e-04}, + {-4.85158746510450101e-10, -6.78789624508624456e-09, 4.95385649168511577e-09}, + {3.47485142271342085e-07, 5.60944792101468470e-07, -4.35887910682497548e-07}, + {5.75824910919892421e-04, -2.18618554413632388e-03, 1.22736498224538170e-03}, + {-2.51838883195707221e-02, -8.23487774284355212e-02, 3.33658831723806573e-02}, + {-8.70167529698484543e-09, -1.37080219501928280e-08, 1.80728228771354082e-08}, + {-4.67111571644807100e-09, -2.72041008123058425e-09, 7.06648883852523113e-09}, + {7.26183221906172727e-10, -6.77816339167414128e-09, 4.52883232651690726e-09}, + {5.28852302228433047e-09, 6.47161005340457507e-09, -8.67298467766008940e-09}, + {-2.25465519365641853e-07, -6.46057585221293529e-07, 3.48151143400587948e-07}, + {-1.30051025504229756e-09, -3.25062288891730944e-09, 2.01775679498084060e-09}, + {-5.12724809831333062e-09, 9.33902577666956280e-10, -6.96327353416625883e-10}, + {-3.10810940873373909e-09, -7.49756534634826721e-10, 6.87357185058523612e-10}, + {-1.52109221995821997e-06, -4.22908767925417317e-07, 1.38629667568307413e-06}, + {1.42955317028459206e-09, -7.02968461219199980e-10, -3.81617160094549490e-09}, + {2.53707400921232562e-09, -1.60727622877665510e-09, -4.18765366827500429e-09}, + {-2.14750738948554787e-07, -6.40554276953864132e-07, 3.76128531993924486e-07}, + {3.83073214815787821e-09, 4.50296289838947317e-10, 2.29523194894554194e-09}, + {4.76340728555735282e-07, 6.83235613037347367e-07, -4.72205395646296822e-07}, + {-6.10651996176347607e-04, -1.06790499934057291e-03, 2.29083496655867842e-03}, + {3.95497823379997726e-09, 1.38236928154400474e-09, -6.26218820548585242e-09}, + {1.11904936705986557e-09, -1.37869946362223494e-08, -9.34049783699042457e-10}, + {1.25499246411697740e-09, -2.73635453185150368e-09, -2.91506864740637139e-09}, + {-3.59882924006599270e-07, 1.32511373732895413e-06, -1.55110207063907657e-07}, + {1.07068498511608823e-09, 8.92087770321126072e-09, 2.62826524433101838e-10}, + {-2.69316546841480431e-09, 9.61138280075601870e-10, 5.19946977139973399e-09}, + {-5.92563579700916554e-07, -1.05071339539294234e-06, 1.56249964602256375e-07}, + {1.32198180180509439e-09, 5.16087961255351502e-09, 8.46339526239248130e-10}, + {2.07323220008381881e-06, 1.02309267446332522e-06, -2.07661522726165781e-06}, + {1.31402366846389393e-03, 3.78229792813366064e-04, 1.77496793932758741e-03}, + {8.59301428624004160e-10, -6.83071707530125138e-09, 3.36249680876754553e-09}, + {5.27310424491833629e-09, 2.09999085065692981e-08, -3.10459945807028959e-08}, + {-8.88666080375855039e-08, 4.60897593930476024e-07, 7.41576575386676540e-07}, + {-4.85540663230921155e-10, -5.58243438975036810e-09, 7.40450811775872353e-10}, + {4.03141117225058743e-07, 1.52035531639227450e-06, 9.06206514897367477e-08}, + {5.61075629915620496e-04, -2.05847905628765053e-03, 1.12849817492909434e-03}, + {5.11216541321246609e-09, 7.26292920250060092e-09, -8.97145741030058730e-09}, + {-4.26211688914213127e-07, -7.03366608210270750e-07, 6.27995585866791828e-07}, + {1.15309052943982646e-03, 2.34474318844151959e-03, -4.91856748507475423e-03}, + {1.01104427799588961e-01, -4.22361682938472982e-02, -1.88750007538552200e-01}, + {3.94738332298860684e-10, -7.81372397340440727e-10, 4.06815717224340290e-09}, + {-8.61483928638051566e-09, 5.37427180535843263e-09, 1.81738104426676372e-08}, + {-8.48011268844706123e-10, -5.33803143354383280e-09, 2.99703953494934172e-10}, + {3.89154099408092063e-07, -2.44166311268514957e-07, -8.03240371135063858e-07}, + {-1.20249536439409610e-08, 1.48908931921210019e-08, 1.88292573199966284e-09}, + {-1.16401289163015065e-08, 2.57866422936903206e-08, -5.27022399332555125e-09}, + {1.37065399911928676e-07, 2.16494406102361175e-08, -7.63924557662179482e-07}, + {-6.94754161319199870e-10, 6.65038621394664631e-09, -4.31779645371221932e-09}, + {4.72542155592614588e-07, -7.58546986886782931e-07, -2.35913417925837088e-07}, + {1.46133817312113241e-03, -3.25193103208258009e-04, -3.06625181254991741e-04}, + {9.35794082672593210e-09, -7.92923574022275091e-09, -5.41426242728348939e-09}, + {-2.15279239157428748e-08, -4.16754339024882903e-09, -1.12896482995505920e-08}, + {2.60645369870582400e-10, 1.44616071127263122e-06, -3.63334053799999057e-07}, + {9.17105741349288905e-09, -2.02295233654725681e-08, -1.20002956877085509e-08}, + {-1.27759226226098477e-07, 1.28193771791124470e-06, -5.83097827522305323e-07}, + {2.26880791869919426e-03, -1.34042850080092401e-03, -7.65092051285704835e-04}, + {7.03374036792325796e-09, -2.53508958270032281e-09, -7.66132998708535240e-09}, + {-9.71978722189015265e-07, -5.57836512454779054e-07, 1.96329328074063003e-06}, + {-1.26115140811304343e-03, -4.81792074617704632e-04, -1.06803272537897391e-03}, + {1.19419564863885497e-01, 5.07766738901840875e-02, 4.87642090320925953e-02}, + {1.14090414893297520e-09, 1.56073433760228752e-08, -1.78054684078429726e-08}, + {3.03285130343056153e-09, -1.58615337531031741e-09, -4.94928394101368241e-09}, + {2.64483280249840080e-07, 2.97155396291660413e-07, -5.41608085095034164e-07}, + {2.68757552324139226e-09, -1.41400907649469332e-08, 2.93255796729452456e-08}, + {-2.11094617584561828e-07, -6.56355695552793272e-07, 3.72180321686621518e-07}, + {-2.55073452371079590e-04, 1.57943859317488818e-03, -1.29154484940938240e-03}, + {1.40049266628139435e-09, 1.40747080656922208e-08, -2.58792021839981956e-09}, + {-2.12330362681090179e-07, -1.30522733223815968e-06, 5.84417623253341567e-07}, + {-9.33144849909676392e-04, 1.90305575962152547e-03, -8.35564417983726418e-04}, + {1.81624805201406961e-02, 6.84911174969819458e-02, -2.28291882522520390e-02}, + {-8.25231299961259879e-09, -1.40227519596081152e-08, 1.78809529925716415e-08}, + {1.90689491530449118e-07, 7.01057736002264065e-07, -4.26430629252294580e-07}, + {-5.85146839837499930e-04, -1.07311215649546045e-03, 2.31986890222730339e-03}, + {-1.05962397073886522e-01, 5.51532131360410807e-02, 1.87542648909451215e-01}, + {-1.37499370823599516e-03, -8.49619409242363438e-04, -1.18180356709159952e-03} +}; + +static const double INTERCEPT[3] = { + -1.29208772400146188e+00, + 6.62251952866635918e+00, + -1.35908984683965173e-01 +}; +// END AUTO-GENERATED COEFFICIENTS + +inline void compute_poly_features(const double x[7], double out[330]) { + for (int i = 0; i < N_FEATURES; ++i) { + double val = 1.0; + for (int j = 0; j < N_INPUTS; ++j) { + if (POWERS[i][j] != 0) { + double base = x[j]; + int exp = POWERS[i][j]; + // Fast integer exponentiation (max exp = 4) + double p = 1.0; + for (int e = 0; e < exp; ++e) + p *= base; + val *= p; + } + } + out[i] = val; + } +} + +} // namespace detail + +struct RGB { + unsigned char r, g, b; +}; + +/** + * Mix two RGB colors using polynomial pigment mixing. + * + * This performs polynomial pigment-style RGB interpolation. + * + * @param r1,g1,b1 First color (0-255) + * @param r2,g2,b2 Second color (0-255) + * @param t Mixing ratio: 0.0 = all color1, 1.0 = all color2 + * @param out_r,out_g,out_b Output color (0-255) + */ +inline void lerp(unsigned char r1, unsigned char g1, unsigned char b1, + unsigned char r2, unsigned char g2, unsigned char b2, + float t, + unsigned char* out_r, unsigned char* out_g, unsigned char* out_b) { + // Clamp t + if (t <= 0.0f) { + *out_r = r1; *out_g = g1; *out_b = b1; + return; + } + if (t >= 1.0f) { + *out_r = r2; *out_g = g2; *out_b = b2; + return; + } + + double x[7] = { + static_cast(r1), static_cast(g1), static_cast(b1), + static_cast(r2), static_cast(g2), static_cast(b2), + static_cast(t) + }; + + double features[330]; + detail::compute_poly_features(x, features); + + // Dot product: features @ COEF + INTERCEPT + for (int c = 0; c < 3; ++c) { + double sum = detail::INTERCEPT[c]; + for (int i = 0; i < detail::N_FEATURES; ++i) { + sum += features[i] * detail::COEF[i][c]; + } + // Clamp to [0, 255] and truncate (matches numpy astype(int) behavior) + int val = static_cast(sum); + if (val < 0) val = 0; + if (val > 255) val = 255; + + if (c == 0) *out_r = static_cast(val); + else if (c == 1) *out_g = static_cast(val); + else *out_b = static_cast(val); + } +} + +/** + * Convenience overload returning an RGB struct. + */ +inline RGB lerp(unsigned char r1, unsigned char g1, unsigned char b1, + unsigned char r2, unsigned char g2, unsigned char b2, + float t) { + RGB result; + lerp(r1, g1, b1, r2, g2, b2, t, &result.r, &result.g, &result.b); + return result; +} + +} // namespace filament_mixer + +#endif // FILAMENT_MIXER_MODEL_HPP diff --git a/src/libslic3r/Fill/FillCornerSmoothing.cpp b/src/libslic3r/Fill/FillCornerSmoothing.cpp index 2af9f6bb9c..dbce39d572 100644 --- a/src/libslic3r/Fill/FillCornerSmoothing.cpp +++ b/src/libslic3r/Fill/FillCornerSmoothing.cpp @@ -108,6 +108,22 @@ const std::vector& CornerSmoother::curve_coefficients( return m_cached_coefficients; } +bool CornerSmoother::is_on_straight_run(const Vec2d &previous, const Vec2d &vertex, const Vec2d &next) +{ + const Vec2d incoming_leg = vertex - previous; + const Vec2d outgoing_leg = next - vertex; + const double incoming_length = incoming_leg.norm(); + const double outgoing_length = outgoing_leg.norm(); + // A vertex repeating one of its neighbours carries no direction of its own. + if (incoming_length < EPSILON || outgoing_length < EPSILON) + return true; + + const Vec2d incoming = incoming_leg / incoming_length; + const Vec2d outgoing = outgoing_leg / outgoing_length; + return incoming.dot(outgoing) > 0. && + std::abs(incoming.x() * outgoing.y() - incoming.y() * outgoing.x()) < EPSILON; +} + void CornerSmoother::round_corner(const Vec2d &previous, const Vec2d &corner, const Vec2d &next) { m_corner_points.clear(); diff --git a/src/libslic3r/Fill/FillCornerSmoothing.hpp b/src/libslic3r/Fill/FillCornerSmoothing.hpp index 1852fc4c67..7f2ead229a 100644 --- a/src/libslic3r/Fill/FillCornerSmoothing.hpp +++ b/src/libslic3r/Fill/FillCornerSmoothing.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -47,36 +48,57 @@ public: template void push(const Vec2d &point, Emit &emit) { - if (m_pending == 0) { + if (m_held == 0) { + // The first point of a path is an end, not a corner, and stays where it is. emit(point); - m_previous = point; - } else if (m_pending > 1) { - round_corner(m_previous, m_corner, point); - for (const Vec2d &corner_point : m_corner_points) - emit(corner_point); - m_previous = m_corner; + m_window[m_held++] = point; + return; } - m_corner = point; - m_pending = std::min(m_pending + 1, 2); + if (m_held > 1 && is_on_straight_run(m_window[m_held - 2], m_window[m_held - 1], point)) { + // The newest vertex only splits a straight leg, so the leg runs on to this point instead. + m_window[m_held - 1] = point; + return; + } + if (m_held < 3) { + m_window[m_held++] = point; + return; + } + // Both legs of the middle vertex are complete now, so its curve can no longer grow. + emit_corner(m_window[0], m_window[1], m_window[2], emit); + m_window[0] = m_window[1]; + m_window[1] = m_window[2]; + m_window[2] = point; } // Emits the last point of the path and prepares the smoother for a new one. template void flush(Emit &emit) { - if (m_pending > 1) - emit(m_corner); - m_pending = 0; + if (m_held > 2) + emit_corner(m_window[0], m_window[1], m_window[2], emit); + if (m_held > 1) + emit(m_window[m_held - 1]); + m_held = 0; } private: + template void emit_corner(const Vec2d &previous, const Vec2d &corner, const Vec2d &next, Emit &emit) + { + round_corner(previous, corner, next); + for (const Vec2d &corner_point : m_corner_points) + emit(corner_point); + } + + // Tells a vertex that only continues a straight leg (or repeats its predecessor) from a corner. + // A path doubling back on itself is not one, that vertex is a hairpin and stays where it is. + static bool is_on_straight_run(const Vec2d &previous, const Vec2d &vertex, const Vec2d &next); // Fills m_corner_points with the points replacing the corner vertex. void round_corner(const Vec2d &previous, const Vec2d &corner, const Vec2d &next); // Flattens the canonical corner curve of the given size and turn into coordinates of the // (incoming, outgoing) basis of the corner. Cached, as an infill path repeats the same corner. const std::vector& curve_coefficients(double corner_distance, const Vec2d &incoming, const Vec2d &outgoing); - // Fraction of the shorter adjoining segment consumed on each side of a corner. Half of a segment - // is the maximum, otherwise the curves of two adjacent corners would overlap. + // Fraction of the shorter adjoining leg consumed on each side of a corner. Half of a leg is the + // maximum, otherwise the curves of two adjacent corners would overlap. const double m_corner_distance_ratio; const double m_tolerance; const double m_max_corner_distance; @@ -88,10 +110,11 @@ private: double m_cached_cosine { 0. }; bool m_has_cached_coefficients { false }; - Vec2d m_previous { Vec2d::Zero() }; - Vec2d m_corner { Vec2d::Zero() }; - // Number of points held back: none, the first point of a path, or a corner candidate. - int m_pending { 0 }; + // The corners seen last, kept free of vertices that merely split a straight leg. The middle one + // is rounded once the third arrives, which is what makes its outgoing leg final. + std::array m_window { Vec2d::Zero(), Vec2d::Zero(), Vec2d::Zero() }; + // How many of them are filled in. + int m_held { 0 }; }; // Rounds the corners of already scaled paths in place. Paths of less than three points are left alone. diff --git a/src/libslic3r/Format/AssimpImport.cpp b/src/libslic3r/Format/AssimpImport.cpp new file mode 100644 index 0000000000..f0ae99506a --- /dev/null +++ b/src/libslic3r/Format/AssimpImport.cpp @@ -0,0 +1,327 @@ +#include "AssimpImport.hpp" + +#include "../TexturePainting.hpp" +#include "ResourcePathUtils.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace Slic3r { +namespace { + +void clear_textured_mesh(TexturedMesh& out) +{ + out.vertices.clear(); + out.indices.clear(); + out.uvs.clear(); + out.uv_coords.clear(); + out.uv_indices.clear(); + out.textures.clear(); + out.material_ids.clear(); + out.material_texture_map.clear(); + out.material_colors.clear(); +} + +void set_error_message(std::string* error_message, const std::string& message) +{ + if (error_message) + *error_message = message; +} + +bool is_fbx_path(const std::string& path) +{ + return boost::algorithm::iends_with(path, ".fbx"); +} + +bool should_flip_uvs(const std::string& path) +{ + return boost::algorithm::iends_with(path, ".fbx") || + boost::algorithm::iends_with(path, ".glb"); +} + +unsigned int assimp_import_flags(const std::string& path) +{ + unsigned int flags = aiProcess_Triangulate + | aiProcess_GenNormals + | aiProcess_PreTransformVertices + | aiProcess_SortByPType; + if (should_flip_uvs(path)) + flags |= aiProcess_FlipUVs; + return flags; +} + +void configure_importer(Assimp::Importer& importer, const std::string& path, unsigned int flags) +{ + importer.SetPropertyInteger(AI_CONFIG_PP_SBP_REMOVE, + aiPrimitiveType_POINT | aiPrimitiveType_LINE); + + if (flags & aiProcess_PreTransformVertices) + importer.SetPropertyBool(AI_CONFIG_PP_PTV_KEEP_HIERARCHY, true); + + if (is_fbx_path(path)) { + importer.SetPropertyBool(AI_CONFIG_IMPORT_FBX_READ_ALL_GEOMETRY_LAYERS, true); + importer.SetPropertyBool(AI_CONFIG_IMPORT_FBX_READ_MATERIALS, true); + importer.SetPropertyBool(AI_CONFIG_IMPORT_FBX_READ_TEXTURES, true); + importer.SetPropertyBool(AI_CONFIG_IMPORT_FBX_READ_ANIMATIONS, false); + importer.SetPropertyBool(AI_CONFIG_IMPORT_FBX_READ_LIGHTS, false); + importer.SetPropertyBool(AI_CONFIG_IMPORT_FBX_READ_CAMERAS, false); + } +} + +bool read_external_texture_file(const boost::filesystem::path& path, TextureImage& out) +{ + boost::nowide::ifstream file(path.string(), std::ios::binary | std::ios::ate); + if (!file.is_open()) + return false; + + const std::streamoff size = file.tellg(); + if (size <= 0) + return false; + if (static_cast(size) > static_cast(std::numeric_limits::max())) + return false; + + file.seekg(0); + out.width = -1; + out.height = -1; + out.channels = 0; + out.data.resize(static_cast(size)); + file.read(reinterpret_cast(out.data.data()), size); + if (!file && !file.eof()) { + out.data.clear(); + return false; + } + return true; +} + +bool read_embedded_texture(const aiTexture& texture, TextureImage& out) +{ + out.data.clear(); + if (texture.mHeight == 0) { + if (texture.mWidth == 0) + return false; + out.width = -1; + out.height = -1; + out.channels = 0; + out.data.assign( + reinterpret_cast(texture.pcData), + reinterpret_cast(texture.pcData) + texture.mWidth); + return !out.data.empty(); + } + + if (texture.mWidth == 0 || texture.mHeight == 0) + return false; + if (texture.mWidth > static_cast(std::numeric_limits::max()) || + texture.mHeight > static_cast(std::numeric_limits::max())) { + return false; + } + const size_t width = static_cast(texture.mWidth); + const size_t height = static_cast(texture.mHeight); + if (width > std::numeric_limits::max() / height || + width * height > std::numeric_limits::max() / 4) { + return false; + } + + out.width = static_cast(texture.mWidth); + out.height = static_cast(texture.mHeight); + out.channels = 4; + const size_t pixel_count = width * height; + out.data.resize(pixel_count * 4); + for (size_t i = 0; i < pixel_count; ++i) { + const aiTexel& texel = texture.pcData[i]; + out.data[i * 4 + 0] = texel.r; + out.data[i * 4 + 1] = texel.g; + out.data[i * 4 + 2] = texel.b; + out.data[i * 4 + 3] = texel.a; + } + return !out.data.empty(); +} + +bool get_material_texture(const aiMaterial& material, aiString& texture_path) +{ + if (material.GetTextureCount(aiTextureType_DIFFUSE) > 0 && + material.GetTexture(aiTextureType_DIFFUSE, 0, &texture_path) == AI_SUCCESS) { + return true; + } + + if (material.GetTextureCount(aiTextureType_BASE_COLOR) > 0 && + material.GetTexture(aiTextureType_BASE_COLOR, 0, &texture_path) == AI_SUCCESS) { + return true; + } + + return false; +} + +std::array get_material_color(const aiMaterial& material) +{ + aiColor4D color(1.f, 1.f, 1.f, 1.f); + if (material.Get(AI_MATKEY_BASE_COLOR, color) == AI_SUCCESS) + return {color.r, color.g, color.b, color.a}; + if (material.Get(AI_MATKEY_COLOR_DIFFUSE, color) == AI_SUCCESS) + return {color.r, color.g, color.b, color.a}; + return {1.f, 1.f, 1.f, 1.f}; +} + +bool collect_mesh(const aiMesh& mesh, size_t& vertex_offset, TexturedMesh& out, std::string& error) +{ + if (mesh.mNumVertices > static_cast(std::numeric_limits::max()) - vertex_offset) { + error = "Assimp mesh has too many vertices for TexturedMesh indices"; + return false; + } + + for (unsigned int i = 0; i < mesh.mNumVertices; ++i) { + const aiVector3D& v = mesh.mVertices[i]; + out.vertices.push_back({v.x, v.y, v.z}); + + if (mesh.HasTextureCoords(0)) { + const aiVector3D& uv = mesh.mTextureCoords[0][i]; + out.uvs.push_back({uv.x, uv.y}); + } else { + out.uvs.push_back({0.f, 0.f}); + } + } + + const int material_index = static_cast(mesh.mMaterialIndex); + for (unsigned int i = 0; i < mesh.mNumFaces; ++i) { + const aiFace& face = mesh.mFaces[i]; + if (face.mNumIndices != 3) + continue; + if (face.mIndices[0] >= mesh.mNumVertices || + face.mIndices[1] >= mesh.mNumVertices || + face.mIndices[2] >= mesh.mNumVertices) { + error = "Assimp mesh face index is out of bounds"; + return false; + } + out.indices.push_back({ + static_cast(static_cast(face.mIndices[0]) + vertex_offset), + static_cast(static_cast(face.mIndices[1]) + vertex_offset), + static_cast(static_cast(face.mIndices[2]) + vertex_offset)}); + out.material_ids.push_back(material_index); + } + + vertex_offset += mesh.mNumVertices; + return true; +} + +void collect_materials(const aiScene& scene, const boost::filesystem::path& base_dir, TexturedMesh& out) +{ + out.material_texture_map.assign(scene.mNumMaterials, -1); + out.material_colors.assign(scene.mNumMaterials, {1.f, 1.f, 1.f, 1.f}); + + for (unsigned int material_index = 0; material_index < scene.mNumMaterials; ++material_index) { + const aiMaterial* material = scene.mMaterials[material_index]; + if (!material) + continue; + + out.material_colors[material_index] = get_material_color(*material); + + aiString texture_path; + if (!get_material_texture(*material, texture_path)) + continue; + + TextureImage image; + const aiTexture* embedded_texture = scene.GetEmbeddedTexture(texture_path.C_Str()); + if (embedded_texture) { + if (!read_embedded_texture(*embedded_texture, image)) + continue; + } else { + const boost::filesystem::path resolved = resource_path::resolve_external_resource_path( + base_dir, texture_path.C_Str(), "Assimp texture"); + if (resolved.empty()) { + BOOST_LOG_TRIVIAL(warning) << "AssimpImport: texture file not found: " + << texture_path.C_Str(); + continue; + } + if (!read_external_texture_file(resolved, image)) { + BOOST_LOG_TRIVIAL(warning) << "AssimpImport: failed to read texture: " + << resolved; + continue; + } + } + + out.material_texture_map[material_index] = static_cast(out.textures.size()); + out.textures.push_back(std::move(image)); + } +} + +std::string scene_failure_summary(const std::string& path, const char* assimp_error) +{ + std::ostringstream ss; + ss << "Assimp failed to import " << path; + if (assimp_error && assimp_error[0] != '\0') + ss << ": " << assimp_error; + return ss.str(); +} + +} // namespace + +bool load_assimp_textured_model(const std::string& path, TexturedMesh& out, std::string* error_message) +{ + clear_textured_mesh(out); + + Assimp::Importer importer; + const unsigned int flags = assimp_import_flags(path); + configure_importer(importer, path, flags); + + const aiScene* scene = importer.ReadFile(path, flags); + if (!scene || (scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE) || !scene->mRootNode) { + const std::string message = scene_failure_summary(path, importer.GetErrorString()); + BOOST_LOG_TRIVIAL(error) << "AssimpImport: " << message; + set_error_message(error_message, message); + return false; + } + + if (scene->mNumMeshes == 0) { + const std::string message = "Assimp scene has no meshes: " + path; + BOOST_LOG_TRIVIAL(error) << "AssimpImport: " << message; + set_error_message(error_message, message); + return false; + } + + size_t vertex_offset = 0; + for (unsigned int mesh_index = 0; mesh_index < scene->mNumMeshes; ++mesh_index) { + const aiMesh* mesh = scene->mMeshes[mesh_index]; + if (!mesh || !mesh->HasPositions()) + continue; + std::string mesh_error; + if (!collect_mesh(*mesh, vertex_offset, out, mesh_error)) { + const std::string message = mesh_error + ": " + path; + BOOST_LOG_TRIVIAL(error) << "AssimpImport: " << message; + set_error_message(error_message, message); + clear_textured_mesh(out); + return false; + } + } + + if (out.vertices.empty() || out.indices.empty()) { + const std::string message = "Assimp extracted no valid triangles: " + path; + BOOST_LOG_TRIVIAL(error) << "AssimpImport: " << message; + set_error_message(error_message, message); + clear_textured_mesh(out); + return false; + } + + collect_materials(*scene, boost::filesystem::path(path).parent_path(), out); + + BOOST_LOG_TRIVIAL(info) << "AssimpImport: loaded " << out.vertices.size() + << " vertices, " << out.indices.size() + << " triangles, " << out.textures.size() + << " textures from " << path; + return true; +} + +} // namespace Slic3r diff --git a/src/libslic3r/Format/AssimpImport.hpp b/src/libslic3r/Format/AssimpImport.hpp new file mode 100644 index 0000000000..80c3e2dc91 --- /dev/null +++ b/src/libslic3r/Format/AssimpImport.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace Slic3r { + +struct TexturedMesh; + +bool load_assimp_textured_model(const std::string& path, TexturedMesh& out, std::string* error_message = nullptr); + +} // namespace Slic3r diff --git a/src/libslic3r/Format/OBJ.cpp b/src/libslic3r/Format/OBJ.cpp index 71f7d1e7e2..e066925a98 100644 --- a/src/libslic3r/Format/OBJ.cpp +++ b/src/libslic3r/Format/OBJ.cpp @@ -1,6 +1,8 @@ #include "../libslic3r.h" #include "../Model.hpp" #include "../TriangleMesh.hpp" +#include "../TexturePainting.hpp" +#include "ResourcePathUtils.hpp" #include "OBJ.hpp" #include "objparser.hpp" @@ -21,7 +23,7 @@ namespace Slic3r { -bool load_obj(const char *path, TriangleMesh *meshptr, ObjInfo& obj_info, std::string &message) +bool load_obj(const char *path, TriangleMesh *meshptr, ObjInfo& obj_info, std::string &message, ObjParser::MtlData *out_mtl) { if (meshptr == nullptr) return false; @@ -53,9 +55,9 @@ bool load_obj(const char *path, TriangleMesh *meshptr, ObjInfo& obj_info, std::s boost::filesystem::path temp_mtl_path(mtl_file); mtl_path = temp_mtl_path; } - auto _mtl_path = mtl_name_is_path ? mtl_abs_path.string().c_str() : mtl_path.string().c_str(); + const std::string _mtl_path = (mtl_name_is_path ? mtl_abs_path : mtl_path).string(); if (boost::filesystem::exists(mtl_name_is_path ? mtl_abs_path : mtl_path)) { - if (!ObjParser::mtlparse(_mtl_path, mtl_data)) { + if (!ObjParser::mtlparse(_mtl_path.c_str(), mtl_data)) { BOOST_LOG_TRIVIAL(error) << "load_obj:load_mtl: failed to parse " << _mtl_path; message = _L("load mtl in obj: failed to parse"); return false; @@ -98,6 +100,7 @@ bool load_obj(const char *path, TriangleMesh *meshptr, ObjInfo& obj_info, std::s its.indices.reserve(num_faces + num_quads); if (exist_mtl) { obj_info.is_single_mtl = data.usemtls.size() == 1 && mtl_data.new_mtl_unmap.size() == 1; + obj_info.usemtls = data.usemtls; obj_info.face_colors.reserve(num_faces + num_quads); } bool has_color = data.has_vertex_color; @@ -210,14 +213,17 @@ bool load_obj(const char *path, TriangleMesh *meshptr, ObjInfo& obj_info, std::s } if (meshptr->volume() < 0) meshptr->flip_triangles(); + // Hand the parsed material table back so callers can build a TexturedMesh from it. + if (out_mtl) + *out_mtl = mtl_data; return true; } -bool load_obj(const char *path, Model *model, ObjInfo& obj_info, std::string &message, const char *object_name_in) +bool load_obj(const char *path, Model *model, ObjInfo& obj_info, std::string &message, const char *object_name_in, ObjParser::MtlData *out_mtl) { TriangleMesh mesh; - bool ret = load_obj(path, &mesh, obj_info, message); + bool ret = load_obj(path, &mesh, obj_info, message, out_mtl); if (ret) { std::string object_name; @@ -232,6 +238,144 @@ bool load_obj(const char *path, Model *model, ObjInfo& obj_info, std::string &me return ret; } +bool obj_to_textured_mesh( + const ObjInfo& obj_info, + const indexed_triangle_set& its, + const ObjParser::MtlData& mtl_data, + const std::string& obj_directory, + TexturedMesh& out) +{ + if (its.vertices.empty() || its.indices.empty() || !obj_info.has_uv_png) + return false; + + const size_t nv = its.vertices.size(); + const size_t nf = its.indices.size(); + + // 1. Copy vertices + out.vertices.resize(nv); + for (size_t i = 0; i < nv; ++i) + out.vertices[i] = {its.vertices[i].x(), its.vertices[i].y(), its.vertices[i].z()}; + + // 2. Copy face indices + out.indices.resize(nf); + for (size_t i = 0; i < nf; ++i) + out.indices[i] = {its.indices[i][0], its.indices[i][1], its.indices[i][2]}; + + // 3. Build per-face UV (uv_coords + uv_indices) + // OBJ UV convention: V=0 at bottom (OpenGL); texture sampling expects V=0 at top (like glTF/OpenCV). + // Flip V here so downstream code works uniformly. + if (!obj_info.uvs.empty()) { + const size_t uv_face_count = obj_info.uvs.size(); + out.uv_coords.resize(uv_face_count * 3); + out.uv_indices.resize(nf); + for (size_t fi = 0; fi < nf; ++fi) { + if (fi < uv_face_count) { + int base = static_cast(fi * 3); + out.uv_coords[base + 0] = {obj_info.uvs[fi][0].x(), 1.f - obj_info.uvs[fi][0].y()}; + out.uv_coords[base + 1] = {obj_info.uvs[fi][1].x(), 1.f - obj_info.uvs[fi][1].y()}; + out.uv_coords[base + 2] = {obj_info.uvs[fi][2].x(), 1.f - obj_info.uvs[fi][2].y()}; + out.uv_indices[fi] = {base, base + 1, base + 2}; + } else { + out.uv_indices[fi] = {0, 0, 0}; + } + } + } + + // 4. Build material list and load textures from disk + // Map: material name -> material index + std::map mtl_name_to_idx; + for (size_t i = 0; i < mtl_data.mtl_orders.size(); ++i) + mtl_name_to_idx[mtl_data.mtl_orders[i]] = static_cast(i); + + const int num_materials = static_cast(mtl_data.mtl_orders.size()); + out.material_colors.resize(num_materials, {1.f, 1.f, 1.f, 1.f}); + out.material_texture_map.resize(num_materials, -1); + + // Map: texture filename -> index in out.textures + std::map png_to_tex_idx; + + for (int mi = 0; mi < num_materials; ++mi) { + const std::string& name = mtl_data.mtl_orders[mi]; + auto it = mtl_data.new_mtl_unmap.find(name); + if (it == mtl_data.new_mtl_unmap.end()) + continue; + const auto& mtl = *(it->second); + + // Material color from Kd + out.material_colors[mi] = {mtl.Kd[0], mtl.Kd[1], mtl.Kd[2], mtl.Tr}; + + // Texture from map_Kd + if (mtl.map_Kd.empty()) + continue; + + auto tex_it = png_to_tex_idx.find(mtl.map_Kd); + if (tex_it != png_to_tex_idx.end()) { + out.material_texture_map[mi] = tex_it->second; + continue; + } + + // Resolve texture file path. + const boost::filesystem::path requested_tex_path(mtl.map_Kd); + const boost::filesystem::path tex_path = requested_tex_path.is_absolute() ? + resource_path::resolve_existing_path_case_insensitive(requested_tex_path, "obj_to_textured_mesh: map_Kd") : + resource_path::resolve_existing_relative_path_case_insensitive( + boost::filesystem::path(obj_directory), requested_tex_path, "obj_to_textured_mesh: map_Kd"); + + if (tex_path.empty()) { + BOOST_LOG_TRIVIAL(warning) << "obj_to_textured_mesh: texture not found: " << requested_tex_path; + continue; + } + + // Read raw file bytes + boost::nowide::ifstream file(tex_path.string(), std::ios::binary | std::ios::ate); + if (!file.is_open()) + continue; + auto file_size = file.tellg(); + if (file_size <= 0) + continue; + file.seekg(0, std::ios::beg); + + TextureImage ti; + ti.data.resize(static_cast(file_size)); + file.read(reinterpret_cast(ti.data.data()), file_size); + ti.width = -1; + ti.height = -1; + ti.channels = 0; + + int new_idx = static_cast(out.textures.size()); + out.textures.push_back(std::move(ti)); + png_to_tex_idx[mtl.map_Kd] = new_idx; + out.material_texture_map[mi] = new_idx; + } + + // 5. Build per-face material_ids from usemtls ranges + out.material_ids.resize(nf, -1); + if (!obj_info.usemtls.empty()) { + for (size_t fi = 0; fi < nf; ++fi) { + int face_idx = static_cast(fi); + for (size_t k = 0; k < obj_info.usemtls.size(); ++k) { + const auto& um = obj_info.usemtls[k]; + if (face_idx >= um.face_start && face_idx <= um.face_end) { + auto name_it = mtl_name_to_idx.find(um.name); + if (name_it != mtl_name_to_idx.end()) + out.material_ids[fi] = name_it->second; + break; + } + } + } + } + + if (out.textures.empty()) { + BOOST_LOG_TRIVIAL(warning) << "obj_to_textured_mesh: no textures loaded"; + return false; + } + + BOOST_LOG_TRIVIAL(info) << "obj_to_textured_mesh: " << nf << " faces, " + << out.textures.size() << " textures, " + << num_materials << " materials"; + return true; +} + bool store_obj(const char *path, TriangleMesh *mesh) { //FIXME returning false even if write failed. diff --git a/src/libslic3r/Format/OBJ.hpp b/src/libslic3r/Format/OBJ.hpp index 2d4370c99a..c103326af6 100644 --- a/src/libslic3r/Format/OBJ.hpp +++ b/src/libslic3r/Format/OBJ.hpp @@ -1,6 +1,7 @@ #ifndef slic3r_Format_OBJ_hpp_ #define slic3r_Format_OBJ_hpp_ #include "libslic3r/Color.hpp" +#include "objparser.hpp" #include namespace Slic3r { @@ -18,6 +19,7 @@ struct ObjInfo { std::map pngs; std::unordered_map uv_map_pngs; bool has_uv_png{false}; + std::vector usemtls; // material spans, for texture import }; struct ObjDialogInOut @@ -32,8 +34,18 @@ struct ObjDialogInOut std::string lost_material_name{""}; }; typedef std::function ObjImportColorFn; -extern bool load_obj(const char *path, TriangleMesh *mesh, ObjInfo &vertex_colors, std::string &message); -extern bool load_obj(const char *path, Model *model, ObjInfo &vertex_colors, std::string &message, const char *object_name = nullptr); +extern bool load_obj(const char *path, TriangleMesh *mesh, ObjInfo &vertex_colors, std::string &message, ObjParser::MtlData *out_mtl = nullptr); +extern bool load_obj(const char *path, Model *model, ObjInfo &vertex_colors, std::string &message, const char *object_name = nullptr, ObjParser::MtlData *out_mtl = nullptr); + +struct TexturedMesh; +// Build a TexturedMesh (vertices + per-face UVs + the texture files named by map_Kd) from a +// parsed OBJ plus its material table, so the texture-to-color importer can sample face colours. +extern bool obj_to_textured_mesh( + const ObjInfo& obj_info, + const indexed_triangle_set& its, + const ObjParser::MtlData& mtl_data, + const std::string& obj_directory, + TexturedMesh& out); extern bool store_obj(const char *path, TriangleMesh *mesh); extern bool store_obj(const char *path, ModelObject *model); diff --git a/src/libslic3r/Format/ResourcePathUtils.hpp b/src/libslic3r/Format/ResourcePathUtils.hpp new file mode 100644 index 0000000000..d82b92bd45 --- /dev/null +++ b/src/libslic3r/Format/ResourcePathUtils.hpp @@ -0,0 +1,240 @@ +#ifndef slic3r_Format_ResourcePathUtils_hpp_ +#define slic3r_Format_ResourcePathUtils_hpp_ + +#include +#include +#include +#include +#include + +#include +#include + +namespace Slic3r { +namespace resource_path { + +inline std::string ascii_lower_copy(const std::string& value) +{ + std::string lowered; + lowered.reserve(value.size()); + for (unsigned char ch : value) + lowered.push_back(static_cast(std::tolower(ch))); + return lowered; +} + +inline boost::filesystem::path portable_path_copy(const boost::filesystem::path& value) +{ + std::string portable = value.string(); + std::replace(portable.begin(), portable.end(), '\\', '/'); + return boost::filesystem::path(portable); +} + +inline int hex_digit_value(char ch) +{ + if (ch >= '0' && ch <= '9') return ch - '0'; + if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10; + if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10; + return -1; +} + +// Byte-level percent decoding. Per RFC 3986 the %XX byte stream is expected to be +// UTF-8 when produced from URIs / Assimp aiString; this function performs no +// transcoding, so callers must treat both input and output as raw UTF-8 bytes. +inline std::string percent_decode_copy(const std::string& value) +{ + std::string decoded; + decoded.reserve(value.size()); + for (std::size_t i = 0; i < value.size(); ++i) { + if (value[i] == '%' && i + 2 < value.size()) { + const int hi = hex_digit_value(value[i + 1]); + const int lo = hex_digit_value(value[i + 2]); + if (hi >= 0 && lo >= 0) { + decoded.push_back(static_cast((hi << 4) | lo)); + i += 2; + continue; + } + } + decoded.push_back(value[i]); + } + return decoded; +} + +inline std::string strip_file_uri_prefix_copy(const std::string& value) +{ + const std::string lower = ascii_lower_copy(value); + if (lower.rfind("file://", 0) != 0) + return value; + + std::string path = value.substr(7); + if (ascii_lower_copy(path).rfind("localhost/", 0) == 0) + path.erase(0, std::string("localhost").size()); + else if (!path.empty() && path.front() != '/') + path = "//" + path; + + // file:///C:/... should become C:/..., while file:///tmp/... keeps /tmp/... + if (path.size() >= 3 && path[0] == '/' && std::isalpha(static_cast(path[1])) && path[2] == ':') + path.erase(path.begin()); + return path; +} + +inline bool file_uri_has_remote_authority(const std::string& value) +{ + const std::string lower = ascii_lower_copy(value); + if (lower.rfind("file://", 0) != 0) + return false; + + const std::string path = value.substr(7); + if (path.empty() || path.front() == '/') + return false; + + const std::size_t slash = path.find('/'); + const std::string authority = path.substr(0, slash); + return ascii_lower_copy(authority) != "localhost"; +} + +inline bool looks_like_windows_absolute_path(const boost::filesystem::path& path) +{ + const std::string portable = portable_path_copy(path).string(); + return portable.size() >= 3 + && std::isalpha(static_cast(portable[0])) + && portable[1] == ':' + && portable[2] == '/'; +} + +inline boost::filesystem::path filename_from_portable_path(const boost::filesystem::path& value) +{ + const boost::filesystem::path portable = portable_path_copy(value); + return portable.filename(); +} + +inline boost::filesystem::path find_child_case_insensitive( + const boost::filesystem::path& directory, + const boost::filesystem::path& requested_name, + const char* context) +{ + if (!boost::filesystem::exists(directory) || !boost::filesystem::is_directory(directory)) + return {}; + + const std::string requested_lower = ascii_lower_copy(requested_name.filename().string()); + std::vector matches; + + boost::system::error_code ec; + for (boost::filesystem::directory_iterator it(directory, ec), end; !ec && it != end; it.increment(ec)) { + if (ascii_lower_copy(it->path().filename().string()) == requested_lower) + matches.push_back(it->path()); + } + + if (matches.size() == 1) + return matches.front(); + + if (matches.size() > 1) { + BOOST_LOG_TRIVIAL(warning) << context << ": ambiguous case-insensitive resource match for " + << requested_name << " in " << directory; + } + + return {}; +} + +inline boost::filesystem::path resolve_existing_path_case_insensitive( + const boost::filesystem::path& requested_path, + const char* context = "resource_path") +{ + const boost::filesystem::path normalized_path = portable_path_copy(requested_path); + + if (normalized_path.empty()) + return {}; + + if (boost::filesystem::exists(normalized_path)) + return normalized_path; + + boost::filesystem::path current; + bool initialized = false; + + for (const boost::filesystem::path& part : normalized_path) { + if (part == normalized_path.root_name() || part == normalized_path.root_directory()) { + current /= part; + initialized = true; + continue; + } + + if (!initialized) { + current = boost::filesystem::current_path(); + initialized = true; + } + + boost::filesystem::path exact = current / part; + if (boost::filesystem::exists(exact)) { + current = exact; + continue; + } + + boost::filesystem::path matched = find_child_case_insensitive(current, part, context); + if (matched.empty()) + return {}; + + BOOST_LOG_TRIVIAL(info) << context << ": resolved resource path case-insensitively from " + << exact << " to " << matched; + current = matched; + } + + return boost::filesystem::exists(current) ? current : boost::filesystem::path(); +} + +inline boost::filesystem::path resolve_existing_relative_path_case_insensitive( + const boost::filesystem::path& base_dir, + const boost::filesystem::path& resource_path, + const char* context = "resource_path") +{ + const boost::filesystem::path requested = resource_path.is_absolute() ? resource_path : base_dir / resource_path; + return resolve_existing_path_case_insensitive(requested, context); +} + +// Resolve a resource path that originated outside our own code (e.g. a glTF/FBX +// material texture reference or a file:// URI inside a 3MF descriptor). +// +// `raw_path` is expected to be UTF-8 regardless of host platform: file URIs are +// UTF-8 by spec, and Assimp aiString uses UTF-8 internally. Cross-platform +// correctness on Windows additionally relies on the process having called +// boost::nowide::nowide_filesystem() during startup (see src/BambuStudio.cpp), +// which imbues boost::filesystem::path with a UTF-8 codecvt so that +// `path(std::string)` constructs from UTF-8 byte sequences. Callers that bypass +// the main entry point (standalone CLI tools, unit tests) must reproduce that +// setup themselves before invoking this helper. +inline boost::filesystem::path resolve_external_resource_path( + const boost::filesystem::path& base_dir, + const std::string& raw_path, + const char* context = "resource_path", + bool allow_basename_fallback = true) +{ + if (raw_path.empty()) + return {}; + + const bool remote_file_uri = file_uri_has_remote_authority(raw_path); + const std::string decoded_path = percent_decode_copy(strip_file_uri_prefix_copy(raw_path)); + const boost::filesystem::path requested = portable_path_copy(boost::filesystem::path(decoded_path)); + + boost::filesystem::path resolved = (requested.is_absolute() || looks_like_windows_absolute_path(requested)) ? + resolve_existing_path_case_insensitive(requested, context) : + resolve_existing_relative_path_case_insensitive(base_dir, requested, context); + if (!resolved.empty()) + return resolved; + + if (!allow_basename_fallback || remote_file_uri) + return {}; + + const boost::filesystem::path basename = filename_from_portable_path(requested); + if (basename.empty()) + return {}; + + resolved = resolve_existing_relative_path_case_insensitive(base_dir, basename, context); + if (!resolved.empty()) { + BOOST_LOG_TRIVIAL(info) << context << ": resolved resource by basename from " + << requested << " to " << resolved; + } + return resolved; +} + +} // namespace resource_path +} // namespace Slic3r + +#endif /* slic3r_Format_ResourcePathUtils_hpp_ */ diff --git a/src/libslic3r/Format/STEP.cpp b/src/libslic3r/Format/STEP.cpp index a5c3bb49a2..22e39b207f 100644 --- a/src/libslic3r/Format/STEP.cpp +++ b/src/libslic3r/Format/STEP.cpp @@ -111,14 +111,19 @@ bool StepPreProcessor::isUtf8File(const char* path) bool StepPreProcessor::isUtf8(const std::string str) { size_t num = 0; - int i = 0; + size_t i = 0; while (i < str.length()) { - if ((str[i] & 0x80) == 0x00) { + const unsigned char lead = static_cast(str[i]); + if ((lead & 0x80) == 0x00) { i++; - } else if ((num = preNum(str[i])) > 2) { + // preNum() counts the leading 1 bits, and a multi-byte sequence is 2 to 4 + // bytes long, so anything outside that range is not a lead byte. + } else if ((num = preNum(lead)) >= 2 && num <= 4) { + if (i + num > str.length()) + return false; i++; - for (int j = 0; j < num - 1; j++) { - if ((str[i] & 0xc0) != 0x80) + for (size_t j = 0; j < num - 1; j++) { + if ((static_cast(str[i]) & 0xc0) != 0x80) return false; i++; } @@ -132,15 +137,20 @@ bool StepPreProcessor::isUtf8(const std::string str) bool StepPreProcessor::isGBK(const std::string str) { size_t i = 0; while (i < str.length()) { - if (str[i] <= 0x7f) { + // char is signed here, so every byte compares <= 0x7f unless widened first. + const unsigned char lead = static_cast(str[i]); + if (lead <= 0x7f) { i++; continue; } else { - if (str[i] >= 0x81 && - str[i] <= 0xfe && - str[i + 1] >= 0x40 && - str[i + 1] <= 0xfe && - str[i + 1] != 0xf7) { + if (i + 1 >= str.length()) + return false; + const unsigned char trail = static_cast(str[i + 1]); + if (lead >= 0x81 && + lead <= 0xfe && + trail >= 0x40 && + trail <= 0xfe && + trail != 0xf7) { i += 2; continue; } @@ -586,7 +596,7 @@ Step::Step_Status Step::mesh(Model* model, for (Standard_Integer aNodeIter = 1; aNodeIter <= aTriangulation->NbNodes(); ++aNodeIter) { gp_Pnt aPnt = aTriangulation->Node(aNodeIter); aPnt.Transform(aTrsf); - points.emplace_back(std::move(Vec3f(aPnt.X(), aPnt.Y(), aPnt.Z()))); + points.emplace_back(Vec3f(aPnt.X(), aPnt.Y(), aPnt.Z())); } // BBS: copy triangles const TopAbs_Orientation anOrientation = anExpSF.Current().Orientation(); diff --git a/src/libslic3r/Format/bbs_3mf.cpp b/src/libslic3r/Format/bbs_3mf.cpp index 3000adb441..b0cbb1fd50 100644 --- a/src/libslic3r/Format/bbs_3mf.cpp +++ b/src/libslic3r/Format/bbs_3mf.cpp @@ -4,6 +4,7 @@ #include "../Preset.hpp" #include "../Utils.hpp" #include "../LocalesUtils.hpp" +#include "../FilamentMixer.hpp" #include "../GCode.hpp" #include "../Geometry.hpp" #include "../GCode/ThumbnailData.hpp" @@ -246,6 +247,8 @@ static constexpr const char* BUILD_TAG = "build"; static constexpr const char* ITEM_TAG = "item"; static constexpr const char* METADATA_TAG = "metadata"; static constexpr const char* FILAMENT_TAG = "filament"; +static constexpr const char* MIXED_FILAMENT_TAG = "mixed_filament"; +static constexpr const char* MIXED_FILAMENT_COMPONENTS_TAG = "components"; static constexpr const char* SLICE_WARNING_TAG = "warning"; static constexpr const char* WARNING_MSG_TAG = "msg"; static constexpr const char *FILAMENT_ID_TAG = "id"; @@ -1315,6 +1318,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) bool _handle_end_config_metadata(); bool _handle_start_config_filament(const char** attributes, unsigned int num_attributes); + bool _handle_start_config_mixed_filament(const char** attributes, unsigned int num_attributes); bool _handle_end_config_filament(); bool _handle_start_config_warning(const char** attributes, unsigned int num_attributes); @@ -2694,6 +2698,14 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) return; } BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(", load project config file successfully from %1%\n") %dest_file; + + // Heal any gradient-curve slots corrupted by the legacy "|" separator collision + // (see FilamentMixer::sanitize_mixed_gradient_curve_array). The 3MF JSON itself + // is safe (";" + C-style escape), but older projects saved through the buggy + // export_selections/load_selections path may already carry single-point entries + // that fail MakerWorld's "curve needs >= 2 points" check. + if (auto* curve_opt = config.option("filament_mixed_gradient_curve")) + Slic3r::sanitize_mixed_gradient_curve_array(curve_opt->values); } } @@ -3511,6 +3523,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) res = _handle_start_config_plater_instance(attributes, num_attributes); else if (::strcmp(FILAMENT_TAG, name) == 0) res = _handle_start_config_filament(attributes, num_attributes); + else if (::strcmp(MIXED_FILAMENT_TAG, name) == 0) + res = _handle_start_config_mixed_filament(attributes, num_attributes); else if (::strcmp(SLICE_WARNING_TAG, name) == 0) res = _handle_start_config_warning(attributes, num_attributes); else if (::strcmp(NOZZLE_TAG, name) == 0) @@ -4684,6 +4698,23 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) return true; } + bool _BBS_3MF_Importer::_handle_start_config_mixed_filament(const char** attributes, unsigned int num_attributes) + { + if (m_curr_plater) { + std::string id = bbs_get_attribute_value_string(attributes, num_attributes, FILAMENT_ID_TAG); + std::string type = bbs_get_attribute_value_string(attributes, num_attributes, FILAMENT_TYPE_TAG); + std::string color = bbs_get_attribute_value_string(attributes, num_attributes, FILAMENT_COLOR_TAG); + std::string components = bbs_get_attribute_value_string(attributes, num_attributes, MIXED_FILAMENT_COMPONENTS_TAG); + PlateMixedFilamentInfo mixed_info; + mixed_info.id = atoi(id.c_str()); + mixed_info.type = type; + mixed_info.color = color; + mixed_info.components = components; + m_curr_plater->mixed_filaments_info.push_back(mixed_info); + } + return true; + } + bool _BBS_3MF_Importer::_handle_end_config_filament() { // do nothing @@ -8488,6 +8519,17 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result) << FILAMENT_USED_FOR_SUPPORT << "=\"" << std::boolalpha << it->used_for_support << "\"/>\n"; } + // Mixed (virtual) filaments used by this plate. These are resolved to physical + // components before g-code statistics, so they are not present in the + // list above and are recorded separately here. + for (auto it = plate_data->mixed_filaments_info.begin(); it != plate_data->mixed_filaments_info.end(); it++) + { + stream << " <" << MIXED_FILAMENT_TAG << " " << FILAMENT_ID_TAG << "=\"" << std::to_string(it->id) << "\" " + << FILAMENT_TYPE_TAG << "=\"" << it->type << "\" " + << FILAMENT_COLOR_TAG << "=\"" << it->color << "\" " + << MIXED_FILAMENT_COMPONENTS_TAG << "=\"" << it->components << "\"/>\n"; + } + for (auto it = plate_data->warnings.begin(); it != plate_data->warnings.end(); it++) { stream << " <" << SLICE_WARNING_TAG << " msg=\"" << it->msg << "\" level=\"" << std::to_string(it->level) << "\" error_code =\"" << it->error_code << "\" />\n"; } @@ -8921,7 +8963,7 @@ private: BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " inital and interval = " << m_interval; m_next_backup = boost::get_system_time() + boost::posix_time::seconds(m_interval); boost::unique_lock lock(m_mutex); - m_thread = std::move(boost::thread(boost::ref(*this))); + m_thread = boost::thread(boost::ref(*this)); } ~_BBS_Backup_Manager() { diff --git a/src/libslic3r/Format/bbs_3mf.hpp b/src/libslic3r/Format/bbs_3mf.hpp index 9c697a14fc..7f5bb8c78d 100644 --- a/src/libslic3r/Format/bbs_3mf.hpp +++ b/src/libslic3r/Format/bbs_3mf.hpp @@ -48,6 +48,18 @@ public: }; +// Mixed (virtual) filament used by a plate. Mixed filaments are virtual slots that get +// resolved to their physical components before g-code statistics, so they never appear in +// slice_filaments_info. They are recorded here separately so a plate's mixed-color usage +// can be recovered from slice_info. +struct PlateMixedFilamentInfo +{ + int id{0}; // 1-based virtual filament slot id + std::string type; + std::string color; // blended display color, "#RRGGBB" + std::string components; // 1-based physical component ids, comma separated, e.g. "1,3" +}; + //BBS: define plate data list related structures struct PlateData { @@ -89,6 +101,8 @@ struct PlateData std::string first_layer_time; std::string plate_name; std::vector slice_filaments_info; + // Mixed (virtual) filaments used by this plate; empty when no mixed filament is used. + std::vector mixed_filaments_info; std::vector skipped_objects; DynamicPrintConfig config; bool is_support_used {false}; diff --git a/src/libslic3r/Format/objparser.cpp b/src/libslic3r/Format/objparser.cpp index 82bf2b4963..886fa423bd 100644 --- a/src/libslic3r/Format/objparser.cpp +++ b/src/libslic3r/Format/objparser.cpp @@ -262,12 +262,9 @@ static bool obj_parseline(const char *line, ObjData &data) } face_index_count++; } - if (face_index_count == 3) {//tri - data.usemtls.back().face_end++; - } else if (face_index_count == 4) {//quad - data.usemtls.back().face_end++; - data.usemtls.back().face_end++; - } + if (face_index_count >= 3) { + data.usemtls.back().face_end += face_index_count - 2; + } } vertex.coordIdx = -1; vertex.normalIdx = -1; @@ -374,6 +371,107 @@ static bool obj_parseline(const char *line, ObjData &data) return true; } static std::string cur_mtl_name = ""; +static bool mtl_is_space(char c) +{ + return c == ' ' || c == '\t' || c == '\r'; +} + +static const char* mtl_skip_ws(const char *line) +{ + while (mtl_is_space(*line)) + ++line; + return line; +} + +static const char* mtl_skip_token(const char *line) +{ + while (*line != 0 && !mtl_is_space(*line)) + ++line; + return line; +} + +static bool mtl_token_equals(const char *begin, const char *end, const char *token) +{ + const size_t len = static_cast(end - begin); + return strlen(token) == len && strncmp(begin, token, len) == 0; +} + +static std::string mtl_trim_value(const char *line) +{ + const char *begin = mtl_skip_ws(line); + const char *end = begin + strlen(begin); + while (end > begin && mtl_is_space(*(end - 1))) + --end; + return std::string(begin, end); +} + +static bool mtl_skip_numeric_token(const char *&line) +{ + const char *begin = mtl_skip_ws(line); + if (*begin == 0) + return false; + char *endptr = 0; + strtod(begin, &endptr); + if (endptr == begin || (!mtl_is_space(*endptr) && *endptr != 0)) + return false; + line = mtl_skip_ws(endptr); + return true; +} + +static bool mtl_skip_required_tokens(const char *&line, int count) +{ + for (int i = 0; i < count; ++i) { + line = mtl_skip_ws(line); + if (*line == 0) + return false; + line = mtl_skip_token(line); + } + line = mtl_skip_ws(line); + return true; +} + +static std::string mtl_parse_texture_name(const char *line) +{ + const char *original = mtl_skip_ws(line); + const char *current = original; + + while (*current == '-') { + const char *option_begin = current; + const char *option_end = mtl_skip_token(current); + current = option_end; + + if (mtl_token_equals(option_begin, option_end, "-o") || + mtl_token_equals(option_begin, option_end, "-s") || + mtl_token_equals(option_begin, option_end, "-t")) { + int skipped = 0; + while (skipped < 3 && mtl_skip_numeric_token(current)) + ++skipped; + if (skipped == 0) + return mtl_trim_value(original); + continue; + } + + int option_args = -1; + if (mtl_token_equals(option_begin, option_end, "-mm")) + option_args = 2; + else if (mtl_token_equals(option_begin, option_end, "-bm") || + mtl_token_equals(option_begin, option_end, "-boost") || + mtl_token_equals(option_begin, option_end, "-texres") || + mtl_token_equals(option_begin, option_end, "-clamp") || + mtl_token_equals(option_begin, option_end, "-blendu") || + mtl_token_equals(option_begin, option_end, "-blendv") || + mtl_token_equals(option_begin, option_end, "-cc") || + mtl_token_equals(option_begin, option_end, "-imfchan") || + mtl_token_equals(option_begin, option_end, "-type")) + option_args = 1; + + if (option_args < 0 || !mtl_skip_required_tokens(current, option_args)) + return mtl_trim_value(original); + } + + return mtl_trim_value(current); +} + static bool mtl_parseline(const char *line, MtlData &data) { if (*line == 0) return true; @@ -394,13 +492,14 @@ static bool mtl_parseline(const char *line, MtlData &data) ObjNewMtl new_mtl; cur_mtl_name = line; data.new_mtl_unmap[cur_mtl_name] = std::make_shared(); + data.mtl_orders.emplace_back(cur_mtl_name); break; } case 'm': { if (*(line++) != 'a' || *(line++) != 'p' || *(line++) != '_' || *(line++) != 'K' || *(line++) != 'd') return false; EATWS(); if (data.new_mtl_unmap.find(cur_mtl_name) != data.new_mtl_unmap.end()) { - data.new_mtl_unmap[cur_mtl_name]->map_Kd = line; + data.new_mtl_unmap[cur_mtl_name]->map_Kd = mtl_parse_texture_name(line); } break; } diff --git a/src/libslic3r/Format/objparser.hpp b/src/libslic3r/Format/objparser.hpp index 48493de3de..58afd015a8 100644 --- a/src/libslic3r/Format/objparser.hpp +++ b/src/libslic3r/Format/objparser.hpp @@ -122,6 +122,9 @@ struct MtlData // Version of the data structure for load / store in the private binary format. int version; std::unordered_map> new_mtl_unmap; + // Material names in declaration order. new_mtl_unmap is unordered, but OBJ material + // indices are positional, so texture import needs the original order. + std::vector mtl_orders; }; extern bool objparse(const char *path, ObjData &data); extern bool mtlparse(const char *path, MtlData &data); diff --git a/src/libslic3r/Format/svg.cpp b/src/libslic3r/Format/svg.cpp index 7bfd73b987..7b720e62ef 100644 --- a/src/libslic3r/Format/svg.cpp +++ b/src/libslic3r/Format/svg.cpp @@ -352,7 +352,7 @@ bool load_svg(const char *path, Model *model, std::string &message) for (Standard_Integer aNodeIter = 1; aNodeIter <= aTriangulation->NbNodes(); ++aNodeIter) { gp_Pnt aPnt = aTriangulation->Node(aNodeIter); aPnt.Transform(aTrsf); - points.emplace_back(std::move(Vec3f(aPnt.X(), aPnt.Y(), aPnt.Z()))); + points.emplace_back(Vec3f(aPnt.X(), aPnt.Y(), aPnt.Z())); } // BBS: copy triangles const TopAbs_Orientation anOrientation = anExpSF.Current().Orientation(); diff --git a/src/libslic3r/GCode.cpp b/src/libslic3r/GCode.cpp index 18d805936e..e947be47a1 100644 --- a/src/libslic3r/GCode.cpp +++ b/src/libslic3r/GCode.cpp @@ -4195,6 +4195,8 @@ void GCode::export_layer_filaments(GCodeProcessorResult* result) } } + result->used_mixed_filaments = m_print->get_slice_used_mixed_filaments(); + result->optimal_assignment.clear(); result->optimal_assignment.reserve(filament_map.size()); for (int nozzle_id : filament_map) @@ -6004,9 +6006,16 @@ LayerResult GCode::process_layer( const WipingExtrusions::ExtruderPerCopy *entity_overrides = nullptr; if (! layer_tools.has_extruder(correct_extruder_id)) { - // this entity is not overridden, but its extruder is not in layer_tools - we'll print it - // by last extruder on this layer (could happen e.g. when a wiping object is taller than others - dontcare extruders are eradicated from layer_tools) - correct_extruder_id = layer_tools.extruders.back(); + // A mixed-color slot is absent from layer_tools.extruders by design: + // resolve_mixed_filaments() replaced it with its physical components, + // and the sublayer block emits its geometry separately. Reassigning it + // to the last extruder here would print it in the wrong colour, so only + // fall back for genuinely stale (dontcare) extruders. + if (!layer_tools.is_mixed_slot(correct_extruder_id)) { + // this entity is not overridden, but its extruder is not in layer_tools - we'll print it + // by last extruder on this layer (could happen e.g. when a wiping object is taller than others - dontcare extruders are eradicated from layer_tools) + correct_extruder_id = layer_tools.extruders.back(); + } } printing_extruders.clear(); if (is_anything_overridden && use_overrides) { @@ -6094,7 +6103,16 @@ LayerResult GCode::process_layer( const bool island_level_ordering = print.config().print_sequence != PrintSequence::ByObject && single_object_instance_idx == size_t(-1) && print.config().print_order != PrintOrder::AsObjectList; - for (unsigned int filament_id : layer_tools.extruders) { + // A mixed-color slot is absent from layer_tools.extruders by design: resolve_mixed_filaments() + // replaced it with its physical components. Its geometry is still keyed under the slot in + // by_extruder though, and the sublayer emitter looks the plan up by slot id, so append the + // slots here. Appending rather than merging leaves the flush-optimized order untouched. + std::vector plan_filaments = layer_tools.extruders; + for (const auto &grp : layer_tools.mixed_sub_layer_groups) + if (std::find(plan_filaments.begin(), plan_filaments.end(), grp.mixed_slot_0based) == plan_filaments.end()) + plan_filaments.push_back(grp.mixed_slot_0based); + + for (unsigned int filament_id : plan_filaments) { auto objects_by_extruder_it = by_extruder.find(filament_id); if (objects_by_extruder_it == by_extruder.end()) continue; @@ -6275,8 +6293,22 @@ LayerResult GCode::process_layer( } if (print.config().print_sequence == PrintSequence::ByLayer && m_enable_exclude_object && print.config().support_object_skip_flush.value) { - std::vector filament_instances_id; - for (InstanceToPrint &instance : filament_to_print_instances[extruder_id].first) filament_instances_id.emplace_back(instance.label_object_id); + std::set all_label_ids; + for (InstanceToPrint &instance : filament_to_print_instances[extruder_id].first) + all_label_ids.insert(instance.label_object_id); + // This extruder may also be printing sub-layers on behalf of a mixed slot, whose + // instances live under the slot id. Their labels belong in the same skip set, or + // exclude-object would not skip that geometry. + for (const auto &grp : layer_tools.mixed_sub_layer_groups) + for (unsigned int comp : grp.components_0based) + if (comp == extruder_id) { + auto mit = filament_to_print_instances.find(grp.mixed_slot_0based); + if (mit != filament_to_print_instances.end()) + for (const InstanceToPrint &inst : mit->second.first) + all_label_ids.insert(inst.label_object_id); + break; + } + std::vector filament_instances_id(all_label_ids.begin(), all_label_ids.end()); m_filament_instances_code = _encode_label_ids_to_base64(filament_instances_id); } @@ -6557,6 +6589,318 @@ LayerResult GCode::process_layer( } } } + + // Mixed-color sublayer extrusion: if this extruder is a component of a mixed sublayer + // group, extrude the mixed slot's geometry at the appropriate sub-Z with scaled flow. + // Ported from BambuStudio and adapted to Orca's instance loop and its finer-grained + // per-role region filament options. + for (const auto &grp : layer_tools.mixed_sub_layer_groups) { + int sub_idx = -1; + for (size_t k = 0; k < grp.components_0based.size(); ++k) { + if (grp.components_0based[k] == extruder_id) { + sub_idx = static_cast(k); + break; + } + } + if (sub_idx < 0) + continue; + + auto mixed_instances_it = filament_to_print_instances.find(grp.mixed_slot_0based); + if (mixed_instances_it == filament_to_print_instances.end() || mixed_instances_it->second.first.empty()) + continue; + + double lh = grp.layer_height > 0. ? grp.layer_height : static_cast(height); + double cumulative_h = 0.0; + for (int i = 0; i < sub_idx; ++i) + cumulative_h += grp.sub_heights[i]; + double default_sub_h = grp.sub_heights[sub_idx]; + double default_sub_z = print_z - lh + cumulative_h + default_sub_h; + + m_sub_layer_flow_ratio = default_sub_h / lh; + m_sub_layer_height = default_sub_h; + m_nominal_z = default_sub_z; + + gcode += this->set_extruder(extruder_id, default_sub_z); + + for (InstanceToPrint &instance_to_print : mixed_instances_it->second.first) { + const bool use_per_volume = grp.is_gradient + && !grp.per_volume_gradient.empty() + && std::any_of(grp.per_volume_gradient.begin(), grp.per_volume_gradient.end(), + [&](const auto &kv) { return kv.first.obj == &instance_to_print.print_object; }); + + // --- Shared instance preamble (mirrors Orca's main instance loop) --- + const LayerToPrint &layer_to_print = layers[instance_to_print.layer_id]; + const auto &inst = instance_to_print.print_object.instances()[instance_to_print.instance_id]; + + bool object_layer_over_raft = layer_to_print.object_layer && layer_to_print.object_layer->id() > 0 && + instance_to_print.print_object.slicing_parameters().raft_layers() == layer_to_print.object_layer->id(); + m_config.apply(print.default_region_config()); + m_config.apply(instance_to_print.print_object.config(), true); + m_layer = layer_to_print.layer(); + m_object_layer_over_raft = object_layer_over_raft; + if (m_config.reduce_crossing_wall) + m_avoid_crossing_perimeters.init_layer(*m_layer); + + if (this->config().gcode_label_objects) { + gcode += std::string("; printing object ") + instance_to_print.print_object.model_object()->name + + " id:" + std::to_string(instance_to_print.print_object.get_id()) + " copy " + + std::to_string(inst.id) + "\n"; + } + if (m_enable_exclude_object) { + if (is_BBL_Printer()) { + m_writer.set_object_start_str( + std::string("; start printing object, unique label id: ") + + std::to_string(instance_to_print.label_object_id) + "\n" + "M624 " + + _encode_label_ids_to_base64({instance_to_print.label_object_id}) + "\n"); + } else { + const auto gflavor = print.config().gcode_flavor.value; + if (gflavor == gcfKlipper) { + m_writer.set_object_start_str(std::string("EXCLUDE_OBJECT_START NAME=") + + get_instance_name(&instance_to_print.print_object, inst.id) + "\n"); + } else if (gflavor == gcfMarlinLegacy || gflavor == gcfMarlinFirmware || gflavor == gcfRepRapFirmware) { + m_writer.set_object_start_str(std::string("M486 S") + std::to_string(inst.unique_id) + "\n"); + } + } + } + + m_extrusion_quality_estimator.set_current_object(&instance_to_print.print_object); + + const Point &offset = inst.shift; + std::pair this_object_copy(&instance_to_print.print_object, offset); + if (m_last_obj_copy != this_object_copy) + m_avoid_crossing_perimeters.use_external_mp_once(); + m_last_obj_copy = this_object_copy; + this->set_origin(unscale(offset)); + + // --- Build emission plan --- + // Each entry represents one travel_to_z + extrude pass. Per-object mode produces + // exactly 1 entry (all regions, single sub_z); per-volume mode produces N entries + // for tagged volumes plus an optional entry for untagged residue. + struct SubLayerEmitEntry { + double sub_h; + double sub_z; + std::function region_filter; + bool skip = false; + }; + std::vector emit_plan; + + auto compute_sub_zh = [&](double r1, double r2, double &out_sub_h, double &out_sub_z) { + std::vector sub_heights_local(grp.components_0based.size()); + for (size_t ci = 0; ci < grp.components_0based.size(); ++ci) + sub_heights_local[ci] = (static_cast(ci) == grp.gradient_first_sorted_idx) ? r1 * lh : r2 * lh; + double cum = 0.0; + for (int ci = 0; ci < sub_idx; ++ci) + cum += sub_heights_local[ci]; + out_sub_h = sub_heights_local[sub_idx]; + out_sub_z = print_z - lh + cum + out_sub_h; + }; + + auto gradient_ratios = [](const auto &g) -> std::pair { + double t = (g.total_layers > 0) ? (2.0 * g.current_idx + 1.0) / (2.0 * g.total_layers) : 0.5; + // Custom curve wins over linear range when present; OFF path stays bit-identical. + double r1 = g.curve.empty() + ? (g.gradient_start + (g.gradient_end - g.gradient_start) * t) + : sample_gradient_curve(g.curve, t); + return {r1, 1.0 - r1}; + }; + + // Orca splits BBS's three role filaments into five; a region belongs to the slot + // when any of its roles is assigned to it. + auto region_uses_slot = [](const PrintRegionConfig &rcfg, unsigned int slot_1b) { + return (unsigned int)rcfg.outer_wall_filament_id.value == slot_1b + || (unsigned int)rcfg.inner_wall_filament_id.value == slot_1b + || (unsigned int)rcfg.sparse_infill_filament_id.value == slot_1b + || (unsigned int)rcfg.internal_solid_filament_id.value == slot_1b + || (unsigned int)rcfg.top_surface_filament_id.value == slot_1b + || (unsigned int)rcfg.bottom_surface_filament_id.value == slot_1b; + }; + + double obj_sub_z = default_sub_z; + + if (use_per_volume) { + const PrintObject *po = &instance_to_print.print_object; + const unsigned int slot_1b = grp.mixed_slot_0based + 1; + + // Discover tagged volumes and untagged presence for this instance. + std::set tagged_volumes_present; + bool has_untagged_for_slot = false; + for (ObjectByExtruder::Island &island : instance_to_print.object_by_extruder.islands) { + for (size_t r = 0; r < island.by_region.size(); ++r) { + const auto ®ion = island.by_region[r]; + if (region.perimeters.empty() && region.infills.empty()) + continue; + const PrintRegion &pr = print.get_print_region(r); + if (!region_uses_slot(pr.config(), slot_1b)) + continue; + ObjectID vid = pr.gradient_volume_id(); + if (vid.valid()) + tagged_volumes_present.insert(vid); + else + has_untagged_for_slot = true; + } + } + + // One entry per tagged volume. + for (const ObjectID &target_vid : tagged_volumes_present) { + auto vg_it = grp.per_volume_gradient.find({po, target_vid}); + if (vg_it == grp.per_volume_gradient.end()) + continue; + const auto &vg = vg_it->second; + auto [r1, r2] = gradient_ratios(vg); + + bool vol_no_split = false; + bool skip_entry = false; + const size_t n = grp.components_0based.size(); + if (n == 2 && vg.current_idx + 1 == vg.total_layers) { + const size_t dom_idx = (r1 >= r2) ? 0 : 1; + const unsigned int first_sorted_comp = grp.components_0based[grp.gradient_first_sorted_idx]; + const unsigned int other_comp = grp.components_0based[1 - grp.gradient_first_sorted_idx]; + const unsigned int dom_0b = (dom_idx == 0) ? first_sorted_comp : other_comp; + const unsigned int oth_0b = (dom_idx == 0) ? other_comp : first_sorted_comp; + if (dom_0b < oth_0b) { + vol_no_split = true; + if (extruder_id != dom_0b) + skip_entry = true; + } + } + + double vol_sub_h = default_sub_h; + double vol_sub_z = default_sub_z; + if (vol_no_split) { + vol_sub_h = lh; + vol_sub_z = print_z; + } else { + compute_sub_zh(r1, r2, vol_sub_h, vol_sub_z); + } + + emit_plan.push_back({vol_sub_h, vol_sub_z, + [target_vid, &print](size_t r) { + return print.get_print_region(r).gradient_volume_id() == target_vid; + }, + skip_entry}); + } + + // Optional entry for untagged regions (modifier / painted / fuzzy_skin). + if (has_untagged_for_slot) { + double obj_sub_h = default_sub_h; + auto og_it = grp.per_object_gradient.find(po); + if (og_it != grp.per_object_gradient.end()) { + auto [r1, r2] = gradient_ratios(og_it->second); + compute_sub_zh(r1, r2, obj_sub_h, obj_sub_z); + } + emit_plan.push_back({obj_sub_h, obj_sub_z, + [&print](size_t r) { + return !print.get_print_region(r).gradient_volume_id().valid(); + }, + false}); + } + } else { + // Legacy per-object path: single entry, no region filter. + double legacy_sub_h = default_sub_h; + obj_sub_z = default_sub_z; + if (grp.is_gradient) { + auto og_it = grp.per_object_gradient.find(&instance_to_print.print_object); + if (og_it != grp.per_object_gradient.end()) { + auto [r1, r2] = gradient_ratios(og_it->second); + compute_sub_zh(r1, r2, legacy_sub_h, obj_sub_z); + } + } + emit_plan.push_back({legacy_sub_h, obj_sub_z, nullptr, false}); + } + + // --- Unified emission loop --- + auto plan_has_infill = [](const std::vector &by_region) { + for (const auto &r : by_region) + if (!r.infills.empty()) + return true; + return false; + }; + + for (auto &entry : emit_plan) { + if (entry.skip) + continue; + m_sub_layer_flow_ratio = entry.sub_h / lh; + m_sub_layer_height = entry.sub_h; + m_nominal_z = entry.sub_z; + // Use the same lazy-Z mechanism as change_layer(): set the flag so travel_to + // fires even when m_last_pos coincides with the first extrusion point, + // ensuring Z reaches sub_z via the combined XY+Z move. + m_need_change_layer_lift_z = true; + + for (ObjectByExtruder::Island &island : instance_to_print.object_by_extruder.islands) { + const auto &src = island.by_region; + std::vector subset_storage; + if (entry.region_filter) { + subset_storage.resize(src.size()); + for (size_t r = 0; r < src.size(); ++r) + if (entry.region_filter(r)) + subset_storage[r] = src[r]; + } + const auto &by_region_specific = entry.region_filter ? subset_storage : src; + + // Orca resolves infill-first per region inside extrude_perimeters() + // (unlike BBS, which branches on a single global flag), so mirror the + // main instance loop's ordering exactly. + gcode += this->extrude_perimeters(print, by_region_specific, first_layer, false); + if (!has_wipe_tower && need_insert_timelapse_gcode_for_traditional + && printer_structure == PrinterStructure::psI3 + && !has_insert_timelapse_gcode && plan_has_infill(by_region_specific)) { + gcode += this->retract(false, false, auto_lift_type, true); + gcode += insert_timelapse_gcode(); + has_insert_timelapse_gcode = true; + } + gcode += this->extrude_infill(print, by_region_specific, false); + gcode += this->extrude_perimeters(print, by_region_specific, first_layer, true); + // ironing + gcode += this->extrude_infill(print, by_region_specific, true); + } + } + + // --- Shared support --- + if (instance_to_print.object_by_extruder.support && !instance_to_print.object_by_extruder.support->empty()) { + if (use_per_volume) { + m_nominal_z = obj_sub_z; + m_need_change_layer_lift_z = true; + } + ExtrusionRole support_role = instance_to_print.object_by_extruder.support_extrusion_role; + gcode += this->extrude_support(*instance_to_print.object_by_extruder.support, support_role); + // Make sure ironing is the last (Orca names this role erIroning, not erSupportIroning). + if (support_role == erMixed || support_role == erSupportMaterialInterface) + gcode += this->extrude_support(*instance_to_print.object_by_extruder.support, erIroning); + } + + // --- Shared instance footer (mirrors Orca's main instance loop) --- + if (!m_writer.is_object_start_str_empty()) { + m_writer.set_object_start_str(""); + } else if (m_enable_exclude_object) { + if (is_BBL_Printer()) { + m_writer.set_object_end_str(std::string("; stop printing object, unique label id: ") + + std::to_string(instance_to_print.label_object_id) + "\n" + + "M625\n"); + } else { + const auto gflavor = print.config().gcode_flavor.value; + if (gflavor == gcfKlipper) { + m_writer.set_object_end_str(std::string("EXCLUDE_OBJECT_END NAME=") + + get_instance_name(&instance_to_print.print_object, inst.id) + "\n"); + } else if (gflavor == gcfMarlinLegacy || gflavor == gcfMarlinFirmware || gflavor == gcfRepRapFirmware) { + m_writer.set_object_end_str(std::string("M486 S-1\n")); + } + } + } + } + + m_sub_layer_flow_ratio = 0.0; + m_sub_layer_height = 0.0; + } + // Flush any pending object end label before leaving the sublayer block, otherwise the + // wipe tower's add_object_end_labels may consume it into a local temp string and the + // M625 would be lost for BBL printers. + if (!layer_tools.mixed_sub_layer_groups.empty()) { + m_writer.add_object_end_labels(gcode); + m_nominal_z = print_z; + m_need_change_layer_lift_z = true; + } + } if (first_layer) { for (auto iter = by_extruder.begin(); iter != by_extruder.end(); ++iter) { @@ -7634,6 +7978,15 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, } } + // Mixed-color sublayer: this path belongs to one sub-layer of a split layer, so scale the + // flow down to that sub-layer's share of the nominal layer height and report the sub-height + // as the effective extrusion height. Inert (ratio == 0) outside the sublayer emission block. + float effective_height = path.height; + if (m_sub_layer_flow_ratio > 0.0) { + _mm3_per_mm *= m_sub_layer_flow_ratio; + effective_height = static_cast(m_sub_layer_height); + } + // Effective extrusion length per distance unit = (filament_flow_ratio/cross_section) * mm3_per_mm / print flow ratio // m_writer.extruder()->e_per_mm3() below is (filament flow ratio / cross-sectional area) double e_per_mm = m_writer.filament()->e_per_mm3() * _mm3_per_mm; @@ -7933,8 +8286,8 @@ std::string GCode::_extrude(const ExtrusionPath &path, std::string description, gcode += buf; } - if (last_was_wipe_tower || std::abs(m_last_height - path.height) > EPSILON) { - m_last_height = path.height; + if (last_was_wipe_tower || std::abs(m_last_height - effective_height) > EPSILON) { + m_last_height = effective_height; sprintf(buf, ";%s%g\n", GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Height).c_str(), m_last_height); gcode += buf; } @@ -8726,7 +9079,7 @@ bool GCode::needs_retraction(const Polyline &travel, ExtrusionRole role, LiftTyp continue; Polygons temp; - temp.emplace_back(std::move(instance_bbox.polygon())); + temp.emplace_back(instance_bbox.polygon()); if (intersection_pl(travel, temp).empty()) continue; diff --git a/src/libslic3r/GCode.hpp b/src/libslic3r/GCode.hpp index 6bdb04a8a9..990bf0fee7 100644 --- a/src/libslic3r/GCode.hpp +++ b/src/libslic3r/GCode.hpp @@ -747,6 +747,11 @@ private: Print* m_curr_print = nullptr; unsigned int m_toolchange_count; coordf_t m_nominal_z; + // Mixed-color sublayer state. Non-zero only while emitting a mixed slot's sub-layer: + // scales extrusion flow to the sub-layer's share of the nominal layer height, and + // reports that sub-height as the effective extrusion height. Reset to 0 afterwards. + double m_sub_layer_flow_ratio = 0.0; + double m_sub_layer_height = 0.0; bool m_need_change_layer_lift_z = false; int m_start_gcode_filament = -1; std::string m_filament_instances_code; diff --git a/src/libslic3r/GCode/GCodeProcessor.cpp b/src/libslic3r/GCode/GCodeProcessor.cpp index b13273d696..4f3f95f297 100644 --- a/src/libslic3r/GCode/GCodeProcessor.cpp +++ b/src/libslic3r/GCode/GCodeProcessor.cpp @@ -2543,6 +2543,7 @@ void GCodeProcessorResult::reset() { spiral_vase_mode = false; layer_filaments.clear(); filament_change_sequence.clear(); + used_mixed_filaments.clear(); nozzle_change_sequence.clear(); optimal_assignment.clear(); filament_change_count_map.clear(); diff --git a/src/libslic3r/GCode/GCodeProcessor.hpp b/src/libslic3r/GCode/GCodeProcessor.hpp index 505f7c06a0..0f211f133e 100644 --- a/src/libslic3r/GCode/GCodeProcessor.hpp +++ b/src/libslic3r/GCode/GCodeProcessor.hpp @@ -306,6 +306,9 @@ class Print; std::unordered_map, std::vector>,FilamentSequenceHash> layer_filaments; std::vector nozzle_change_sequence; std::vector filament_change_sequence; + // 0-based mixed (virtual) filament slots actually used on this plate. + // Recorded before resolve_mixed_filaments expands them to physical components. + std::vector used_mixed_filaments; std::vector optimal_assignment; // first key stores `from` filament, second keys stores the `to` filament std::map, int > filament_change_count_map; @@ -357,6 +360,7 @@ class Print; printer_extruder_id = other.printer_extruder_id; layer_filaments = other.layer_filaments; filament_change_sequence = other.filament_change_sequence; + used_mixed_filaments = other.used_mixed_filaments; nozzle_change_sequence = other.nozzle_change_sequence; optimal_assignment = other.optimal_assignment; filament_change_count_map = other.filament_change_count_map; diff --git a/src/libslic3r/GCode/ToolOrdering.cpp b/src/libslic3r/GCode/ToolOrdering.cpp index f37025d4e7..0a97e7ac41 100644 --- a/src/libslic3r/GCode/ToolOrdering.cpp +++ b/src/libslic3r/GCode/ToolOrdering.cpp @@ -7,6 +7,8 @@ #include "GCode/ToolOrderUtils.hpp" #include "FilamentGroupUtils.hpp" #include "MultiNozzleUtils.hpp" +#include "FilamentMixer.hpp" +#include "LocalesUtils.hpp" #include "Utils.hpp" #include "I18N.hpp" @@ -22,8 +24,13 @@ #endif #include +#include #include #include +#include +#include +#include +#include #include #include @@ -84,22 +91,28 @@ bool check_filament_printable_after_group(const std::vector &used_ } // Return a zero based extruder from the region, or extruder_override if overriden. +// The region accessors below resolve mixed-color slots to the physical filament chosen for this +// layer by resolve_mixed_filaments(), because a virtual slot id is never a real tool. resolve_mixed() +// returns its argument unchanged for every filament that is not a mixed slot. unsigned int LayerTools::wall_extruder_id(const PrintRegion ®ion) const { assert(region.config().outer_wall_filament_id.value > 0); - return ((this->extruder_override == 0) ? region.config().outer_wall_filament_id.value : this->extruder_override) - 1; + unsigned int result = ((this->extruder_override == 0) ? region.config().outer_wall_filament_id.value : this->extruder_override) - 1; + return resolve_mixed(result); } unsigned int LayerTools::sparse_infill_filament_id(const PrintRegion ®ion) const { assert(region.config().sparse_infill_filament_id.value > 0); - return ((this->extruder_override == 0) ? region.config().sparse_infill_filament_id.value : this->extruder_override) - 1; + unsigned int result = ((this->extruder_override == 0) ? region.config().sparse_infill_filament_id.value : this->extruder_override) - 1; + return resolve_mixed(result); } unsigned int LayerTools::internal_solid_filament_id(const PrintRegion ®ion) const { assert(region.config().internal_solid_filament_id.value > 0); - return ((this->extruder_override == 0) ? region.config().internal_solid_filament_id.value : this->extruder_override) - 1; + unsigned int result = ((this->extruder_override == 0) ? region.config().internal_solid_filament_id.value : this->extruder_override) - 1; + return resolve_mixed(result); } // Returns a zero based extruder this eec should be printed with, according to PrintRegion config or extruder_override if overriden. @@ -135,7 +148,8 @@ unsigned int LayerTools::extruder(const ExtrusionEntityCollection &extrusions, c } else extruder = this->extruder_override; - return (extruder == 0) ? 0 : extruder - 1; + unsigned int result = (extruder == 0) ? 0 : extruder - 1; + return resolve_mixed(result); } static double calc_max_layer_height(const PrintConfig &config, double max_object_layer_height) @@ -402,7 +416,9 @@ void ToolOrdering::sort_and_build_data(const Print& print, unsigned int first_ex // if first extruder is -1, we can decide the first layer tool order before doing reorder function // so we shouldn't reorder first layer in reorder function bool reorder_first_layer = (first_extruder != (unsigned int)(-1)); + this->resolve_mixed_filaments(print.config()); reorder_extruders_for_minimum_flush_volume(reorder_first_layer); + this->enforce_mixed_component_order(); m_sorted = true; double max_layer_height = 0.; @@ -422,6 +438,9 @@ void ToolOrdering::sort_and_build_data(const Print& print, unsigned int first_ex this->fill_wipe_tower_partitions(print.config(), object_bottom_z, max_layer_height); if (this->insert_wipe_tower_extruder()) { reorder_extruders_for_minimum_flush_volume(reorder_first_layer); + // Orca reorders a second time here (BBS has no such path); re-enforce so the + // mixed sub-layer component order survives the extra pass. + this->enforce_mixed_component_order(); this->fill_wipe_tower_partitions(print.config(), object_bottom_z, max_layer_height); } @@ -433,7 +452,9 @@ void ToolOrdering::sort_and_build_data(const PrintObject& object , unsigned int // if first extruder is -1, we can decide the first layer tool order before doing reorder function // so we shouldn't reorder first layer in reorder function bool reorder_first_layer = (first_extruder != (unsigned int)(-1)); + this->resolve_mixed_filaments(object.print()->config()); reorder_extruders_for_minimum_flush_volume(reorder_first_layer); + this->enforce_mixed_component_order(); m_sorted = true; double max_layer_height = calc_max_layer_height(object.print()->config(), object.config().layer_height); @@ -441,6 +462,9 @@ void ToolOrdering::sort_and_build_data(const PrintObject& object , unsigned int this->fill_wipe_tower_partitions(object.print()->config(), object.layers().front()->print_z - object.layers().front()->height, max_layer_height); if (this->insert_wipe_tower_extruder()) { reorder_extruders_for_minimum_flush_volume(reorder_first_layer); + // Orca reorders a second time here (BBS has no such path); re-enforce so the + // mixed sub-layer component order survives the extra pass. + this->enforce_mixed_component_order(); this->fill_wipe_tower_partitions(object.print()->config(), object.layers().front()->print_z - object.layers().front()->height, max_layer_height); } @@ -723,6 +747,38 @@ void ToolOrdering::collect_extruders(const PrintObject &object, const std::vecto it_per_layer_extruder_override = per_layer_extruder_switches.begin(); unsigned int extruder_override = 0; + // Pre-compute 1-based IDs of mixed filament slots for per-object tracking. + // mixed_slots_1based covers ALL mixed slots (needed by calc_slot_lh for + // accurate layer height when a slot skips layers). gradient_slots_1based + // and per_part_slots_1based are subsets for gradient-specific logic. + std::set mixed_slots_1based; + std::set gradient_slots_1based; + std::set per_part_slots_1based; + { + const PrintConfig &cfg = object.print()->config(); + const auto &is_mixed = cfg.filament_is_mixed.values; + const auto &grad_flags = cfg.filament_mixed_gradient.values; + const auto &per_part_flags = cfg.filament_mixed_gradient_per_part.values; + const auto &comp_strs = cfg.filament_mixed_components.values; + for (size_t i = 0; i < is_mixed.size(); ++i) { + if (!is_mixed[i]) + continue; + auto comps = parse_mixed_components(i < comp_strs.size() ? comp_strs[i] : ""); + if (comps.size() < 2) + continue; + mixed_slots_1based.insert(static_cast(i + 1)); + // Gradient/per-part are only defined for 2-component slots; keep their + // tracking limited to them (mirrors the is_gradient guard at resolve time). + if (comps.size() != 2) + continue; + if (i >= grad_flags.size() || !grad_flags[i]) + continue; + gradient_slots_1based.insert(static_cast(i + 1)); + if (i < per_part_flags.size() && per_part_flags[i]) + per_part_slots_1based.insert(static_cast(i + 1)); + } + } + // BBS: collect first layer extruders of an object's wall, which will be used by brim generator int layerCount = 0; std::vector firstLayerExtruders; @@ -732,6 +788,9 @@ void ToolOrdering::collect_extruders(const PrintObject &object, const std::vecto for (auto layer : object.layers()) { LayerTools &layer_tools = this->tools_for_layer(layer->print_z); + m_object_all_layer_indices[&object].push_back( + static_cast(&layer_tools - m_layer_tools.data())); + // Override extruder with the next for (; it_per_layer_extruder_override != per_layer_extruder_switches.end() && it_per_layer_extruder_override->first < layer->print_z + EPSILON; ++ it_per_layer_extruder_override) extruder_override = (int)it_per_layer_extruder_override->second; @@ -739,6 +798,9 @@ void ToolOrdering::collect_extruders(const PrintObject &object, const std::vecto // Store the current extruder override (set to zero if no overriden), so that layer_tools.wiping_extrusions().is_overridable_and_mark() will use it. layer_tools.extruder_override = extruder_override; + // Snapshot extruders before this object's regions to track new additions. + const size_t ext_snapshot = layer_tools.extruders.size(); + // What extruders are required to print this object layer? for (const LayerRegion *layerm : layer->regions()) { const PrintRegion ®ion = layerm->region(); @@ -805,6 +867,54 @@ void ToolOrdering::collect_extruders(const PrintObject &object, const std::vecto if (has_internal_solid || has_top_solid_surface || has_bottom_surface || has_infill) layer_tools.has_object = true; } + + // Record mixed slot usage for this object at this layer. + // All mixed slots are tracked (not just gradient) so that calc_slot_lh + // can compute accurate layer heights even when a slot skips layers. + if (!mixed_slots_1based.empty()) { + size_t layer_idx = static_cast(&layer_tools - m_layer_tools.data()); + std::set seen; + for (size_t ei = ext_snapshot; ei < layer_tools.extruders.size(); ++ei) { + unsigned int ext_1based = layer_tools.extruders[ei]; + if (mixed_slots_1based.count(ext_1based) && seen.insert(ext_1based).second) + m_mixed_object_layers[ext_1based - 1][&object].push_back(layer_idx); + } + } + + // Per-part gradient: walk LayerRegions and record which (slot, ModelVolume) pairs + // contributed to this layer. Only regions tagged by PrintApply.cpp's get_create_region + // (i.e. gradient_volume_id().valid()) are considered, so this loop is a strict no-op + // unless per_part_gradient is enabled for at least one slot AND the corresponding + // ModelObject has >=2 model-part volumes using that slot. The per-object pass above is + // unaffected — both run the same layer's data through orthogonal containers. + if (!per_part_slots_1based.empty()) { + size_t layer_idx = static_cast(&layer_tools - m_layer_tools.data()); + std::set> vol_seen; + for (const LayerRegion *layerm : layer->regions()) { + if (layerm->slices.empty()) + continue; + const PrintRegion ®ion = layerm->region(); + ObjectID vol_id = region.gradient_volume_id(); + if (! vol_id.valid()) + continue; + const PrintRegionConfig &rcfg = region.config(); + // Orca splits BBS's three role slots into five; cover them all so a mixed + // slot used by any role is tracked. + const unsigned int role_slots[5] = { + static_cast(rcfg.outer_wall_filament_id.value), + static_cast(rcfg.inner_wall_filament_id.value), + static_cast(rcfg.sparse_infill_filament_id.value), + static_cast(rcfg.top_surface_filament_id.value), + static_cast(rcfg.bottom_surface_filament_id.value), + }; + for (unsigned int ext_1based : role_slots) { + if (ext_1based >= 1 + && per_part_slots_1based.count(ext_1based) + && vol_seen.insert({ext_1based, vol_id}).second) + m_gradient_volume_layers[ext_1based - 1][{&object, vol_id}].push_back(layer_idx); + } + } + } layerCount++; } @@ -903,7 +1013,7 @@ void ToolOrdering::fill_wipe_tower_partitions(const PrintConfig &config, coordf_ //FIXME this is a hack to get the ball rolling. for (LayerTools < : m_layer_tools) - lt.has_wipe_tower |= (lt.has_object && (config.timelapse_type == TimelapseType::tlSmooth || lt.wipe_tower_partitions > 0)) + lt.has_wipe_tower |= ((lt.has_object || lt.has_support) && (config.timelapse_type == TimelapseType::tlSmooth || lt.wipe_tower_partitions > 0)) || lt.print_z < object_bottom_z + EPSILON; // Test for a raft, insert additional wipe tower layer to fill in the raft separation gap. @@ -944,6 +1054,84 @@ void ToolOrdering::fill_wipe_tower_partitions(const PrintConfig &config, coordf_ } } + // Ensure wipe tower vertical continuity: + // + // (1) Any existing LayerTools sandwiched between two has_wipe_tower layers must itself be a + // wipe-tower layer. The LayerTools entry already exists, but it has neither object nor + // support geometry (has_object == false && has_support == false), so the marking pass + // above leaves has_wipe_tower == false. Happens e.g. when one object is fully floating + // above another and the support_top_z_distance / support_bottom_z_distance gap leaves an + // interior layer with no object and no support (e.g. B top z=20.4, A first layer z=20.8, + // the z=20.6 LayerTools entry exists but stays unmarked). + // + // (2) When two adjacent has_wipe_tower layers are farther apart than max_layer_height and no + // LayerTools entry exists between them, insert virtual wipe-tower-only layers to bridge + // the gap. Happens with raft: BambuStudio's raft contact layer can be thicker than + // max_layer_height (e.g. raft base top z=0.2, raft contact top z=0.5 — gap 0.3 > 0.28), + // and there is no LayerTools entry between those two z values. + // + // wipe_tower_partitions has already been max-propagated downward above, so partition counts + // on the filled-in / inserted layers stay consistent. + { + int first_wt_idx = -1; + int last_wt_idx = -1; + for (int i = 0; i < (int)m_layer_tools.size(); ++i) + if (m_layer_tools[i].has_wipe_tower) { + if (first_wt_idx < 0) first_wt_idx = i; + last_wt_idx = i; + } + for (int i = first_wt_idx + 1; i < last_wt_idx; ++i) { + LayerTools < = m_layer_tools[i]; + lt.has_wipe_tower = true; + // GCode::process_layer emits wipe-tower G-code inside `for (extruder_id : layer_tools.extruders)`. + // An empty extruders vector here would silently skip wipe tower output, leaving the tower + // physically floating. Seed from the nearest non-empty neighbor so the loop actually runs. + if (lt.extruders.empty()) { + unsigned int seed_extruder = 0; + bool found_seed = false; + for (int j = i - 1; j >= 0; --j) + if (!m_layer_tools[j].extruders.empty()) { + seed_extruder = m_layer_tools[j].extruders.back(); + found_seed = true; + break; + } + if (!found_seed) + for (int j = i + 1; j < (int)m_layer_tools.size(); ++j) + if (!m_layer_tools[j].extruders.empty()) { + seed_extruder = m_layer_tools[j].extruders.front(); + found_seed = true; + break; + } + if (found_seed) + lt.extruders.push_back(seed_extruder); + } + } + + // Walk adjacent has_wipe_tower pairs and split oversized gaps. Re-evaluate the same i + // after each insertion so very large gaps get split into multiple layers. + for (int i = 0; i + 1 < (int)m_layer_tools.size(); ) { + LayerTools < = m_layer_tools[i]; + LayerTools <_next = m_layer_tools[i + 1]; + if (!lt.has_wipe_tower || !lt_next.has_wipe_tower) { + ++i; + continue; + } + coordf_t gap = lt_next.print_z - lt.print_z; + if (gap <= max_layer_height + EPSILON) { + ++i; + continue; + } + LayerTools lt_new(0.5 * (lt.print_z + lt_next.print_z)); + lt_new.has_wipe_tower = true; + if (!lt_next.extruders.empty()) + lt_new.extruders.push_back(lt_next.extruders.front()); + else if (!lt.extruders.empty()) + lt_new.extruders.push_back(lt.extruders.back()); + lt_new.wipe_tower_partitions = lt_next.wipe_tower_partitions; + m_layer_tools.insert(m_layer_tools.begin() + i + 1, lt_new); + } + } + // If the model contains empty layers (such as https://github.com/prusa3d/Slic3r/issues/1266), there might be layers // that were not marked as has_wipe_tower, even when they should have been. This produces a crash with soluble supports // and maybe other problems. We will therefore go through layer_tools and detect and fix this. @@ -1945,6 +2133,605 @@ MultiNozzleUtils::LayeredNozzleGroupResult ToolOrdering::build_sequential_group_ return result ? *result : MultiNozzleUtils::LayeredNozzleGroupResult(); } +static double snap_to_simple_fraction(double r, int max_denom = 10) +{ + double best_r = r; + double best_err = 1.0; + for (int q = 1; q <= max_denom; ++q) { + int p = (int)std::round(r * q); + if (p < 0) p = 0; + if (p > q) p = q; + double candidate = (double)p / q; + double err = std::abs(candidate - r); + if (err < best_err) { + best_err = err; + best_r = candidate; + } + } + return best_r; +} + +void ToolOrdering::resolve_mixed_filaments(const PrintConfig &config) +{ + const auto &is_mixed = config.filament_is_mixed.values; + const auto &comp_strs = config.filament_mixed_components.values; + const auto &ratio_strs = config.filament_mixed_sublayer_ratios.values; + + // Capture mixed slots that actually appear on layers before they are expanded to + // physical components. Assigned-but-unused mixed slots never enter layer_tools. + m_used_mixed_filaments.clear(); + if (has_any_mixed_filament(is_mixed)) { + std::set used; + for (const LayerTools < : m_layer_tools) + for (unsigned int ext : lt.extruders) + if (ext < is_mixed.size() && is_mixed[ext]) + used.insert(ext); + m_used_mixed_filaments.assign(used.begin(), used.end()); + } + + if (!has_any_mixed_filament(is_mixed)) + return; + + const bool sublayer_enabled = config.enable_mixed_color_sublayer.value; + + struct SlotInfo { + std::vector components; // 1-based + std::vector ratios; + std::vector accum; // deficit accumulator (integer, unit: 1e-6 mm) + }; + std::vector slots(is_mixed.size()); + for (size_t i = 0; i < is_mixed.size(); ++i) { + if (!is_mixed[i]) + continue; + slots[i].components = parse_mixed_components(i < comp_strs.size() ? comp_strs[i] : ""); + if (slots[i].components.size() < 2) { + slots[i].components.clear(); + continue; + } + for (unsigned int cid : slots[i].components) { + unsigned int idx0 = cid - 1; + if (idx0 >= is_mixed.size() || (idx0 < is_mixed.size() && is_mixed[idx0])) { + slots[i].components.clear(); + break; + } + } + if (slots[i].components.empty()) + continue; + slots[i].ratios = parse_mixed_ratios( + i < ratio_strs.size() ? ratio_strs[i] : "", slots[i].components.size()); + if (!sublayer_enabled) { + for (double &r : slots[i].ratios) + r = snap_to_simple_fraction(r); + double sum = 0; + for (double r : slots[i].ratios) sum += r; + if (sum > 0) + for (double &r : slots[i].ratios) r /= sum; + } + slots[i].accum.assign(slots[i].components.size(), 0LL); + } + + // Parse gradient settings per slot + const auto &gradient_flags = config.filament_mixed_gradient.values; + const auto &gradient_range_strs = config.filament_mixed_gradient_range.values; + const auto &gradient_curve_strs = config.filament_mixed_gradient_curve.values; + struct GradientInfo { + double start = 0.10; + double end_val = 0.90; + GradientCurve curve; // empty -> use linear (start, end_val); non-empty wins + }; + std::vector is_gradient(is_mixed.size(), false); + std::vector gradient_info(is_mixed.size()); + for (size_t i = 0; i < is_mixed.size(); ++i) { + if (!is_mixed[i] || slots[i].components.size() != 2) + continue; + if (i >= gradient_flags.size() || !gradient_flags[i]) + continue; + is_gradient[i] = true; + if (i < gradient_range_strs.size() && !gradient_range_strs[i].empty()) { + CNumericLocalesSetter c_locale_setter; + float v0 = 0, v1 = 0; + if (std::sscanf(gradient_range_strs[i].c_str(), "%f,%f", &v0, &v1) == 2 && + v0 > 0 && v0 < 1.0 && v1 > 0 && v1 < 1.0) { + gradient_info[i].start = v0; + gradient_info[i].end_val = v1; + } + } + if (i < gradient_curve_strs.size() && !gradient_curve_strs[i].empty()) + gradient_info[i].curve = parse_gradient_curve(gradient_curve_strs[i]); + } + + // Pass 1: identify continuous runs for each gradient slot (Per-Run). + // A "run" is a maximal sequence of consecutive layers where the slot appears. + struct GradientRunInfo { + std::vector run_lengths; + int current_run = -1; + size_t current_idx = 0; + bool prev_appeared = false; + bool last_absent_was_relevant = false; + }; + std::map gradient_runs; + for (size_t i = 0; i < is_mixed.size(); ++i) + if (is_gradient[i]) gradient_runs[static_cast(i)] = {}; + + // Build per-slot sets of all layer indices where any slot-owning object has a + // layer. Used by gradient run detection (a gap is real only if the slot is + // absent at a layer belonging to one of its own objects) and by calc_slot_lh + // to keep prev_relevant_z_for_slot current even when a slot skips many layers. + std::map> slot_relevant_layers; + for (auto &[slot_idx, obj_map] : m_mixed_object_layers) { + for (auto &[obj, _] : obj_map) { + auto it = m_object_all_layer_indices.find(obj); + if (it != m_object_all_layer_indices.end()) + slot_relevant_layers[slot_idx].insert(it->second.begin(), it->second.end()); + } + } + + if (!gradient_runs.empty()) { + for (size_t li = 0; li < m_layer_tools.size(); ++li) { + if (li == 0) continue; + const auto < = m_layer_tools[li]; + for (auto &[slot, run] : gradient_runs) { + bool here = std::find(lt.extruders.begin(), lt.extruders.end(), slot) != lt.extruders.end(); + if (here) { + bool real_gap = false; + if (!run.prev_appeared && !run.run_lengths.empty()) { + real_gap = run.last_absent_was_relevant; + } + if (run.run_lengths.empty() || real_gap) + run.run_lengths.push_back(0); + run.run_lengths.back()++; + run.last_absent_was_relevant = false; + } else if (!run.run_lengths.empty()) { + auto rel_it = slot_relevant_layers.find(slot); + if (rel_it != slot_relevant_layers.end() && rel_it->second.count(li)) + run.last_absent_was_relevant = true; + } + run.prev_appeared = here; + } + } + for (auto &[slot, run] : gradient_runs) { + run.current_run = -1; + run.current_idx = 0; + run.prev_appeared = false; + run.last_absent_was_relevant = false; + } + } + + // Per-object gradient: pre-compute per-object runs (respecting Z gaps within each object). + struct PerObjRunState { + std::vector run_start_offsets; // index into layer_indices where each run starts + std::vector run_lengths; + int current_run = -1; + size_t current_idx = 0; + }; + + // Detect whether a gap between two consecutive gradient-slot appearances is a + // real run break. A gap is real only if the object has its own layer inside the + // gap that does NOT use the gradient slot (i.e. the slot was genuinely absent). + // Uses lower_bound to skip global indices that don't belong to the object. + auto has_real_gap = [](size_t prev_idx, size_t cur_idx, + const std::set& obj_set, + const std::set& slot_set) -> bool { + for (auto it = obj_set.lower_bound(prev_idx + 1); + it != obj_set.end() && *it < cur_idx; ++it) { + if (!slot_set.count(*it)) + return true; + } + return false; + }; + + // Segment a sorted list of layer indices into runs, using has_real_gap to decide + // where to break. Shared by the per-object and per-volume paths below. + auto segment_runs = [&](const std::vector& layer_indices, + const std::set& obj_set, + const std::set& slot_set) -> PerObjRunState { + PerObjRunState st; + for (size_t i = 0; i < layer_indices.size(); ++i) { + bool new_run = (i == 0) || + has_real_gap(layer_indices[i - 1], layer_indices[i], obj_set, slot_set); + if (new_run) { + st.run_start_offsets.push_back(i); + st.run_lengths.push_back(0); + } + st.run_lengths.back()++; + } + return st; + }; + + std::map> per_obj_runs; + for (auto &[slot, obj_map] : m_mixed_object_layers) { + if (slot >= is_gradient.size() || !is_gradient[slot]) + continue; + for (auto &[obj, layer_indices] : obj_map) { + sort_remove_duplicates(layer_indices); + // Erase layer 0 — this mutation is also relied upon by the Pass 2 binary_search below. + if (!layer_indices.empty() && layer_indices.front() == 0) + layer_indices.erase(layer_indices.begin()); + + const auto &all_obj_layers = m_object_all_layer_indices[obj]; + std::set all_obj_set(all_obj_layers.begin(), all_obj_layers.end()); + std::set grad_set(layer_indices.begin(), layer_indices.end()); + + per_obj_runs[slot][obj] = segment_runs(layer_indices, all_obj_set, grad_set); + } + } + + // Per-volume gradient: mirror the per-object run-segmentation logic above for + // m_gradient_volume_layers. When per_part_gradient is off (or no qualifying volume exists), + // m_gradient_volume_layers is empty and per_vol_runs ends up empty too — so all subsequent + // checks of `per_vol_runs.find(slot) != end()` will fail and the legacy per-object path + // remains the only path taken. + using VolumeKey = LayerTools::MixedSubLayerGroup::VolumeKey; + std::map> per_vol_runs; + for (auto &[slot, vol_map] : m_gradient_volume_layers) { + if (slot >= is_gradient.size() || !is_gradient[slot]) + continue; + for (auto &[vkey, layer_indices] : vol_map) { + sort_remove_duplicates(layer_indices); + if (!layer_indices.empty() && layer_indices.front() == 0) + layer_indices.erase(layer_indices.begin()); + + const auto &all_obj_layers = m_object_all_layer_indices[vkey.obj]; + std::set all_obj_set(all_obj_layers.begin(), all_obj_layers.end()); + std::set vol_grad_set(layer_indices.begin(), layer_indices.end()); + + per_vol_runs[slot][vkey] = segment_runs(layer_indices, all_obj_set, vol_grad_set); + } + } + // Pass 2: resolve per layer + coordf_t prev_print_z = 0.; + // Track last print_z per mixed slot so that layer height is computed from the + // slot's own previous appearance, not from a global Z that may include layers + // belonging only to other objects with different layer heights. + std::map prev_print_z_for_slot; + // Track the last Z where a slot-owning object had ANY layer (regardless of + // whether the slot was present). Used to detect genuine gaps: if the slot was + // absent but its owner objects had layers, prev_relevant_z advances while + // prev_print_z_for_slot stays stale. Taking the max of both gives correct lh. + std::map prev_relevant_z_for_slot; + + // Compute the effective layer height for a mixed slot by choosing the best + // reference Z among: (1) the slot's own last Z, (2) the last Z where the + // slot's owning object had any layer, (3) the global previous Z as fallback + // when the slot appears for the first time. + auto calc_slot_lh = [&](unsigned int ext, coordf_t print_z) -> double { + auto slot_pz_it = prev_print_z_for_slot.find(ext); + auto rel_pz_it = prev_relevant_z_for_slot.find(ext); + coordf_t base_z = prev_print_z; + if (slot_pz_it != prev_print_z_for_slot.end()) { + base_z = slot_pz_it->second; + if (rel_pz_it != prev_relevant_z_for_slot.end()) + base_z = std::max(base_z, rel_pz_it->second); + } + double lh = print_z - base_z; + return (lh > 0.) ? lh : 0.2; // 0.2mm safety fallback; should not trigger in normal operation + }; + + for (LayerTools < : m_layer_tools) { + size_t layer_idx = static_cast(< - m_layer_tools.data()); + + // Update gradient run state (skip first layer to match counting). + if (layer_idx > 0) { + for (auto &[slot, run] : gradient_runs) { + bool here = std::find(lt.extruders.begin(), lt.extruders.end(), slot) != lt.extruders.end(); + if (here) { + if (!run.prev_appeared) { + if (run.last_absent_was_relevant || run.current_run < 0) { + run.current_run++; + run.current_idx = 0; + } + } + run.last_absent_was_relevant = false; + } else { + auto rel_it = slot_relevant_layers.find(slot); + if (rel_it != slot_relevant_layers.end() && rel_it->second.count(layer_idx)) + run.last_absent_was_relevant = true; + } + run.prev_appeared = here; + } + } + + std::vector new_extruders; + for (unsigned int ext : lt.extruders) { + if (ext >= slots.size() || slots[ext].components.empty()) { + new_extruders.push_back(ext); + continue; + } + auto &s = slots[ext]; + + // Skip sublayer splitting for the first layer to preserve bed adhesion. + if (sublayer_enabled && layer_idx > 0) { + double lh = calc_slot_lh(ext, lt.print_z); + size_t n = s.components.size(); + + std::vector sub_heights; + bool gradient_last_no_split = false; + unsigned int gradient_last_dominant_0b = 0; + if (is_gradient[ext] && n == 2) { + auto gr_it = gradient_runs.find(ext); + if (gr_it != gradient_runs.end() && gr_it->second.current_run >= 0 && + static_cast(gr_it->second.current_run) < gr_it->second.run_lengths.size()) { + auto &run = gr_it->second; + size_t N = run.run_lengths[run.current_run]; + size_t idx = run.current_idx++; + double t = (N > 0) ? (2.0 * idx + 1.0) / (2.0 * N) : 0.5; + // Custom curve wins over linear range when present; OFF path stays bit-identical. + double r1 = gradient_info[ext].curve.empty() + ? (gradient_info[ext].start + (gradient_info[ext].end_val - gradient_info[ext].start) * t) + : sample_gradient_curve(gradient_info[ext].curve, t); + double r2 = 1.0 - r1; + sub_heights.push_back(r1 * lh); + sub_heights.push_back(r2 * lh); + // The sublayer split path sorts components by physical ID ascending; + // the higher-ID component ends up on top (visible surface). If the + // gradient's dominant component has the lower physical ID, splitting + // would put the non-dominant color on the visible top surface. In + // that case, skip the split and print this final run-layer as pure + // dominant color to preserve the gradient appearance. + if (idx == N - 1) { + // When r1 == r2 (exactly 50/50), component[0] is treated as dominant. + size_t dominant = (r1 >= r2) ? 0 : 1; + unsigned int dom_0b = s.components[dominant] - 1; + unsigned int oth_0b = s.components[1 - dominant] - 1; + if (dom_0b < oth_0b) { + gradient_last_no_split = true; + gradient_last_dominant_0b = dom_0b; + } + } + } else { + for (double r : s.ratios) + sub_heights.push_back(r * lh); + } + } else { + for (double r : s.ratios) + sub_heights.push_back(r * lh); + } + + // Per-part gradient: when this slot has any qualifying volume, the global + // no-split short-circuit must NOT bypass MixedSubLayerGroup creation — each + // volume needs its own no-split decision in GCode.cpp (a per-volume "last + // run-layer" can occur on a different layer index than the per-object one). We + // still keep the per-object short-circuit when per_vol_runs[ext] is empty, which + // covers the legacy path bit-identically. + bool per_vol_active_for_slot = per_vol_runs.find(ext) != per_vol_runs.end() + && !per_vol_runs[ext].empty(); + + if (gradient_last_no_split && !per_vol_active_for_slot) { + lt.mixed_filament_resolution[ext] = gradient_last_dominant_0b; + new_extruders.push_back(gradient_last_dominant_0b); + prev_print_z_for_slot[ext] = lt.print_z; + continue; + } + + LayerTools::MixedSubLayerGroup grp; + grp.mixed_slot_0based = ext; + grp.layer_height = lh; + grp.is_gradient = is_gradient[ext]; + for (size_t k = 0; k < s.components.size(); ++k) { + unsigned int comp_0based = s.components[k] - 1; + grp.components_0based.push_back(comp_0based); + } + grp.sub_heights = sub_heights; + + // Write gradient metadata (run-aware). Both per_object_gradient and + // per_volume_gradient are populated independently from their own run-state + // machines; the GCode emitter chooses per-region: + // - tagged region (gradient_volume_id valid) -> per_volume_gradient[{obj, vol}] + // - untagged region (modifier / painted / etc.) -> per_object_gradient[obj] + // Populating both keeps the per-object run state correct even when per-volume + // takes over for the same (slot, obj), and lets untagged geometry (which is + // never split per-volume) keep its per-object gradient ratios. + if (grp.is_gradient) { + auto vol_runs_slot_it = per_vol_runs.find(ext); + if (vol_runs_slot_it != per_vol_runs.end()) { + auto vol_slot_it = m_gradient_volume_layers.find(ext); + for (auto &[vkey, st] : vol_runs_slot_it->second) { + auto &layer_indices = vol_slot_it->second[vkey]; + if (!std::binary_search(layer_indices.begin(), layer_indices.end(), layer_idx)) + continue; + if (st.current_run < 0 || + st.current_idx >= st.run_lengths[st.current_run]) { + st.current_run++; + st.current_idx = 0; + } + size_t run_N = st.run_lengths[st.current_run]; + size_t run_idx = st.current_idx++; + grp.per_volume_gradient[vkey] = { + run_N, + run_idx, + gradient_info[ext].start, + gradient_info[ext].end_val, + gradient_info[ext].curve, + }; + } + } + + auto runs_slot_it = per_obj_runs.find(ext); + if (runs_slot_it != per_obj_runs.end()) { + auto slot_it = m_mixed_object_layers.find(ext); + for (auto &[obj, st] : runs_slot_it->second) { + auto &layer_indices = slot_it->second[obj]; + if (!std::binary_search(layer_indices.begin(), layer_indices.end(), layer_idx)) + continue; + if (st.current_run < 0 || + st.current_idx >= st.run_lengths[st.current_run]) { + st.current_run++; + st.current_idx = 0; + } + size_t run_N = st.run_lengths[st.current_run]; + size_t run_idx = st.current_idx++; + grp.per_object_gradient[obj] = { + run_N, + run_idx, + gradient_info[ext].start, + gradient_info[ext].end_val, + gradient_info[ext].curve, + }; + } + } + } + + if (grp.components_0based.size() > 1) { + unsigned int first_comp_0based = s.components[0] - 1; + std::vector idx(grp.components_0based.size()); + std::iota(idx.begin(), idx.end(), 0); + std::sort(idx.begin(), idx.end(), [&](size_t a, size_t b) { + return grp.components_0based[a] < grp.components_0based[b]; + }); + std::vector sorted_comps; + std::vector sorted_heights; + for (size_t i : idx) { + sorted_comps.push_back(grp.components_0based[i]); + sorted_heights.push_back(grp.sub_heights[i]); + } + grp.components_0based = std::move(sorted_comps); + grp.sub_heights = std::move(sorted_heights); + if (grp.is_gradient) { + for (size_t i = 0; i < grp.components_0based.size(); ++i) { + if (grp.components_0based[i] == first_comp_0based) { + grp.gradient_first_sorted_idx = static_cast(i); + break; + } + } + } + } + + for (unsigned int comp : grp.components_0based) + new_extruders.push_back(comp); + lt.mixed_sub_layer_groups.push_back(std::move(grp)); + prev_print_z_for_slot[ext] = lt.print_z; + } else { + // Deficit Round-Robin: pick one component per layer. + // Weight by layer height so volume ratios stay accurate + // even with adaptive layer heights. + double lh = calc_slot_lh(ext, lt.print_z); + long long lh_i = std::llround(lh * 1e6); + + // For 2-component gradient on the first layer, use the gradient's + // starting ratio instead of the configured mixing ratio so the + // selected filament matches the gradient's "from" end. + // Only affects the first layer; when sublayer splitting is enabled + // (required for gradient), layers 1+ take the sublayer path and + // do not touch the DRR accumulator. + if (layer_idx == 0 && is_gradient[ext] && s.components.size() == 2) { + double r0 = gradient_info[ext].start; + s.accum[0] += std::llround(r0 * lh_i); + s.accum[1] += std::llround((1.0 - r0) * lh_i); + } else { + for (size_t k = 0; k < s.ratios.size(); ++k) + s.accum[k] += std::llround(s.ratios[k] * lh_i); + } + size_t sel = 0; + for (size_t k = 1; k < s.accum.size(); ++k) + if (s.accum[k] > s.accum[sel]) + sel = k; + s.accum[sel] -= lh_i; + unsigned int resolved = s.components[sel] - 1; + lt.mixed_filament_resolution[ext] = resolved; + new_extruders.push_back(resolved); + prev_print_z_for_slot[ext] = lt.print_z; + } + } + lt.extruders = new_extruders; + sort_remove_duplicates(lt.extruders); + + // Update prev_relevant_z: for each slot that has relevant-layer tracking, + // advance if the current layer belongs to a slot-owning object. + for (auto &[slot, rel_set] : slot_relevant_layers) { + if (rel_set.count(layer_idx)) + prev_relevant_z_for_slot[slot] = lt.print_z; + } + + prev_print_z = lt.print_z; + } +} + +void ToolOrdering::enforce_mixed_component_order() +{ + for (LayerTools < : m_layer_tools) { + if (lt.mixed_sub_layer_groups.empty()) + continue; + + // Build a set of extruders present in lt.extruders for fast lookup. + std::set ext_set(lt.extruders.begin(), lt.extruders.end()); + + // 1. Build DAG from mixed group constraints. + // For each group [c0, c1, c2, ...], add edges c0->c1, c1->c2, ... + // Only between components that are both present in lt.extruders. + // Use an edge set to avoid duplicate edges inflating in-degree. + std::map> adj; + std::map in_degree; + std::set> edge_set; + + for (unsigned int ext : lt.extruders) + in_degree[ext] = 0; + + for (const auto &grp : lt.mixed_sub_layer_groups) { + for (size_t i = 0; i + 1 < grp.components_0based.size(); ++i) { + unsigned int a = grp.components_0based[i]; + unsigned int b = grp.components_0based[i + 1]; + if (!ext_set.count(a) || !ext_set.count(b)) + continue; + if (edge_set.insert({a, b}).second) { + adj[a].push_back(b); + in_degree[b] += 1; + } + } + } + + // 2. Record original position (from flush optimizer) as priority. + std::map orig_pos; + for (size_t i = 0; i < lt.extruders.size(); ++i) + orig_pos[lt.extruders[i]] = i; + + // 3. Kahn's topological sort with priority queue (prefer original position). + auto cmp = [&orig_pos](unsigned int lhs, unsigned int rhs) { + return orig_pos[lhs] > orig_pos[rhs]; // min-heap by orig_pos + }; + std::priority_queue, decltype(cmp)> pq(cmp); + + for (unsigned int ext : lt.extruders) { + if (in_degree[ext] == 0) + pq.push(ext); + } + + std::vector ordered; + ordered.reserve(lt.extruders.size()); + while (!pq.empty()) { + unsigned int ext = pq.top(); + pq.pop(); + ordered.push_back(ext); + if (auto it = adj.find(ext); it != adj.end()) { + for (unsigned int next : it->second) { + if (--in_degree[next] == 0) + pq.push(next); + } + } + } + + // Safety: if topological sort didn't produce all elements, keep original order. + if (ordered.size() != lt.extruders.size()) + ordered = lt.extruders; + + // 4. Verify: every mixed group's component order is preserved as subsequence. + for (const auto &grp : lt.mixed_sub_layer_groups) { + size_t prev_pos = 0; + bool valid = true; + for (unsigned int c : grp.components_0based) { + if (!ext_set.count(c)) + continue; + auto it = std::find(ordered.begin() + prev_pos, ordered.end(), c); + if (it == ordered.end()) { valid = false; break; } + prev_pos = (it - ordered.begin()) + 1; + } + assert(valid && "enforce_mixed_component_order: mixed group subsequence violated"); + (void)valid; + } + + lt.extruders = ordered; + } +} + void ToolOrdering::reorder_extruders_for_minimum_flush_volume(bool reorder_first_layer) { const PrintConfig* print_config = m_print_config_ptr; @@ -1998,6 +2785,17 @@ void ToolOrdering::reorder_extruders_for_minimum_flush_volume(bool reorder_first std::vector used_filaments = collect_sorted_used_filaments(layer_filaments); std::vector>geometric_unprintables = m_print->get_geometric_unprintable_filaments(); + + // Unprintable sets are keyed by filament id, but a mixed-color slot is virtual: what actually + // reaches the nozzle are its components. Expand the slot to those components so a geometric + // restriction is applied to the filaments really being printed. No-op without mixed filaments. + { + const auto &is_mixed = m_print->config().filament_is_mixed.values; + const auto &comp_strs = m_print->config().filament_mixed_components.values; + if (has_any_mixed_filament(is_mixed)) + expand_mixed_slots_in_unprintables(geometric_unprintables, is_mixed, comp_strs); + } + std::vector>physical_unprintables = m_print->get_physical_unprintable_filaments(used_filaments); auto filament_unprintable_volumes = m_print->get_filament_unprintable_flow(used_filaments); diff --git a/src/libslic3r/GCode/ToolOrdering.hpp b/src/libslic3r/GCode/ToolOrdering.hpp index c77b152fe9..4dc08c0e8b 100644 --- a/src/libslic3r/GCode/ToolOrdering.hpp +++ b/src/libslic3r/GCode/ToolOrdering.hpp @@ -5,12 +5,16 @@ #include "../libslic3r.h" +#include +#include #include #include #include "../FilamentGroup.hpp" +#include "../FilamentMixer.hpp" #include "../MultiNozzleUtils.hpp" #include "../ExtrusionEntity.hpp" +#include "../ObjectID.hpp" #include "../PrintConfig.hpp" namespace Slic3r { @@ -172,6 +176,65 @@ public: // Custom G-code (color change, extruder switch, pause) to be performed before this layer starts to print. const CustomGCode::Item *custom_gcode = nullptr; + // 0-based mixed filament slot → 0-based resolved physical filament for this layer. + // Populated by ToolOrdering::resolve_mixed_filaments(). Empty when no mixed filaments. + std::map mixed_filament_resolution; + + unsigned int resolve_mixed(unsigned int filament_0based) const { + auto it = mixed_filament_resolution.find(filament_0based); + return (it != mixed_filament_resolution.end()) ? it->second : filament_0based; + } + + struct MixedSubLayerGroup { + unsigned int mixed_slot_0based; + std::vector components_0based; + std::vector sub_heights; // per-component, sum ≈ layer_height + double layer_height = 0.; // the actual lh used to compute sub_heights + bool is_gradient = false; + int gradient_first_sorted_idx = 0; // index of "first" config component after sorting + + struct ObjectGradient { + size_t total_layers; + size_t current_idx; + double gradient_start; + double gradient_end; + GradientCurve curve; // empty -> linear fallback (start, end); non-empty wins + }; + std::map per_object_gradient; + + // Per-volume gradient: same metadata layout as ObjectGradient but keyed by + // (PrintObject*, ModelVolume id). Populated only when filament_mixed_gradient_per_part is + // enabled for this slot AND the corresponding ModelObject contains >=2 model-part volumes + // using this slot. When non-empty for a given (PrintObject*), GCode emission takes the + // per-volume path for tagged regions; untagged regions (modifier/painted/fuzzy_skin) still + // use per_object_gradient. Both maps are populated in parallel to keep run states correct. + struct VolumeKey { + const PrintObject* obj; + ObjectID volume_id; + bool operator<(const VolumeKey &o) const { + if (obj != o.obj) return std::less{}(obj, o.obj); + return volume_id < o.volume_id; + } + bool operator==(const VolumeKey &o) const { + return obj == o.obj && volume_id == o.volume_id; + } + }; + using VolumeGradient = ObjectGradient; + std::map per_volume_gradient; + }; + std::vector mixed_sub_layer_groups; + + const MixedSubLayerGroup* mixed_group_by_slot(unsigned int slot_id) const { + for (const auto &g : mixed_sub_layer_groups) + if (g.mixed_slot_0based == slot_id) + return &g; + return nullptr; + } + + bool is_mixed_slot(unsigned int slot_id) const { + return mixed_group_by_slot(slot_id) != nullptr; + } + WipingExtrusions& wiping_extrusions() { m_wiping_extrusions.set_layer_tools_ptr(this); return m_wiping_extrusions; @@ -227,6 +290,9 @@ public: // For a multi-material print, the printing extruders are ordered in the order they shall be primed. const std::vector& all_extruders() const { return m_all_printing_extruders; } + // 0-based mixed (virtual) slots that appeared on layers before resolve_mixed_filaments + // expanded them to physical components. + const std::vector& used_mixed_filaments() const { return m_used_mixed_filaments; } // Find LayerTools with the closest print_z. const LayerTools& tools_for_layer(coordf_t print_z) const; @@ -299,6 +365,8 @@ private: void mark_skirt_layers(const PrintConfig &config, coordf_t max_layer_height); void collect_extruder_statistics(bool prime_multi_material); void reorder_extruders_for_minimum_flush_volume(bool reorder_first_layer); + void resolve_mixed_filaments(const PrintConfig &config); + void enforce_mixed_component_order(); // BBS std::vector generate_first_layer_tool_order(const Print& print); @@ -311,8 +379,26 @@ private: unsigned int m_last_printing_extruder = (unsigned int)-1; // All extruders, which extrude some material over m_layer_tools. std::vector m_all_printing_extruders; + std::vector m_used_mixed_filaments; const DynamicPrintConfig* m_print_full_config = nullptr; const PrintConfig* m_print_config_ptr = nullptr; + + // Per-object gradient tracking: slot(0-based) -> PrintObject* -> list of layer indices + // where that object uses the slot. Populated by collect_extruders, consumed by resolve_mixed_filaments. + std::map>> m_mixed_object_layers; + + // All layer indices (in m_layer_tools) where each object has any layer. + // Used by gradient run detection to distinguish real gaps (object has a layer + // that doesn't use the slot) from spurious gaps (another object's layer). + std::map> m_object_all_layer_indices; + + // Per-volume gradient tracking: slot(0-based) -> (PrintObject*, ModelVolume id) -> list of + // layer indices where the given volume contributes to the slot. Populated by collect_extruders + // alongside m_mixed_object_layers when per_part gradient is enabled for the slot AND the + // ModelObject has >=2 model-part volumes using the slot. Empty for all other configurations, + // which keeps every legacy per-object code path bit-identical (loops over an empty map are + // no-ops; downstream emission falls through to the per-object branch). + std::map>> m_gradient_volume_layers; const PrintObject* m_print_object_ptr = nullptr; Print* m_print; bool m_sorted = false; diff --git a/src/libslic3r/Layer.cpp b/src/libslic3r/Layer.cpp index b3a145bed0..87ad11bcf8 100644 --- a/src/libslic3r/Layer.cpp +++ b/src/libslic3r/Layer.cpp @@ -210,6 +210,12 @@ void Layer::make_perimeters() if (! (*it)->slices.empty()) { LayerRegion* other_layerm = *it; const PrintRegion &other_region = other_layerm->region(); + // Per-part gradient tags a region with its owning ModelVolume; merging two + // differently-tagged regions would collapse volumes that need independent + // gradient runs. Both tags are invalid unless per-part gradient is on, so + // this is a no-op for every other configuration. + if (this_region.gradient_volume_id() != other_region.gradient_volume_id()) + continue; if (is_perimeter_compatible(*m_object->print(), this_region, other_region)) { other_layerm->perimeters.clear(); diff --git a/src/libslic3r/LocalesUtils.cpp b/src/libslic3r/LocalesUtils.cpp index d321072335..308752cc62 100644 --- a/src/libslic3r/LocalesUtils.cpp +++ b/src/libslic3r/LocalesUtils.cpp @@ -53,7 +53,7 @@ bool is_decimal_separator_point() double string_to_double_decimal_point(const std::string_view str, size_t* pos /* = nullptr*/) { - double out; + double out = 0.; size_t p = fast_float::from_chars(str.data(), str.data() + str.size(), out).ptr - str.data(); if (pos) *pos = p; diff --git a/src/libslic3r/MeshBoolean.cpp b/src/libslic3r/MeshBoolean.cpp index 8b50681370..7497f92abf 100644 --- a/src/libslic3r/MeshBoolean.cpp +++ b/src/libslic3r/MeshBoolean.cpp @@ -352,7 +352,7 @@ void segment(CGALMesh& src, std::vector& dst, double smoothing_alpha = //} //else { - dst.emplace_back(std::move(CGALMesh(out))); + dst.emplace_back(CGALMesh(out)); } } //if (mesh_merged.is_empty() == false) { @@ -371,7 +371,7 @@ std::vector segment(const TriangleMesh& src, double smoothing_alph std::vector out_meshes; for (auto& outf_cgal_mesh: out_cgal_meshes) { - out_meshes.emplace_back(std::move(cgal_to_triangle_mesh(outf_cgal_mesh.m))); + out_meshes.emplace_back(cgal_to_triangle_mesh(outf_cgal_mesh.m)); } return out_meshes; diff --git a/src/libslic3r/Model.cpp b/src/libslic3r/Model.cpp index c689c7ce78..bbc3aa80f3 100644 --- a/src/libslic3r/Model.cpp +++ b/src/libslic3r/Model.cpp @@ -1,6 +1,8 @@ #include "Model.hpp" #include "libslic3r.h" #include "BuildVolume.hpp" +#include "TexturePainting.hpp" +#include "Format/AssimpImport.hpp" #include "ClipperUtils.hpp" #include "Exception.hpp" #include "Model.hpp" @@ -104,6 +106,7 @@ Model& Model::assign_copy(const Model &rhs) this->mk_version = rhs.mk_version; this->md_name = rhs.md_name; this->md_value = rhs.md_value; + this->texture_mesh = rhs.texture_mesh; return *this; } @@ -139,6 +142,7 @@ Model& Model::assign_copy(Model &&rhs) this->mk_version = rhs.mk_version; this->md_name = rhs.md_name; this->md_value = rhs.md_value; + this->texture_mesh = std::move(rhs.texture_mesh); this->backup_path = std::move(rhs.backup_path); this->object_backup_id_map = std::move(rhs.object_backup_id_map); this->next_object_backup_id = rhs.next_object_backup_id; @@ -239,6 +243,27 @@ _finished: // BBS: add part plate related logic // BBS: backup & restore // Loading model from a file, it may be a simple geometry file as STL or OBJ, however it may be a project file as well. +// Build a plain geometry ModelObject from a textured mesh. The texture itself is carried +// separately on Model::texture_mesh and consumed by the texture import dialog. +static void add_textured_mesh_to_model(Model& model, const TexturedMesh& tex_mesh, const std::string& input_file) +{ + std::string object_name = boost::filesystem::path(input_file).filename().string(); + + indexed_triangle_set its; + its.vertices.resize(tex_mesh.vertices.size()); + for (size_t i = 0; i < tex_mesh.vertices.size(); ++i) + its.vertices[i] = Vec3f(tex_mesh.vertices[i][0], tex_mesh.vertices[i][1], tex_mesh.vertices[i][2]); + its.indices.resize(tex_mesh.indices.size()); + for (size_t i = 0; i < tex_mesh.indices.size(); ++i) + its.indices[i] = Vec3i32(tex_mesh.indices[i][0], tex_mesh.indices[i][1], tex_mesh.indices[i][2]); + + its_merge_vertices(its); + its_remove_degenerate_faces(its); + its_compactify_vertices(its); + + model.add_object(object_name.c_str(), input_file.c_str(), TriangleMesh(std::move(its))); +} + Model Model::read_from_file(const std::string& input_file, DynamicPrintConfig* config, ConfigSubstitutionContext* config_substitutions, @@ -281,32 +306,85 @@ Model Model::read_from_file(const std::string& result = load_stl(input_file.c_str(), &model, nullptr, stlFn,256); else if (boost::algorithm::iends_with(input_file, ".obj")) { ObjInfo obj_info; - result = load_obj(input_file.c_str(), &model, obj_info, message); - if (result){ - ObjDialogInOut in_out; - in_out.model = &model; - in_out.lost_material_name = obj_info.lost_material_name; + ObjParser::MtlData mtl_data; + result = load_obj(input_file.c_str(), &model, obj_info, message, nullptr, &mtl_data); + if (result && obj_info.has_uv_png && !obj_info.uvs.empty() && !model.objects.empty()) { + // Textured OBJ: hand the mesh + materials to the texture-to-color importer + // instead of the flat per-face colour dialog. + auto tex_mesh = std::make_shared(); + std::string obj_dir = boost::filesystem::path(input_file).parent_path().string(); + if (obj_to_textured_mesh(obj_info, + model.objects.back()->volumes[0]->mesh().its, + mtl_data, obj_dir, *tex_mesh)) { + model.texture_mesh = tex_mesh; + } + } + else if (result && !model.objects.empty() && !model.objects.back()->volumes.empty()) { + // Vertex-colour and MTL face-colour OBJs also go through the texture-to-color + // importer (as precomputed per-face colors) instead of the flat + // per-face colour dialog, matching the uv_png branch above. + auto build_tex_mesh_geometry = [&]() { + auto tex_mesh = std::make_shared(); + const auto& its = model.objects.back()->volumes[0]->mesh().its; + tex_mesh->vertices.resize(its.vertices.size()); + for (size_t i = 0; i < its.vertices.size(); ++i) + tex_mesh->vertices[i] = {its.vertices[i].x(), its.vertices[i].y(), its.vertices[i].z()}; + tex_mesh->indices.resize(its.indices.size()); + for (size_t i = 0; i < its.indices.size(); ++i) + tex_mesh->indices[i] = {its.indices[i][0], its.indices[i][1], its.indices[i][2]}; + return tex_mesh; + }; if (obj_info.vertex_colors.size() > 0) { - if (objFn) { // 1.result is ok and pop up a dialog - in_out.input_colors = std::move(obj_info.vertex_colors); - in_out.is_single_color = false; - in_out.deal_vertex_color = true; - objFn(in_out); + auto tex_mesh = build_tex_mesh_geometry(); + const auto& its = model.objects.back()->volumes[0]->mesh().its; + tex_mesh->precomputed_face_colors.resize(its.indices.size()); + for (size_t i = 0; i < its.indices.size(); ++i) { + const auto& f = its.indices[i]; + auto avg = [&](int ch) -> std::size_t { + float v = (obj_info.vertex_colors[f[0]][ch] + + obj_info.vertex_colors[f[1]][ch] + + obj_info.vertex_colors[f[2]][ch]) / 3.0f * 255.0f; + return (std::size_t) std::clamp(v, 0.0f, 255.0f); + }; + tex_mesh->precomputed_face_colors[i] = {avg(0), avg(1), avg(2)}; } - } else if (obj_info.face_colors.size() > 0 && obj_info.has_uv_png == false) { // mtl file - if (objFn) { // 1.result is ok and pop up a dialog - in_out.input_colors = std::move(obj_info.face_colors); - in_out.is_single_color = obj_info.is_single_mtl; - in_out.deal_vertex_color = false; - objFn(in_out); + tex_mesh->precomputed_vertex_colors = obj_info.vertex_colors; + model.texture_mesh = tex_mesh; + } else if (obj_info.face_colors.size() > 0 && obj_info.has_uv_png == false) { + auto tex_mesh = build_tex_mesh_geometry(); + const size_t nf = tex_mesh->indices.size(); + tex_mesh->precomputed_face_colors.resize(nf); + for (size_t i = 0; i < nf; ++i) { + if (i < obj_info.face_colors.size()) { + const auto& c = obj_info.face_colors[i]; + tex_mesh->precomputed_face_colors[i] = { + (std::size_t) std::clamp(c[0] * 255.0f, 0.0f, 255.0f), + (std::size_t) std::clamp(c[1] * 255.0f, 0.0f, 255.0f), + (std::size_t) std::clamp(c[2] * 255.0f, 0.0f, 255.0f) + }; + } else { + tex_mesh->precomputed_face_colors[i] = {128, 128, 128}; + } } - } /*else if (obj_info.has_uv_png && obj_info.uvs.size() > 0) { - boost::filesystem::path full_path(input_file); - std::string obj_directory = full_path.parent_path().string(); - obj_info.obj_dircetory = obj_directory; - result = false; - message = _L("Importing obj with png function is developing."); - }*/ + model.texture_mesh = tex_mesh; + } + } + } + else if (boost::algorithm::iends_with(input_file, ".glb") || + boost::algorithm::iends_with(input_file, ".gltf") || + boost::algorithm::iends_with(input_file, ".fbx")) { + // These formats can carry material/texture data, so they go through the textured + // import path: the geometry becomes a normal object and the texture is handed to the + // texture-to-color dialog via Model::texture_mesh. + auto tex_mesh = std::make_shared(); + result = load_assimp_textured_model(input_file, *tex_mesh, &message); + if (result) { + model.texture_mesh = tex_mesh; + add_textured_mesh_to_model(model, *tex_mesh, input_file); + } else if (!message.empty()) { + BOOST_LOG_TRIVIAL(error) << "Assimp: failed to load model: " << message + << ", path=" << input_file; + message = _L("The file format is incompatible and cannot be parsed."); } } else if (boost::algorithm::iends_with(input_file, ".svg")) @@ -578,6 +656,7 @@ void Model::clear_objects() this->objects.clear(); object_backup_id_map.clear(); next_object_backup_id = 1; + texture_mesh.reset(); } // BBS: backup, reuse objects @@ -2576,7 +2655,8 @@ void ModelVolume::update_extruder_count(size_t extruder_count) } } -void ModelVolume::update_extruder_count_when_delete_filament(size_t extruder_count, size_t filament_id, int replace_filament_id) +void ModelVolume::update_extruder_count_when_delete_filament(size_t extruder_count, size_t filament_id, int replace_filament_id, + const std::vector &filament_is_mixed) { std::vector used_extruders = get_extruders(); for (int extruder_id : used_extruders) { @@ -2587,8 +2667,22 @@ void ModelVolume::update_extruder_count_when_delete_filament(size_t extruder_cou } // Same stale-assignment cleanup as update_extruder_count, for the filament-delete path. // Ported from BambuStudio (STUDIO-15763). - if (extruder_id() > extruder_count) { - this->config.erase("extruder"); + size_t eid = extruder_id(); + // Judge out-of-range against the post-remap id, mirroring update_filament_values_for_items_when_delete_filament. + // Using the pre-remap eid would wrongly erase a high extruder that should remap (e.g. 5 -> 4 after + // deleting filament 1); update_filament_values_for_items_when_delete_filament would then skip it + // (!has("extruder")) and the volume would fall back to the object default color. + size_t remapped = eid; + if (eid == filament_id) + remapped = (replace_filament_id > 0) ? (size_t)replace_filament_id : 1; + else if (eid > filament_id) + remapped = eid - 1; + if (remapped > extruder_count) { + // filament_is_mixed is the pre-delete snapshot; index it with the ORIGINAL eid (1-based), + // not remapped, so we check whether this volume's current slot is a mixed slot. + bool is_mixed = !filament_is_mixed.empty() && eid >= 1 && (eid - 1) < filament_is_mixed.size() && filament_is_mixed[eid - 1]; + if (!is_mixed) + this->config.erase("extruder"); } } @@ -3495,6 +3589,15 @@ void FacetsAnnotation::get_facets(const ModelVolume& mv, std::vectorset(selector); +} + void FacetsAnnotation::set_enforcer_block_type_limit(const ModelVolume &mv, EnforcerBlockerType max_type, EnforcerBlockerType to_delete_filament, diff --git a/src/libslic3r/Model.hpp b/src/libslic3r/Model.hpp index 2d46bc4cdf..6834c7a59b 100644 --- a/src/libslic3r/Model.hpp +++ b/src/libslic3r/Model.hpp @@ -47,6 +47,8 @@ namespace cereal { } namespace Slic3r { + +struct TexturedMesh; enum class ConversionType; class BuildVolume; @@ -740,6 +742,9 @@ public: EnforcerBlockerType max_type, EnforcerBlockerType to_delete_filament = EnforcerBlockerType::NONE, EnforcerBlockerType replace_filament = EnforcerBlockerType::NONE); + // Shift painted filament indices >= threshold by delta. Used when a physical filament is + // inserted ahead of existing slots (mixed-color slots are kept at the end of the list). + void shift_states_above(const ModelVolume &mv, EnforcerBlockerType threshold, int delta); indexed_triangle_set get_facets_strict(const ModelVolume& mv, EnforcerBlockerType type) const; bool has_facets(const ModelVolume& mv, EnforcerBlockerType type) const; bool empty() const { return m_data.triangles_to_split.empty(); } @@ -932,7 +937,8 @@ public: // BBS std::vector get_extruders() const; void update_extruder_count(size_t extruder_count); - void update_extruder_count_when_delete_filament(size_t extruder_count, size_t filament_id, int replace_filament_id = -1); + void update_extruder_count_when_delete_filament(size_t extruder_count, size_t filament_id, int replace_filament_id = -1, + const std::vector &filament_is_mixed = {}); // Split this volume, append the result to the object owning this volume. // Return the number of volumes created from this one. @@ -1549,6 +1555,10 @@ public: std::shared_ptr model_info = nullptr; std::shared_ptr profile_info = nullptr; + // Textured mesh data for texture-to-painting import. Populated by the loader when a mesh + // arrives with usable UVs and a texture map; consumed (and reset) by the import dialog. + std::shared_ptr texture_mesh; + //makerlab information std::string mk_name; std::string mk_version; diff --git a/src/libslic3r/PerimeterGenerator.cpp b/src/libslic3r/PerimeterGenerator.cpp index 659a3a7038..9037118f0c 100644 --- a/src/libslic3r/PerimeterGenerator.cpp +++ b/src/libslic3r/PerimeterGenerator.cpp @@ -408,7 +408,7 @@ static ExtrusionEntityCollection traverse_extrusions(const PerimeterGenerator& p ExtrusionRole role = is_external ? erExternalPerimeter : erPerimeter; const bool is_contour = !extrusion->is_closed || pg_extrusion.is_contour; - apply_fuzzy_skin(extrusion, perimeter_generator, is_contour); + apply_fuzzy_skin(extrusion, perimeter_generator, is_contour, extrusion->is_closed); ExtrusionPaths paths; // detect overhanging/bridging perimeters diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index f48f151357..304c8957d5 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1185,6 +1185,7 @@ static std::vector s_Preset_print_options{ "flush_into_infill", "flush_into_objects", "flush_into_support", + "enable_mixed_color_sublayer", "tree_support_branch_angle", "tree_support_angle_slow", "tree_support_wall_count", diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index e847b9d72f..a3699e0352 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -7,6 +7,7 @@ #include "PresetCacheFormat.hpp" #include "PrintConfig.hpp" +#include "FilamentMixer.hpp" #include "libslic3r.h" #include "I18N.hpp" #include "Utils.hpp" @@ -72,7 +73,17 @@ static std::vector s_project_options { // whether dynamic per-nozzle filament mapping is active. Persisted with the project and // restored from a saved 3mf; reset to false on load and set true only by live device sync. "has_filament_switcher", - "enable_filament_dynamic_map" + "enable_filament_dynamic_map", + // Mixed-color filament slots. Project-level parallel arrays indexed like filament_colour: + // which slots are virtual mixes, their component filaments, blend ratios and the optional + // Z-gradient description. Kept with the project so a saved 3mf round-trips the mix setup. + "filament_is_mixed", + "filament_mixed_components", + "filament_mixed_sublayer_ratios", + "filament_mixed_gradient", + "filament_mixed_gradient_range", + "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part" }; //Orca: add custom as default @@ -2705,6 +2716,79 @@ void PresetBundle::load_installed_sla_materials(AppConfig &config) preset.set_visible_from_appconfig(config); } +// Mixed-color filament metadata is project state saved in the 3mf, also mirrored into the app +// config so the last session's mixes are back before any project is opened. It is kept in the +// per-printer snapshot next to the filament list it indexes (filament_%02u/filament_colors), +// because that list is rebuilt on every printer selection and the component ids are 1-based +// indices into exactly that list. Missing keys clear the arrays, so one printer never inherits +// another's mixes; fallback_to_global also reads the shared "presets" keys an older config +// layout used, which export_selections drops on the next save. +static void load_mixed_filament_settings(DynamicPrintConfig &project_config, AppConfig &config, + const std::string &printer_name, size_t n_filaments, + bool fallback_to_global) +{ + auto raw_value = [&](const char *key, bool &found) -> std::string { + if (config.has_printer_setting(printer_name, key)) { + found = true; + return config.get_printer_setting(printer_name, key); + } + if (fallback_to_global && config.has("presets", key)) { + found = true; + return config.get("presets", key); + } + found = false; + return std::string{}; + }; + std::vector parts; + auto load_bools = [&](const char *key) { + auto &vals = project_config.option(key)->values; + vals.clear(); + bool found = false; + const std::string s = raw_value(key, found); + if (found && !s.empty()) { + boost::algorithm::split(parts, s, boost::algorithm::is_any_of(",")); + for (const auto &p : parts) vals.push_back(p == "1"); + } + vals.resize(n_filaments, false); + }; + auto load_strings = [&](const char *key) { + auto &vals = project_config.option(key)->values; + vals.clear(); + bool found = false; + const std::string s = raw_value(key, found); + if (found && !s.empty()) { + boost::algorithm::split(parts, s, boost::algorithm::is_any_of("|")); + vals = parts; + } + vals.resize(n_filaments, std::string{}); + }; + + load_bools("filament_is_mixed"); + load_strings("filament_mixed_components"); + load_strings("filament_mixed_sublayer_ratios"); + load_bools("filament_mixed_gradient"); + load_strings("filament_mixed_gradient_range"); + load_bools("filament_mixed_gradient_per_part"); + + // The gradient curve is the one array whose values contain '|' themselves (it separates the + // control points), so it is stored C-style escaped rather than '|'-joined. + { + auto &vals = project_config.option("filament_mixed_gradient_curve")->values; + vals.clear(); + bool found = false; + const std::string s = raw_value("filament_mixed_gradient_curve", found); + if (found && !s.empty()) { + std::vector curves; + if (unescape_strings_cstyle(s, curves)) + vals = std::move(curves); + } + vals.resize(n_filaments, std::string{}); + // Heal legacy corruption: clear any non-empty slot that ended up with < 2 points + // (e.g. a curve split across slots by the old "|" delimiter). Falls back to linear. + Slic3r::sanitize_mixed_gradient_curve_array(vals); + } +} + void PresetBundle::update_selections(AppConfig &config) { std::string initial_printer_profile_name = printers.get_selected_preset_name(); @@ -2785,6 +2869,9 @@ void PresetBundle::update_selections(AppConfig &config) auto flush_multipliers = matrix | boost::adaptors::transformed(boost::lexical_cast); project_config.option("flush_multiplier")->values = std::vector(flush_multipliers.begin(), flush_multipliers.end()); } + // No global fallback here: on a printer change the legacy shared keys describe another + // printer's filament list, so absent per-printer keys must clear the mixes, not revive them. + load_mixed_filament_settings(project_config, config, initial_printer_profile_name, filament_presets.size(), false); // Update visibility of presets based on their compatibility with the active printer. // Always try to select a compatible print and filament preset to the current printer preset, @@ -2935,6 +3022,7 @@ void PresetBundle::load_selections(AppConfig &config, const PresetPreferences& p auto flush_multipliers = matrix | boost::adaptors::transformed(boost::lexical_cast); project_config.option("flush_multiplier")->values = std::vector(flush_multipliers.begin(), flush_multipliers.end()); } + load_mixed_filament_settings(project_config, config, initial_printer_profile_name, filament_presets.size(), true); // Update visibility of presets based on their compatibility with the active printer. // Always try to select a compatible print and filament preset to the current printer preset, @@ -3069,6 +3157,32 @@ void PresetBundle::export_selections(AppConfig &config) "|"); config.set_printer_setting(printer_name, "flush_multiplier", flush_multiplier_str); + // Mixed-color filament metadata goes into the per-printer snapshot next to the filament list + // it indexes (see load_mixed_filament_settings). Bools are ','-joined and the component, ratio + // and range strings '|'-joined; the gradient curve is escaped instead, as it contains '|'. + auto join_bools = [](const std::vector &vals) { + std::string s; + for (size_t i = 0; i < vals.size(); ++i) { + if (i > 0) s += ","; + s += (vals[i] ? "1" : "0"); + } + return s; + }; + if (auto *opt = project_config.option("filament_is_mixed")) + config.set_printer_setting(printer_name, "filament_is_mixed", join_bools(opt->values)); + if (auto *opt = project_config.option("filament_mixed_components")) + config.set_printer_setting(printer_name, "filament_mixed_components", boost::algorithm::join(opt->values, "|")); + if (auto *opt = project_config.option("filament_mixed_sublayer_ratios")) + config.set_printer_setting(printer_name, "filament_mixed_sublayer_ratios", boost::algorithm::join(opt->values, "|")); + if (auto *opt = project_config.option("filament_mixed_gradient")) + config.set_printer_setting(printer_name, "filament_mixed_gradient", join_bools(opt->values)); + if (auto *opt = project_config.option("filament_mixed_gradient_range")) + config.set_printer_setting(printer_name, "filament_mixed_gradient_range", boost::algorithm::join(opt->values, "|")); + if (auto *opt = project_config.option("filament_mixed_gradient_curve")) + config.set_printer_setting(printer_name, "filament_mixed_gradient_curve", escape_strings_cstyle(opt->values)); + if (auto *opt = project_config.option("filament_mixed_gradient_per_part")) + config.set_printer_setting(printer_name, "filament_mixed_gradient_per_part", join_bools(opt->values)); + // BBS //config.set("presets", "sla_print", sla_prints.get_selected_preset_name()); //config.set("presets", "sla_material", sla_materials.get_selected_preset_name()); @@ -3077,46 +3191,6 @@ void PresetBundle::export_selections(AppConfig &config) BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": printer %1%, print %2%, filaments[0] %3% ")%printers.get_selected_preset_name() % prints.get_selected_preset_name() %filament_presets[0]; } -// BBS -void PresetBundle::set_num_filaments(unsigned int n, std::vector new_colors) { - int old_filament_count = this->filament_presets.size(); - if (n > old_filament_count && old_filament_count != 0) - filament_presets.resize(n, filament_presets.back()); - else { - filament_presets.resize(n); - } - ConfigOptionStrings* filament_color = project_config.option("filament_colour"); - ConfigOptionStrings *filament_multi_color = project_config.option("filament_multi_colour"); - ConfigOptionStrings* filament_color_type = project_config.option("filament_colour_type"); - ConfigOptionInts* filament_map = project_config.option("filament_map"); - ConfigOptionInts* filament_nozzle_map = project_config.option("filament_nozzle_map"); - ConfigOptionInts* filament_volume_map = project_config.option("filament_volume_map"); - - filament_color->resize(n); - // Sync filament multi colour - filament_multi_color->values.resize(n); - for (size_t i = 0; i < n; i++) { - filament_multi_color->values[i] = filament_color->values[i]; - } - filament_color_type->resize(n); - filament_map->values.resize(n, 1); - filament_nozzle_map->values.resize(n, 0); - filament_volume_map->values.resize(n, static_cast(NozzleVolumeType::nvtStandard)); - ams_multi_color_filment.resize(n); - - // BBS set new filament color to new_color - if (old_filament_count < n) { - if (!new_colors.empty()) { - for (int i = old_filament_count; i < n; i++) { - filament_color->values[i] = new_colors[i - old_filament_count]; - filament_multi_color->values[i] = new_colors[i - old_filament_count]; - filament_color_type->values[i] = "1"; // default color type - } - } - } - - update_multi_material_filament_presets(); -} void PresetBundle::set_num_filaments(unsigned int n, std::string new_color) { unsigned old_filament_count = this->filament_presets.size(); @@ -3132,6 +3206,11 @@ void PresetBundle::set_num_filaments(unsigned int n, std::string new_color) ConfigOptionInts* filament_nozzle_map = project_config.option("filament_nozzle_map"); ConfigOptionInts* filament_volume_map = project_config.option("filament_volume_map"); + // Which slots are new is a fact about the arrays below, not about filament_presets: + // update_multi_material_filament_presets() tops that list up to the nozzle count on its own, + // so it can already sit at the new size while every array below is still at the old one. + const size_t old_slot_count = filament_color->values.size(); + filament_color->resize(n); // Sync filament multi colour filament_multi_color->values.resize(n); @@ -3144,14 +3223,29 @@ void PresetBundle::set_num_filaments(unsigned int n, std::string new_color) filament_volume_map->values.resize(n, static_cast(NozzleVolumeType::nvtStandard)); ams_multi_color_filment.resize(n); + // Mixed-color metadata is a parallel per-filament array set, so it has to grow and shrink + // with the filament count exactly like filament_colour above. + if (auto* opt = project_config.option("filament_is_mixed")) + opt->values.resize(n, false); + if (auto* opt = project_config.option("filament_mixed_components")) + opt->values.resize(n, std::string{}); + if (auto* opt = project_config.option("filament_mixed_sublayer_ratios")) + opt->values.resize(n, std::string{}); + if (auto* opt = project_config.option("filament_mixed_gradient")) + opt->values.resize(n, false); + if (auto* opt = project_config.option("filament_mixed_gradient_range")) + opt->values.resize(n, std::string{}); + if (auto* opt = project_config.option("filament_mixed_gradient_curve")) + opt->values.resize(n, std::string{}); + if (auto* opt = project_config.option("filament_mixed_gradient_per_part")) + opt->values.resize(n, false); + //BBS set new filament color to new_color - if (old_filament_count < n) { - if (!new_color.empty()) { - for (unsigned i = old_filament_count; i < n; i++) { - filament_color->values[i] = new_color; - filament_multi_color->values[i] = new_color; - filament_color_type->values[i] = "1"; // default color type - } + if (!new_color.empty()) { + for (size_t i = old_slot_count; i < n; i++) { + filament_color->values[i] = new_color; + filament_multi_color->values[i] = new_color; + filament_color_type->values[i] = "1"; // default color type } } @@ -3216,9 +3310,69 @@ void PresetBundle::update_num_filaments(unsigned int to_del_flament_id) erase_or_resize(filament_color_type->values); erase_or_resize(ams_multi_color_filment); + // Mixed-color metadata. Component IDs reference other slots by 1-based index, so a deleted + // *physical* filament must be remapped out of every mix before the arrays themselves shrink. + // Deleting a mixed slot needs no remap (nothing references a mixed slot as a component). + { + auto *is_mixed_opt = project_config.option("filament_is_mixed"); + auto *comp_opt = project_config.option("filament_mixed_components"); + if (is_mixed_opt && comp_opt) { + bool del_is_physical = (to_del_flament_id >= is_mixed_opt->values.size() + || !is_mixed_opt->values[to_del_flament_id]); + if (del_is_physical) + remap_mixed_components_on_delete(is_mixed_opt->values, comp_opt->values, + to_del_flament_id + 1); + } + if (is_mixed_opt) + erase_or_resize(is_mixed_opt->values); + if (comp_opt) + erase_or_resize(comp_opt->values); + } + if (auto *opt = project_config.option("filament_mixed_sublayer_ratios")) + erase_or_resize(opt->values); + if (auto *opt = project_config.option("filament_mixed_gradient")) + erase_or_resize(opt->values); + if (auto *opt = project_config.option("filament_mixed_gradient_range")) + erase_or_resize(opt->values); + if (auto *opt = project_config.option("filament_mixed_gradient_curve")) + erase_or_resize(opt->values); + if (auto *opt = project_config.option("filament_mixed_gradient_per_part")) + erase_or_resize(opt->values); + update_multi_material_filament_presets(to_del_flament_id); } +bool PresetBundle::is_mixed_filament(size_t idx) const +{ + auto *opt = project_config.option("filament_is_mixed"); + return opt && idx < opt->values.size() && opt->values[idx]; +} + +size_t PresetBundle::num_mixed_filaments() const +{ + auto *opt = project_config.option("filament_is_mixed"); + return opt == nullptr ? 0 : size_t(std::count(opt->values.begin(), opt->values.end(), true)); +} + +// Counted off the mixed flags, not filament_presets: that list is topped up to the nozzle count on +// its own, so it can sit a slot ahead of the arrays that describe slots. Unlike the sibling +// physical_filament_config_indices(), which bounds by filament_presets, this ignores that top-up. +size_t PresetBundle::num_physical_filaments() const +{ + const auto *opt = project_config.option("filament_is_mixed"); + return opt == nullptr ? filament_presets.size() + : size_t(std::count(opt->values.begin(), opt->values.end(), false)); +} + +std::vector PresetBundle::physical_filament_config_indices() const +{ + std::vector indices; + for (size_t i = 0; i < filament_presets.size(); ++i) + if (!is_mixed_filament(i)) + indices.push_back(i); + return indices; +} + void PresetBundle::get_ams_cobox_infos(AMSComboInfo& combox_info) { @@ -3445,6 +3599,63 @@ unsigned int PresetBundle::sync_ams_list(std::vector mixed_snapshots; + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + auto* mixed_comp_opt = project_config.option("filament_mixed_components"); + auto* mixed_ratios_opt = project_config.option("filament_mixed_sublayer_ratios"); + auto* mixed_gradient_opt = project_config.option("filament_mixed_gradient"); + auto* mixed_grad_range_opt = project_config.option("filament_mixed_gradient_range"); + auto* mixed_grad_curve_opt = project_config.option("filament_mixed_gradient_curve"); + auto* mixed_per_part_opt = project_config.option("filament_mixed_gradient_per_part"); + if (is_mixed_opt) { + for (size_t i = 0; i < is_mixed_opt->values.size() && i < this->filament_presets.size(); ++i) { + if (!is_mixed_opt->values[i]) + continue; + MixedSlotSnapshot snap; + snap.preset = this->filament_presets[i]; + snap.color = (i < filament_color->values.size()) ? filament_color->values[i] : ""; + snap.color_type = (i < filament_color_type->values.size()) ? filament_color_type->values[i] : ""; + if (mixed_comp_opt && i < mixed_comp_opt->values.size()) snap.mixed_components = mixed_comp_opt->values[i]; + if (mixed_ratios_opt && i < mixed_ratios_opt->values.size()) snap.mixed_sublayer_ratios = mixed_ratios_opt->values[i]; + if (mixed_gradient_opt && i < mixed_gradient_opt->values.size()) snap.mixed_gradient = mixed_gradient_opt->values[i]; + if (mixed_grad_range_opt && i < mixed_grad_range_opt->values.size()) snap.mixed_gradient_range = mixed_grad_range_opt->values[i]; + if (mixed_grad_curve_opt && i < mixed_grad_curve_opt->values.size()) snap.mixed_gradient_curve = mixed_grad_curve_opt->values[i]; + if (mixed_per_part_opt && i < mixed_per_part_opt->values.size()) snap.mixed_gradient_per_part = mixed_per_part_opt->values[i]; + mixed_snapshots.push_back(snap); + } + if (!mixed_snapshots.empty()) { + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": stripping " << mixed_snapshots.size() << " mixed filament slot(s) before AMS sync"; + size_t phys_count = this->filament_presets.size() - mixed_snapshots.size(); + this->filament_presets.resize(phys_count); + filament_color->values.resize(phys_count); + filament_color_type->values.resize(phys_count); + filament_map->values.resize(phys_count, 1); + is_mixed_opt->values.resize(phys_count); + if (mixed_comp_opt) mixed_comp_opt->values.resize(phys_count); + if (mixed_ratios_opt) mixed_ratios_opt->values.resize(phys_count); + if (mixed_gradient_opt) mixed_gradient_opt->values.resize(phys_count); + if (mixed_grad_range_opt) mixed_grad_range_opt->values.resize(phys_count); + if (mixed_grad_curve_opt) mixed_grad_curve_opt->values.resize(phys_count); + if (mixed_per_part_opt) mixed_per_part_opt->values.resize(phys_count); + } + } + if (color_only) { auto get_map_index = [&ams_infos](const std::vector &infos, const AMSMapInfo &temp) { for (int i = 0; i < infos.size(); i++) { @@ -3594,7 +3805,7 @@ unsigned int PresetBundle::sync_ams_list(std::vector= size_t(EnforcerBlockerType::ExtruderMax)){ + if (exist_filament_presets.size() >= MAXIMUM_AMS_SYNC_FILAMENT_NUMBER){ break; } auto idx = get_idx_in_array(exist_filament_presets, exist_colors, need_append_colors[i].filament_preset, need_append_colors[i].filament_color); @@ -3696,6 +3907,34 @@ unsigned int PresetBundle::sync_ams_list(std::vectorvalue > filament_color_type->values.size()) support_interface_filament_opt->value = 0; } + // Re-append mixed filament slots that were stripped before AMS sync + if (!mixed_snapshots.empty()) { + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": re-appending " << mixed_snapshots.size() << " mixed filament slot(s) after AMS sync"; + size_t new_phys_count = this->filament_presets.size(); + if (is_mixed_opt) is_mixed_opt->values.resize(new_phys_count, (unsigned char)false); + if (mixed_comp_opt) mixed_comp_opt->values.resize(new_phys_count); + if (mixed_ratios_opt) mixed_ratios_opt->values.resize(new_phys_count); + if (mixed_gradient_opt) mixed_gradient_opt->values.resize(new_phys_count, (unsigned char)false); + if (mixed_grad_range_opt) mixed_grad_range_opt->values.resize(new_phys_count); + if (mixed_grad_curve_opt) mixed_grad_curve_opt->values.resize(new_phys_count); + if (mixed_per_part_opt) mixed_per_part_opt->values.resize(new_phys_count, (unsigned char)false); + + for (auto& snap : mixed_snapshots) { + this->filament_presets.push_back(snap.preset); + filament_color->values.push_back(snap.color); + filament_color_type->values.push_back(snap.color_type); + ams_multi_color_filment.push_back({snap.color}); + filament_map->values.push_back(1); + if (is_mixed_opt) is_mixed_opt->values.push_back((unsigned char)true); + if (mixed_comp_opt) mixed_comp_opt->values.push_back(snap.mixed_components); + if (mixed_ratios_opt) mixed_ratios_opt->values.push_back(snap.mixed_sublayer_ratios); + if (mixed_gradient_opt) mixed_gradient_opt->values.push_back((unsigned char)snap.mixed_gradient); + if (mixed_grad_range_opt) mixed_grad_range_opt->values.push_back(snap.mixed_gradient_range); + if (mixed_grad_curve_opt) mixed_grad_curve_opt->values.push_back(snap.mixed_gradient_curve); + if (mixed_per_part_opt) mixed_per_part_opt->values.push_back((unsigned char)snap.mixed_gradient_per_part); + } + } + // Update ams_multi_color_filment update_filament_multi_color(); update_multi_material_filament_presets(); diff --git a/src/libslic3r/PresetBundle.hpp b/src/libslic3r/PresetBundle.hpp index 9da8fb4251..6e7e07b26e 100644 --- a/src/libslic3r/PresetBundle.hpp +++ b/src/libslic3r/PresetBundle.hpp @@ -326,8 +326,9 @@ public: // Export selections (current print, current filaments, current printer) into config.ini void export_selections(AppConfig &config); - // BBS - void set_num_filaments(unsigned int n, std::vector new_colors); + // n is the total slot count, and growth appends at the raw tail - which is where the mixed + // slots live. A caller adding physical filaments has to add num_mixed_filaments() on top and + // then move the new slots ahead of the mixed tail, as Sidebar::add_custom_filament does. void set_num_filaments(unsigned int n, std::string new_col = ""); void update_num_filaments(unsigned int to_del_flament_id); @@ -497,6 +498,14 @@ public: // Read out the number of extruders from an active printer preset, // update size and content of filament_presets. void update_multi_material_filament_presets(size_t to_delete_filament_id = size_t(-1)); + // Mixed-color filament slots: virtual slots realized from 2-3 physical filaments. + bool is_mixed_filament(size_t idx) const; + std::vector physical_filament_config_indices() const; + // How many slots are mixed. They sit at the tail of the filament list and have no nozzle of + // their own, so any resize driven by the printer's extruder count has to add this on top. + size_t num_mixed_filaments() const; + // How many slots hold a real filament, i.e. everything ahead of the mixed tail. + size_t num_physical_filaments() const; void on_extruders_count_changed(int extruder_count); diff --git a/src/libslic3r/Print.cpp b/src/libslic3r/Print.cpp index 509744abe2..40764342fb 100644 --- a/src/libslic3r/Print.cpp +++ b/src/libslic3r/Print.cpp @@ -5,6 +5,7 @@ #include "Brim.hpp" #include "ClipperUtils.hpp" #include "Extruder.hpp" +#include "FilamentMixer.hpp" #include "Flow.hpp" #include "Geometry/ConvexHull.hpp" #include "I18N.hpp" @@ -565,7 +566,7 @@ std::vector Print::extruders(bool conside_custom_gcode) const // If a wipe tower filament is explicitly set, ensure it participates in tool ordering. if (has_wipe_tower() && config().wipe_tower_filament != 0 && extruders.size() > 1) { - assert(config().wipe_tower_filament > 0 && config().wipe_tower_filament < int(config().nozzle_diameter.size())); + assert(config().wipe_tower_filament > 0 && config().wipe_tower_filament <= int(config().filament_diameter.size())); extruders.emplace_back(config().wipe_tower_filament - 1); // config value is 1-based } @@ -1327,6 +1328,19 @@ StringObjectException Print::validate(std::vector *warnin if (extruders.empty()) return { L("No extrusions under current settings.") }; + // Orca: a gradient mixed filament only renders its gradient with "Mixed color sublayer" on; + // without it ToolOrdering::resolve_mixed_filaments prints one whole component per layer and + // the gradient is dropped silently. extruders() already covers painting, height ranges, + // per-feature filament ids and supports, and still lists mixed slots under their own id here. + if (!m_config.enable_mixed_color_sublayer.value) { + const auto &is_mixed = m_config.filament_is_mixed.values; + const auto &gradient = m_config.filament_mixed_gradient.values; + if (std::any_of(extruders.begin(), extruders.end(), [&](unsigned int e) { + return e < is_mixed.size() && is_mixed[e] && e < gradient.size() && gradient[e]; })) + warn(L("A gradient mixed filament is used, but 'Mixed color sublayer' is disabled. The gradient will not be printed."), + "enable_mixed_color_sublayer"); + } + if (nozzles < 2 && extruders.size() > 1) { auto ret = check_multi_filament_valid(*this); if (!ret.string.empty()) @@ -1388,6 +1402,13 @@ StringObjectException Print::validate(std::vector *warnin // #4043 if (total_copies_count > 1 && m_config.print_sequence != PrintSequence::ByObject) return {L("Please select \"By object\" print sequence to print multiple objects in spiral vase mode."), nullptr, "spiral_mode"}; + // A mixed (virtual) filament always resolves to multiple physical components, which + // spiral vase cannot print. + const auto &is_mixed = m_config.filament_is_mixed.values; + for (const PrintObject *object : m_objects) + for (unsigned int ext : object->object_extruders()) + if (ext < is_mixed.size() && is_mixed[ext]) + return {L("Spiral (vase) mode does not work when an object contains more than one material."), nullptr, "spiral_mode"}; assert(m_objects.size() == 1); const auto all_regions = m_objects.front()->all_regions(); if (all_regions.size() > 1) { @@ -1464,6 +1485,17 @@ StringObjectException Print::validate(std::vector *warnin } if (this->has_wipe_tower() && ! m_objects.empty()) { + // Orca: wipe_tower_filament (issue #10971) is inserted into the tool order after + // resolve_mixed_filaments has expanded every mixed (virtual) slot, so a mixed slot here + // would reach the G-code as a tool change to a slot no nozzle carries. The GUI hides + // mixed slots from the option; this guards loaded projects and the CLI. + if (m_config.wipe_tower_filament > 0) { + const auto &is_mixed = m_config.filament_is_mixed.values; + const size_t wipe_idx = size_t(m_config.wipe_tower_filament - 1); + if (wipe_idx < is_mixed.size() && is_mixed[wipe_idx]) + return { L("The wipe tower filament cannot be a mixed filament."), nullptr, "wipe_tower_filament" }; + } + // Make sure all extruders use same diameter filament and have the same nozzle diameter // EPSILON comparison is used for nozzles and 10 % tolerance is used for filaments double first_nozzle_diam = m_config.nozzle_diameter.get_at(extruders.front()); @@ -2585,18 +2617,31 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) std::vector::const_iterator print_object_instance_sequential_active; std::vector>> layers_to_print = GCode::collect_layers_to_print(*this); std::vector printExtruders; + // Per-object first-layer mixed-slot resolutions for the by-object remap below + // (BBS reads them from m_sequential_print_data->object_tool_ordering_map). + std::map> seq_mixed_resolution; // Cleared on every process so a print-sequence or selector-mode change can never leave // stale object pointers behind; repopulated below only by the sequential selector path. m_sequential_dynamic_orderings.clear(); if (this->config().print_sequence == PrintSequence::ByObject) { // Order object instances for sequential print. print_object_instances_ordering = sort_object_instances_by_model_order(*this); + // A mixed slot is virtual; only its components reach a nozzle. These per-object orderings + // are unsorted (no resolve_mixed_filaments), so expand the slots here for the grouping, the + // unprintable sets and the slice-used lists. Because the expansion happens here rather than + // on the sorted orderings, the first-layer used set lists every component of a mixed slot, + // not just the one layer 0 resolves to. No-op without mixed filaments. + const auto &is_mixed = m_config.filament_is_mixed.values; + const auto &comp_strs = m_config.filament_mixed_components.values; + const bool has_mixed = has_any_mixed_filament(is_mixed); std::vector first_layer_used_filaments; std::vector> all_filaments; for (print_object_instance_sequential_active = print_object_instances_ordering.begin(); print_object_instance_sequential_active != print_object_instances_ordering.end(); ++print_object_instance_sequential_active) { tool_ordering = ToolOrdering(*(*print_object_instance_sequential_active)->print_object, initial_extruder_id); for (size_t idx = 0; idx < tool_ordering.layer_tools().size(); ++idx) { - auto& layer_filament = tool_ordering.layer_tools()[idx].extruders; + auto layer_filament = tool_ordering.layer_tools()[idx].extruders; + if (has_mixed) + layer_filament = expand_mixed_filaments(layer_filament, is_mixed, comp_strs); all_filaments.emplace_back(layer_filament); if (idx == 0) first_layer_used_filaments.insert(first_layer_used_filaments.end(), layer_filament.begin(), layer_filament.end()); @@ -2608,6 +2653,8 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) auto physical_unprintables = this->get_physical_unprintable_filaments(used_filaments); auto geometric_unprintables = this->get_geometric_unprintable_filaments(); + if (has_mixed) + expand_mixed_slots_in_unprintables(geometric_unprintables, is_mixed, comp_strs); auto filament_unprintable_volumes = this->get_filament_unprintable_flow(used_filaments); // Selector (per-layer regroup) prints skip the static grouping: their print-wide result // is stitched from the per-object plans after the ordering loop below. @@ -2659,6 +2706,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) std::vector> nozzle_map_per_layer; std::vector> stitched_layer_filaments; print_object_instance_sequential_active = print_object_instances_ordering.begin(); + std::vector used_mixed_filaments; for (; print_object_instance_sequential_active != print_object_instances_ordering.end(); ++print_object_instance_sequential_active) { const PrintObject *print_object = (*print_object_instance_sequential_active)->print_object; if (dynamic_reorder) { @@ -2687,11 +2735,18 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) } else { tool_ordering = ToolOrdering(*print_object, initial_extruder_id); tool_ordering.sort_and_build_data(*print_object, initial_extruder_id); + if (!tool_ordering.layer_tools().empty()) + seq_mixed_resolution[print_object->id()] = tool_ordering.layer_tools().front().mixed_filament_resolution; } + // Only sorted orderings have run resolve_mixed_filaments, so only they know which + // mixed slots actually print. + append(used_mixed_filaments, tool_ordering.used_mixed_filaments()); if ((initial_extruder_id = tool_ordering.first_extruder()) != static_cast(-1)) { append(printExtruders, tool_ordering.tools_for_layer(layers_to_print.front().first).extruders); } } + sort_remove_duplicates(used_mixed_filaments); + this->set_slice_used_mixed_filaments(used_mixed_filaments); if (dynamic_reorder && m_objects.size() > 1) { // Stitch the per-object plans into one print-wide selector result. A single-object // sequential print publishes (and writes back) from its own ordering instead: the @@ -2712,6 +2767,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) first_layer_used_filaments = tool_ordering.layer_tools().front().extruders; this->set_slice_used_filaments(first_layer_used_filaments, tool_ordering.all_extruders()); + this->set_slice_used_mixed_filaments(tool_ordering.used_mixed_filaments()); has_wipe_tower = this->has_wipe_tower() && tool_ordering.has_wipe_tower(); initial_extruder_id = tool_ordering.first_extruder(); print_object_instances_ordering = chain_print_object_instances(*this); @@ -2719,6 +2775,28 @@ void Print::process(long long *time_cost_with_cache, bool use_cache) } auto objectExtruderMap = getObjectExtruderMap(*this); + // Resolve mixed filament virtual slots to physical components so brim + // extruder matching works correctly (mixed slot IDs are not present + // in printExtruders after ToolOrdering::resolve_mixed_filaments). + { + const LayerTools *first_lt = nullptr; + if (m_config.print_sequence != PrintSequence::ByObject && !tool_ordering.layer_tools().empty()) + first_lt = &tool_ordering.layer_tools().front(); + for (auto &[obj_id, ext_1based] : objectExtruderMap) { + if (ext_1based == 0) + continue; + const std::map *resolution = nullptr; + if (first_lt) + resolution = &first_lt->mixed_filament_resolution; + else if (auto obj_it = seq_mixed_resolution.find(obj_id); obj_it != seq_mixed_resolution.end()) + resolution = &obj_it->second; + if (resolution) { + auto it = resolution->find(ext_1based - 1); + if (it != resolution->end()) + ext_1based = it->second + 1; + } + } + } std::vector> objPrintVec; for (const PrintInstance* instance : print_object_instances_ordering) { const ObjectID& print_object_ID = instance->print_object->id(); @@ -3712,7 +3790,7 @@ std::vector Print::get_extruder_printable_polygons() const Polygons ploys = {Polygon::new_scale(e_printable_area)}; extruder_printable_polys.emplace_back(ploys); } - return std::move(extruder_printable_polys); + return extruder_printable_polys; } std::vector Print::get_extruder_unprintable_polygons() const @@ -3725,7 +3803,7 @@ std::vector Print::get_extruder_unprintable_polygons() const Polygons ploys = diff(printable_poly, Polygon::new_scale(e_printable_area)); extruder_unprintable_polys.emplace_back(ploys); } - return std::move(extruder_unprintable_polys); + return extruder_unprintable_polys; } size_t Print::get_extruder_id(unsigned int filament_id) const @@ -3776,6 +3854,14 @@ bool Print::is_dynamic_group_reorder() const const bool enabled = opt && opt->value; if (!enabled || m_config.filament_map_mode != FilamentMapMode::fmmAutoForFlush || m_config.nozzle_diameter.size() <= 1) return false; + + // Dynamic regrouping and mixed-color slots are incompatible: a mixed slot is resolved to + // different physical components per layer, so a group assignment made up-front would be wrong. + const auto &is_mixed = m_config.filament_is_mixed.values; + for (unsigned int filament_id : extruders()) { + if (filament_id < is_mixed.size() && is_mixed[filament_id]) + return false; + } return true; } @@ -3999,38 +4085,36 @@ void Print::_make_wipe_tower() return; // Check whether there are any layers in m_tool_ordering, which are marked with has_wipe_tower, - // they print neither object, nor support. These layers are above the raft and below the object, and they - // shall be added to the support layers to be printed. - // see https://github.com/prusa3d/PrusaSlicer/issues/607 + // they print neither object, nor support. Each such layer needs a virtual support layer + // counterpart in m_objects.front() so that GCode::collect_layers_to_print picks it up and the + // wipe tower G-code is actually emitted for that z. Such layers appear in two scenarios: + // - above the raft, between raft top and the first real object layer + // (see https://github.com/prusa3d/PrusaSlicer/issues/607); + // - between two real wipe-tower layers, when one object is fully floating above another and + // the support_top_z_distance / support_bottom_z_distance gap leaves interior z values with + // neither object nor support (continuity fill in ToolOrdering::fill_wipe_tower_partitions). + // The previous implementation only handled the first contiguous run starting at the first + // virtual layer, which made the second scenario silently produce empty wipe-tower layers. { - size_t idx_begin = size_t(-1); - size_t idx_end = m_wipe_tower_data.tool_ordering.layer_tools().size(); - // Find the first wipe tower layer, which does not have a counterpart in an object or a support layer. + auto &support_layers = m_objects.front()->support_layers(); + auto it_layer = support_layers.begin(); + const size_t idx_end = m_wipe_tower_data.tool_ordering.layer_tools().size(); for (size_t i = 0; i < idx_end; ++ i) { - const LayerTools < = m_wipe_tower_data.tool_ordering.layer_tools()[i]; - if (lt.has_wipe_tower && ! lt.has_object && ! lt.has_support) { - idx_begin = i; - break; - } - } - if (idx_begin != size_t(-1)) { - // Find the position in m_objects.first()->support_layers to insert these new support layers. - double wipe_tower_new_layer_print_z_first = m_wipe_tower_data.tool_ordering.layer_tools()[idx_begin].print_z; - auto it_layer = m_objects.front()->support_layers().begin(); - auto it_end = m_objects.front()->support_layers().end(); - for (; it_layer != it_end && (*it_layer)->print_z - EPSILON < wipe_tower_new_layer_print_z_first; ++ it_layer); - // Find the stopper of the sequence of wipe tower layers, which do not have a counterpart in an object or a support layer. - for (size_t i = idx_begin; i < idx_end; ++ i) { - LayerTools < = const_cast(m_wipe_tower_data.tool_ordering.layer_tools()[i]); - if (! (lt.has_wipe_tower && ! lt.has_object && ! lt.has_support)) - break; - lt.has_support = true; - // Insert the new support layer. - double height = lt.print_z - (i == 0 ? 0. : m_wipe_tower_data.tool_ordering.layer_tools()[i-1].print_z); - //FIXME the support layer ID is set to -1, as Vojtech hopes it is not being used anyway. - it_layer = m_objects.front()->insert_support_layer(it_layer, -1, 0, height, lt.print_z, lt.print_z - 0.5 * height); + LayerTools < = const_cast(m_wipe_tower_data.tool_ordering.layer_tools()[i]); + if (! (lt.has_wipe_tower && ! lt.has_object && ! lt.has_support)) + continue; + while (it_layer != support_layers.end() && (*it_layer)->print_z + EPSILON < lt.print_z) ++ it_layer; + if (it_layer != support_layers.end() && std::abs((*it_layer)->print_z - lt.print_z) < EPSILON) { + lt.has_support = true; + ++ it_layer; + continue; } + lt.has_support = true; + double height = lt.print_z - (i == 0 ? 0. : m_wipe_tower_data.tool_ordering.layer_tools()[i-1].print_z); + //FIXME the support layer ID is set to -1, as Vojtech hopes it is not being used anyway. + it_layer = m_objects.front()->insert_support_layer(it_layer, -1, 0, height, lt.print_z, lt.print_z - 0.5 * height); + ++ it_layer; } } this->throw_if_canceled(); diff --git a/src/libslic3r/Print.hpp b/src/libslic3r/Print.hpp index b38a0ca058..9e20061501 100644 --- a/src/libslic3r/Print.hpp +++ b/src/libslic3r/Print.hpp @@ -117,9 +117,9 @@ class PrintRegion public: PrintRegion() = default; PrintRegion(const PrintRegionConfig &config); - PrintRegion(const PrintRegionConfig &config, const size_t config_hash, int print_object_region_id = -1) : m_config(config), m_config_hash(config_hash), m_print_object_region_id(print_object_region_id) {} + PrintRegion(const PrintRegionConfig &config, const size_t config_hash, int print_object_region_id = -1, ObjectID gradient_volume_id = ObjectID()) : m_config(config), m_config_hash(config_hash), m_print_object_region_id(print_object_region_id), m_gradient_volume_id(gradient_volume_id) {} PrintRegion(PrintRegionConfig &&config); - PrintRegion(PrintRegionConfig &&config, const size_t config_hash, int print_object_region_id = -1) : m_config(std::move(config)), m_config_hash(config_hash), m_print_object_region_id(print_object_region_id) {} + PrintRegion(PrintRegionConfig &&config, const size_t config_hash, int print_object_region_id = -1, ObjectID gradient_volume_id = ObjectID()) : m_config(std::move(config)), m_config_hash(config_hash), m_print_object_region_id(print_object_region_id), m_gradient_volume_id(gradient_volume_id) {} ~PrintRegion() = default; // Methods NOT modifying the PrintRegion's state: @@ -129,6 +129,10 @@ public: // Identifier of this PrintRegion in the list of Print::m_print_regions. int print_region_id() const throw() { return m_print_region_id; } int print_object_region_id() const throw() { return m_print_object_region_id; } + // Volume identity used to differentiate same-config regions when per-part gradient is enabled. + // Default-constructed (invalid) means this region is not tied to a specific volume — preserves + // existing behavior for all paths not using per_part_gradient. + ObjectID gradient_volume_id() const throw() { return m_gradient_volume_id; } // 1-based extruder identifier for this region and role. unsigned int extruder(FlowRole role) const; Flow flow(const PrintObject &object, FlowRole role, double layer_height, bool first_layer = false) const; @@ -158,6 +162,10 @@ private: int m_print_region_id { -1 }; int m_print_object_region_id { -1 }; int m_ref_cnt { 0 }; + // Per-part gradient: when non-invalid, this region belongs exclusively to one ModelVolume, + // letting same-color volumes within a combined ModelObject be tracked separately for gradient + // emission. Default invalid -> region keying behaves exactly as before. + ObjectID m_gradient_volume_id; }; inline bool operator==(const PrintRegion &lhs, const PrintRegion &rhs) { return lhs.config_hash() == rhs.config_hash() && lhs.config() == rhs.config(); } @@ -306,6 +314,11 @@ public: Transform3d trafo_bboxes; std::vector cached_volume_ids; + // Per-part gradient: the slot_per_part_enabled bit vector that produced these regions. + // Print::apply compares it against the current one to detect a change that PrintRegionConfig + // alone would not reveal, and regenerates the regions when it differs. + std::vector last_slot_per_part_enabled; + void ref_cnt_inc() { ++ m_ref_cnt; } void ref_cnt_dec() { if (-- m_ref_cnt == 0) delete this; } void clear() { @@ -930,8 +943,8 @@ public: // If preview_data is not null, the preview_data is filled in for the G-code visualization (not used by the command line Slic3r). std::string export_gcode(const std::string& path_template, GCodeProcessorResult* result, ThumbnailsGeneratorCallback thumbnail_cb = nullptr); //return 0 means successful - int export_cached_data(const std::string& dir_path, bool with_space=false); - int load_cached_data(const std::string& directory); + int export_cached_data(const std::string& dir_path, bool with_space=false) override; + int load_cached_data(const std::string& directory) override; // methods for handling state bool is_step_done(PrintStep step) const { return Inherited::is_step_done(step); } @@ -1075,6 +1088,10 @@ public: m_slice_used_filaments = used_filaments; } std::vector get_slice_used_filaments(bool first_layer) const { return first_layer ? m_slice_used_filaments_first_layer : m_slice_used_filaments;} + void set_slice_used_mixed_filaments(const std::vector &used_mixed_filaments) { + m_slice_used_mixed_filaments = used_mixed_filaments; + } + const std::vector& get_slice_used_mixed_filaments() const { return m_slice_used_mixed_filaments; } /** * @brief Determines the unprintable filaments for each extruder based on its physical attributes @@ -1342,6 +1359,8 @@ private: std::vector m_slice_used_filaments; std::vector m_slice_used_filaments_first_layer; + // 0-based mixed (virtual) filament slots actually used on this plate. + std::vector m_slice_used_mixed_filaments; //BBS: plate's origin Vec3d m_origin {0, 0, 0}; diff --git a/src/libslic3r/PrintApply.cpp b/src/libslic3r/PrintApply.cpp index e2e9bc737d..f03271bf73 100644 --- a/src/libslic3r/PrintApply.cpp +++ b/src/libslic3r/PrintApply.cpp @@ -1,6 +1,7 @@ #include "ClipperUtils.hpp" #include "Model.hpp" #include "Print.hpp" +#include "FilamentMixer.hpp" #include #include @@ -886,7 +887,12 @@ bool verify_update_print_object_regions( size_t hash = regions[i]->config_hash(); size_t j = i; for (++ j; j < regions.size() && regions[j]->config_hash() == hash; ++ j) - if (regions[i]->config() == regions[j]->config()) { + // Same config but different gradient_volume_id is intentional (per-part gradient + // splitting) and must NOT be flagged as a merge. When per-part is off all regions + // carry an invalid (default) gradient_volume_id, so the AND condition is always + // true and behavior matches the legacy check. + if (regions[i]->config() == regions[j]->config() + && regions[i]->gradient_volume_id() == regions[j]->gradient_volume_id()) { // Regions were merged. We need to reslice. return false; } @@ -978,7 +984,10 @@ static PrintObjectRegions* generate_print_object_regions( const float xy_contour_compensation, const std::vector &painting_extruders, std::vector &variant_index, - const bool has_painted_fuzzy_skin) + const bool has_painted_fuzzy_skin, + // Per-part gradient: slot_per_part_enabled[s-1] is true when mixed slot s has + // filament_mixed_gradient_per_part on. Empty / all-false preserves legacy behavior. + const std::vector &slot_per_part_enabled = {}) { // Reuse the old object or generate a new one. auto out = print_object_regions_old ? std::unique_ptr(print_object_regions_old) : std::make_unique(); @@ -1013,19 +1022,71 @@ static PrintObjectRegions* generate_print_object_regions( update_volume_bboxes(layer_ranges_regions, out->cached_volume_ids, model_volumes, out->trafo_bboxes, is_mm_painted ? 0.f : std::max(0.f, xy_contour_compensation)); std::vector region_set; - auto get_create_region = [®ion_set, &all_regions](PrintRegionConfig &&config) -> PrintRegion* { + // Look up or create a PrintRegion. The optional volume_tag, when valid (non-zero ObjectID), + // keys the region to one ModelVolume so two volumes with identical settings still get + // separate regions — needed so each part can run its own gradient. A default (invalid) + // tag reproduces the previous lookup exactly. + auto get_create_region = [®ion_set, &all_regions](PrintRegionConfig &&config, ObjectID volume_tag = ObjectID()) -> PrintRegion* { size_t hash = config.hash(); - auto it = Slic3r::lower_bound_by_predicate(region_set.begin(), region_set.end(), [&config, hash](const PrintRegion* l) { - return l->config_hash() < hash || (l->config_hash() == hash && l->config() < config); }); - if (it != region_set.end() && (*it)->config_hash() == hash && (*it)->config() == config) + auto it = Slic3r::lower_bound_by_predicate(region_set.begin(), region_set.end(), [&config, hash, volume_tag](const PrintRegion* l) { + return l->config_hash() < hash || (l->config_hash() == hash && l->config() < config) + || (l->config_hash() == hash && l->config() == config && l->gradient_volume_id() < volume_tag); }); + if (it != region_set.end() && (*it)->config_hash() == hash && (*it)->config() == config + && (*it)->gradient_volume_id() == volume_tag) return *it; // Insert into a sorted array, it has O(n) complexity, but the calling algorithm has an O(n^2*log(n)) complexity anyways. - all_regions.emplace_back(std::make_unique(std::move(config), hash, int(all_regions.size()))); + all_regions.emplace_back(std::make_unique(std::move(config), hash, int(all_regions.size()), volume_tag)); PrintRegion *region = all_regions.back().get(); region_set.emplace(it, region); return region; }; + // Per-part gradient: count how many model-part volumes in this object use each + // per-part-enabled gradient slot. Only slots with at least 2 users get their volumes + // tagged — a single-user slot gains nothing from per-volume splitting and would only + // inflate the region count. Empty slot_per_part_enabled leaves this empty, so + // compute_volume_tag below always returns an invalid tag and nothing changes. + std::vector per_part_volume_users; + if (!slot_per_part_enabled.empty()) { + per_part_volume_users.assign(slot_per_part_enabled.size(), 0); + for (const ModelVolume *mv : model_volumes) { + if (! mv->is_model_part()) + continue; + const DynamicPrintConfig *range_cfg = layer_ranges_regions.empty() ? nullptr : layer_ranges_regions.front().config; + PrintRegionConfig vol_cfg = region_config_from_model_volume(default_region_config, range_cfg, *mv, num_extruders, variant_index); + for (unsigned int s_1based : { (unsigned int)vol_cfg.outer_wall_filament_id.value, + (unsigned int)vol_cfg.inner_wall_filament_id.value, + (unsigned int)vol_cfg.sparse_infill_filament_id.value, + (unsigned int)vol_cfg.internal_solid_filament_id.value, + (unsigned int)vol_cfg.top_surface_filament_id.value, + (unsigned int)vol_cfg.bottom_surface_filament_id.value }) { + if (s_1based >= 1 + && size_t(s_1based - 1) < slot_per_part_enabled.size() + && slot_per_part_enabled[s_1based - 1]) + ++per_part_volume_users[s_1based - 1]; + } + } + } + auto compute_volume_tag = [&](const PrintRegionConfig &cfg, const ModelVolume &mv) -> ObjectID { + if (per_part_volume_users.empty()) + return ObjectID(); + auto qualifies = [&](unsigned int s_1based) { + return s_1based >= 1 + && size_t(s_1based - 1) < slot_per_part_enabled.size() + && slot_per_part_enabled[s_1based - 1] + && per_part_volume_users[s_1based - 1] >= 2; + }; + if (qualifies((unsigned int)cfg.outer_wall_filament_id.value) + || qualifies((unsigned int)cfg.inner_wall_filament_id.value) + || qualifies((unsigned int)cfg.sparse_infill_filament_id.value) + || qualifies((unsigned int)cfg.internal_solid_filament_id.value) + || qualifies((unsigned int)cfg.top_surface_filament_id.value) + || qualifies((unsigned int)cfg.bottom_surface_filament_id.value)) { + return mv.id(); + } + return ObjectID(); + }; + // Chain the regions in the order they are stored in the volumes list. for (int volume_id = 0; volume_id < int(model_volumes.size()); ++ volume_id) { const ModelVolume &volume = *model_volumes[volume_id]; @@ -1034,9 +1095,11 @@ static PrintObjectRegions* generate_print_object_regions( if (const PrintObjectRegions::BoundingBox *bbox = find_volume_extents(layer_range, volume); bbox) { if (volume.is_model_part()) { // Add a model volume, assign an existing region or generate a new one. + PrintRegionConfig vol_cfg = region_config_from_model_volume(default_region_config, layer_range.config, volume, num_extruders, variant_index); + ObjectID volume_tag = compute_volume_tag(vol_cfg, volume); layer_range.volume_regions.push_back({ &volume, -1, - get_create_region(region_config_from_model_volume(default_region_config, layer_range.config, volume, num_extruders, variant_index)), + get_create_region(std::move(vol_cfg), volume_tag), bbox }); } else if (volume.is_negative_volume()) { @@ -1121,6 +1184,12 @@ static PrintObjectRegions* generate_print_object_regions( } } + + // Save the slot_per_part_enabled bit vector that produced these regions, so the guard in + // Print::apply can detect changes on the next call even when PrintRegionConfig did not + // change. Always written — including an empty vector — so the snapshot always reflects + // the exact input used to generate the current regions. + out->last_slot_per_part_enabled = slot_per_part_enabled; return out.release(); } @@ -1141,6 +1210,17 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ std::vector used_filaments = this->extruders(true); std::unordered_set used_filament_set(used_filaments.begin(), used_filaments.end()); + // A mixed slot is virtual: the filaments actually consumed are its components, so add them + // to the used set or they would be treated as unused and stripped from the config. + { + auto* is_mixed_opt = new_full_config.option("filament_is_mixed"); + auto* comp_strs_opt = new_full_config.option("filament_mixed_components"); + if (is_mixed_opt && comp_strs_opt && has_any_mixed_filament(is_mixed_opt->values)) { + auto expanded = expand_mixed_filaments(used_filaments, is_mixed_opt->values, comp_strs_opt->values); + used_filament_set.insert(expanded.begin(), expanded.end()); + } + } + //new_full_config.normalize_fdm(used_filaments); new_full_config.normalize_fdm_1(); t_config_option_keys changed_keys = new_full_config.normalize_fdm_2(objects().size(), used_filaments.size()); @@ -1802,6 +1882,29 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ update_filament_self_index_cache(); } + // Per-part gradient: compute the per-slot enable bit vector once for this Print::apply pass. + // Used by generate_print_object_regions to decide which volumes deserve their own PrintRegion. + std::vector slot_per_part_enabled; + { + const auto &is_mixed_vec = m_config.filament_is_mixed.values; + const auto &grad_vec = m_config.filament_mixed_gradient.values; + const auto &per_part_vec = m_config.filament_mixed_gradient_per_part.values; + const auto &components_vec = m_config.filament_mixed_components.values; + slot_per_part_enabled.assign(is_mixed_vec.size(), false); + for (size_t i = 0; i < is_mixed_vec.size(); ++i) { + if (! is_mixed_vec[i]) + continue; + std::vector comps = parse_mixed_components(i < components_vec.size() ? components_vec[i] : ""); + if (comps.size() != 2) + continue; + if (i >= grad_vec.size() || ! grad_vec[i]) + continue; + if (i >= per_part_vec.size() || ! per_part_vec[i]) + continue; + slot_per_part_enabled[i] = true; + } + } + // All regions now have distinct settings. // Check whether applying the new region config defaults we would get different regions, // update regions or create regions from scratch. @@ -1828,7 +1931,8 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ for (const ModelVolume *volume : volumes) { const std::vector &volume_used_facet_states = volume->mmu_segmentation_facets.get_data().used_states; - assert(volume_used_facet_states.size() == used_facet_states.size()); + // Paint data saved before the painted state range was extended deserializes a + // shorter used_states vector, so merge over the common prefix. for (size_t state_idx = 0; state_idx < std::min(volume_used_facet_states.size(), used_facet_states.size()); ++state_idx) used_facet_states[state_idx] |= volume_used_facet_states[state_idx]; } @@ -1862,6 +1966,15 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ update_apply_status((*it)->invalidate_state_by_config_options(old_config, new_config, diff_keys)); }, print_variant_index)) { + // Per-part gradient: PrintRegionConfig alone cannot reveal a change in which slots + // have per-part enabled, so compare against the snapshot taken when these regions + // were generated and regenerate on any difference (slot toggled, per-part moved + // between slots, eligibility changed via components / gradient / is_mixed). + if (print_object_regions->last_slot_per_part_enabled != slot_per_part_enabled) { + invalidate(); + model_object_status.print_object_regions_status = ModelObjectStatus::PrintObjectRegionsStatus::PartiallyValid; + print_regions_reshuffled = true; + } // Regions are valid, just keep them. } else { // Regions were reshuffled. @@ -1884,7 +1997,8 @@ Print::ApplyStatus Print::apply(const Model &model, DynamicPrintConfig new_full_ print_object.is_mm_painted() ? 0.f : float(print_object.config().xy_contour_compensation.value), painting_extruders, print_variant_index, - print_object.is_fuzzy_skin_painted()); + print_object.is_fuzzy_skin_painted(), + slot_per_part_enabled); } for (auto it = it_print_object; it != it_print_object_end; ++it) if ((*it)->m_shared_regions) { diff --git a/src/libslic3r/PrintConfig.cpp b/src/libslic3r/PrintConfig.cpp index 8083da954e..2a4eb8d7a7 100644 --- a/src/libslic3r/PrintConfig.cpp +++ b/src/libslic3r/PrintConfig.cpp @@ -2,6 +2,7 @@ #include "PrintConfigConstants.hpp" #include "ClipperUtils.hpp" #include "Config.hpp" +#include "FilamentMixer.hpp" #include "MaterialType.hpp" #include "I18N.hpp" #include "format.hpp" @@ -3263,6 +3264,62 @@ void PrintConfigDef::init_fff_params() def->mode = comAdvanced; def->set_default_value(new ConfigOptionBools { false }); + // Mixed-color filament. A slot flagged here is virtual: it is not loaded into any + // physical extruder, but resolved at slicing time into the physical filaments listed + // in filament_mixed_components, blended either by splitting each layer into + // sub-layers or by alternating whole layers (see enable_mixed_color_sublayer). + def = this->add("filament_is_mixed", coBools); + def->label = L("Is mixed filament"); + def->tooltip = L("Whether this filament slot is a mixed filament composed of multiple physical filaments"); + def->mode = comDevelop; + def->set_default_value(new ConfigOptionBools{false}); + + def = this->add("filament_mixed_components", coStrings); + def->label = L("Mixed filament components"); + def->tooltip = L("Comma-separated 1-based indices of component filaments, e.g. \"1,3\""); + def->mode = comDevelop; + def->set_default_value(new ConfigOptionStrings{""}); + + def = this->add("filament_mixed_sublayer_ratios", coStrings); + def->label = L("Mixed filament sublayer ratios"); + def->tooltip = L("Comma-separated ratio values summing to 1.0, e.g. \"0.7,0.3\""); + def->mode = comDevelop; + def->set_default_value(new ConfigOptionStrings{""}); + + def = this->add("filament_mixed_gradient", coBools); + def->label = L("Mixed filament gradient"); + def->tooltip = L("Enable Z-direction gradient mode for mixed filament sub-layers. " + "When enabled, the sub-layer ratios vary linearly across layers."); + def->mode = comDevelop; + def->set_default_value(new ConfigOptionBools{false}); + + def = this->add("filament_mixed_gradient_range", coStrings); + def->label = L("Mixed filament gradient range"); + def->tooltip = L("Start and end ratios for the first component in gradient mode. " + "Comma-separated pair, e.g. \"0.10,0.90\" means 10% to 90%."); + def->mode = comDevelop; + def->set_default_value(new ConfigOptionStrings{""}); + + def = this->add("filament_mixed_gradient_curve", coStrings); + def->label = L("Mixed filament gradient curve"); + def->tooltip = L("Optional Photoshop-style custom curve mapping Z progress to the first " + "component ratio. Encoded as pipe-separated control points, " + "either \"x,y\" (legacy) or \"x,y,m_in,m_out\" when a tangent override " + "is needed (empty token or \"nan\" means use PCHIP default). " + "x in [0,1]; y is clamped to the configured ratio range, " + "e.g. \"0,0.15|0.5,0.50|1,0.85\". When empty, the linear " + "gradient_range is used instead."); + def->mode = comDevelop; + def->set_default_value(new ConfigOptionStrings{""}); + + def = this->add("filament_mixed_gradient_per_part", coBools); + def->label = L("Mixed filament per-part gradient"); + def->tooltip = L("When gradient mode is enabled, apply the gradient to each part of an " + "assembly independently rather than treating the whole assembly as one " + "Z range."); + def->mode = comDevelop; + def->set_default_value(new ConfigOptionBools{false}); + // defined in bits // 0 means cannot support, 1 means support // 0 bit: can support in left extruder @@ -7402,6 +7459,14 @@ void PrintConfigDef::init_fff_params() def->mode = comAdvanced; def->set_default_value(new ConfigOptionFloats { 1. }); + def = this->add("enable_mixed_color_sublayer", coBool); + def->label = L("Mixed color sublayer"); + def->tooltip = L("Enable mixed color sublayer splitting. When enabled, layers containing mixed color " + "filaments will be split into sub-layers to achieve color mixing effects."); + def->category = L("Quality"); + def->mode = comSimple; + def->set_default_value(new ConfigOptionBool(false)); + def = this->add("enable_prime_tower", coBool); def->label = L("Enable"); def->tooltip = L("The wiping tower can be used to clean up residue on the nozzle and stabilize the chamber pressure inside the nozzle in order to avoid appearance defects when printing objects."); @@ -9605,7 +9670,15 @@ t_config_option_keys DynamicPrintConfig::normalize_fdm_2(int num_objects, int us ConfigOptionBool *enable_wrapping_opt = this->option("enable_wrapping_detection"); bool enable_wrapping = enable_wrapping_opt != nullptr && enable_wrapping_opt->value; - if (!is_smooth_timelapse && !enable_wrapping && (used_filaments == 1 || (ps_opt->value == PrintSequence::ByObject && num_objects > 1))) { + bool has_mixed_filament = false; + { + auto *mixed_opt = this->option("filament_is_mixed"); + if (mixed_opt) + has_mixed_filament = has_any_mixed_filament(mixed_opt->values); + } + if (!is_smooth_timelapse && !enable_wrapping + && ( (used_filaments == 1 && !has_mixed_filament) + || (ps_opt->value == PrintSequence::ByObject && num_objects > 1))) { if (ept_opt->value) { ept_opt->value = false; changed_keys.push_back("enable_prime_tower"); @@ -11753,6 +11826,23 @@ std::map validate(const FullPrintConfig &cfg, bool und } } + // Mixed-color (混色) parameter validation. + { + const auto &is_mixed = cfg.filament_is_mixed.values; + const auto &comp_strs = cfg.filament_mixed_components.values; + const auto &ratio_strs = cfg.filament_mixed_sublayer_ratios.values; + const auto &gradient_flags = cfg.filament_mixed_gradient.values; + const auto &range_strs = cfg.filament_mixed_gradient_range.values; + const auto &curve_strs = cfg.filament_mixed_gradient_curve.values; + + std::map mixed_errors = validate_mixed_filament_params( + is_mixed, comp_strs, ratio_strs, gradient_flags, + range_strs, curve_strs); + for (const auto &kv : mixed_errors) + if (error_message.find(kv.first) == error_message.end()) + error_message.emplace(kv.first, kv.second); + } + // The configuration is valid. return error_message; } diff --git a/src/libslic3r/PrintConfig.hpp b/src/libslic3r/PrintConfig.hpp index 255c8721b9..330151c4d3 100644 --- a/src/libslic3r/PrintConfig.hpp +++ b/src/libslic3r/PrintConfig.hpp @@ -1538,6 +1538,14 @@ PRINT_CONFIG_CLASS_DEFINE( ((ConfigOptionStrings, filament_colour)) ((ConfigOptionStrings, filament_vendor)) ((ConfigOptionBools, filament_is_support)) + // Mixed-color filament: a virtual slot realized from 2-3 physical filaments. + ((ConfigOptionBools, filament_is_mixed)) + ((ConfigOptionStrings, filament_mixed_components)) + ((ConfigOptionStrings, filament_mixed_sublayer_ratios)) + ((ConfigOptionBools, filament_mixed_gradient)) + ((ConfigOptionStrings, filament_mixed_gradient_range)) + ((ConfigOptionStrings, filament_mixed_gradient_curve)) + ((ConfigOptionBools, filament_mixed_gradient_per_part)) ((ConfigOptionInts, filament_printable)) ((ConfigOptionInts, filament_extruder_compatibility)) ((ConfigOptionFloats, filament_change_length)) @@ -1838,6 +1846,7 @@ PRINT_CONFIG_CLASS_DERIVED_DEFINE( ((ConfigOptionInts, nozzle_temperature_range_low)) ((ConfigOptionInts, nozzle_temperature_range_high)) ((ConfigOptionFloats, wipe_distance)) + ((ConfigOptionBool, enable_mixed_color_sublayer)) ((ConfigOptionBool, enable_prime_tower)) ((ConfigOptionBool, prime_tower_enable_framework)) // BBS: change wipe_tower_x and wipe_tower_y data type to floats to add partplate logic diff --git a/src/libslic3r/PrintObject.cpp b/src/libslic3r/PrintObject.cpp index 8368de1a4f..7bc18f6b86 100644 --- a/src/libslic3r/PrintObject.cpp +++ b/src/libslic3r/PrintObject.cpp @@ -906,7 +906,7 @@ void PrintObject::detect_overhangs_for_lift() Layer& lower_layer = *layer.lower_layer; ExPolygons overhangs = diff_ex(layer.lslices, offset_ex(lower_layer.lslices, scale_(min_overlap))); - layer.loverhangs = std::move(offset2_ex(overhangs, -0.1f * scale_(line_width), 0.1f * scale_(line_width))); + layer.loverhangs = offset2_ex(overhangs, -0.1f * scale_(line_width), 0.1f * scale_(line_width)); layer.loverhangs_bbox = get_extents(layer.loverhangs); } }); diff --git a/src/libslic3r/Shape/TextShape.cpp b/src/libslic3r/Shape/TextShape.cpp index dce731af19..4f32b9d857 100644 --- a/src/libslic3r/Shape/TextShape.cpp +++ b/src/libslic3r/Shape/TextShape.cpp @@ -199,7 +199,7 @@ static void MakeMesh(TopoDS_Shape& theSolid, TriangleMesh& theMesh) for (Standard_Integer aNodeIter = 1; aNodeIter <= aTriangulation->NbNodes(); ++aNodeIter) { gp_Pnt aPnt = aTriangulation->Node(aNodeIter); aPnt.Transform(aTrsf); - points.emplace_back(std::move(Vec3f(aPnt.X(), aPnt.Y(), aPnt.Z()))); + points.emplace_back(Vec3f(aPnt.X(), aPnt.Y(), aPnt.Z())); } //BBS: copy triangles const TopAbs_Orientation anOrientation = anExpSF.Current().Orientation(); diff --git a/src/libslic3r/Support/TreeSupport.cpp b/src/libslic3r/Support/TreeSupport.cpp index d4a43a4767..de08870216 100644 --- a/src/libslic3r/Support/TreeSupport.cpp +++ b/src/libslic3r/Support/TreeSupport.cpp @@ -842,7 +842,7 @@ void TreeSupport::detect_overhangs(bool check_support_necessity/* = false*/) // normal overhang ExPolygons lower_layer_offseted = offset_ex(lower_polys, support_offset_scaled, SUPPORT_SURFACES_OFFSET_PARAMETERS); - overhangs_all_layers[layer_nr] = std::move(diff_ex(curr_polys, lower_layer_offseted)); + overhangs_all_layers[layer_nr] = diff_ex(curr_polys, lower_layer_offseted); double duration{ std::chrono::duration_cast(clock_::now() - t0).count() }; if (duration > 30 || overhangs_all_layers[layer_nr].size() > 100) { @@ -1396,7 +1396,7 @@ void TreeSupport::generate_toolpaths() raft_areas.push_back(expoly); } - raft_areas = std::move(offset_ex(raft_areas, scale_(object_config.raft_first_layer_expansion))); + raft_areas = offset_ex(raft_areas, scale_(object_config.raft_first_layer_expansion)); size_t layer_nr = 0; for (; layer_nr < m_slicing_params.base_raft_layers; layer_nr++) { @@ -1522,9 +1522,9 @@ void TreeSupport::generate_toolpaths() erSupportMaterialInterface : erSupportMaterial; make_perimeter_and_inner_brim(ts_layer->support_fills.entities, poly, wall_count, flow, brim_role); - polys = std::move(offset_ex(poly, -flow.scaled_spacing())); + polys = offset_ex(poly, -flow.scaled_spacing()); } else if (area_group.type == SupportLayer::Roof1stLayer) { - polys = std::move(offset_ex(poly, 0.5*support_flow.scaled_width())); + polys = offset_ex(poly, 0.5*support_flow.scaled_width()); } else { polys.push_back(poly); @@ -2269,7 +2269,7 @@ void TreeSupport::draw_circles() // Inside the gap: remove only the part overlapping the contact surface, keep the rest. if (bottom_gap_height > EPSILON && layer_bottom_z < band_gap_top - EPSILON) { any_gap_cleared = true; - comp_poly = std::move(diff_ex(comp_poly, band.surfaces)); + comp_poly = diff_ex(comp_poly, band.surfaces); } // Overlaps interface band @@ -2304,7 +2304,7 @@ void TreeSupport::draw_circles() ExPolygons comp_interface = band_ex.empty() ? ExPolygons {} : intersection_ex(comp_poly, band_ex); if (!comp_interface.empty()) { append(new_floor_areas, comp_interface); - comp_poly = std::move(diff_ex(comp_poly, offset_ex(comp_interface, 10))); + comp_poly = diff_ex(comp_poly, offset_ex(comp_interface, 10)); } } @@ -2396,7 +2396,7 @@ void TreeSupport::draw_circles() ts_layer->lslices.emplace_back(*expoly); } - ts_layer->lslices = std::move(union_ex(ts_layer->lslices)); + ts_layer->lslices = union_ex(ts_layer->lslices); //Must update bounding box which is used in avoid crossing perimeter ts_layer->lslices_bboxes.clear(); ts_layer->lslices_bboxes.reserve(ts_layer->lslices.size()); @@ -2474,7 +2474,7 @@ void TreeSupport::draw_circles() if (global_lightning_infill) { //search overhangs globally - overhang = std::move(diff_ex(offset_ex(base_areas_lower, -2.0 * scale_(support_extrusion_width)), base_areas)); + overhang = diff_ex(offset_ex(base_areas_lower, -2.0 * scale_(support_extrusion_width)), base_areas); } else { @@ -2485,13 +2485,13 @@ void TreeSupport::draw_circles() Polygon rev_hole = hole; rev_hole.make_counter_clockwise(); ExPolygons ex_hole; - ex_hole.emplace_back(std::move(ExPolygon(rev_hole))); + ex_hole.emplace_back(ExPolygon(rev_hole)); for (auto& other_area : base_areas) //if (&other_area != &base_area) - ex_hole = std::move(diff_ex(ex_hole, other_area)); - overhang = std::move(union_ex(overhang, ex_hole)); + ex_hole = diff_ex(ex_hole, other_area); + overhang = union_ex(overhang, ex_hole); } - overhang = std::move(intersection_ex(overhang, offset_ex(base_areas_lower, -0.5 * scale_(support_extrusion_width)))); + overhang = intersection_ex(overhang, offset_ex(base_areas_lower, -0.5 * scale_(support_extrusion_width))); } overhangs.emplace_back(to_polygons(overhang)); @@ -2746,7 +2746,7 @@ void TreeSupport::drop_nodes() m_object->print()->set_status(60 + int(10 * (1 - float(layer_nr) / contact_nodes.size())), _u8L("Generating support"));// (boost::format(_u8L("Support: propagate branches at layer %d")) % layer_nr).str()); - Polygons layer_contours = std::move(m_ts_data->get_contours_with_holes(obj_layer_nr)); + Polygons layer_contours = m_ts_data->get_contours_with_holes(obj_layer_nr); //std::unordered_map& mst_line_x_layer_contour_cache = m_mst_line_x_layer_contour_caches[layer_nr]; tbb::concurrent_unordered_map mst_line_x_layer_contour_cache; auto is_line_cut_by_contour = [&mst_line_x_layer_contour_cache,&layer_contours](Point a, Point b) @@ -3763,7 +3763,7 @@ const ExPolygons& TreeSupportData::calculate_avoidance(const RadiusLayerPair& ke } const ExPolygons &collision = get_collision(radius, layer_nr); avoidance_areas.insert(avoidance_areas.end(), collision.begin(), collision.end()); - avoidance_areas = std::move(union_ex(avoidance_areas)); + avoidance_areas = union_ex(avoidance_areas); auto ret = m_avoidance_cache.insert({key, std::move(avoidance_areas)}); //assert(ret.second); return ret.first->second; diff --git a/src/libslic3r/TexturePainting.cpp b/src/libslic3r/TexturePainting.cpp new file mode 100644 index 0000000000..9f83f282cd --- /dev/null +++ b/src/libslic3r/TexturePainting.cpp @@ -0,0 +1,726 @@ +#include "TexturePainting.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +#include "TextureToColor/TextureToColor.hpp" +#include "TextureToColor/ColorUtils.hpp" + +#include "Model.hpp" +#include "TriangleMesh.hpp" +#include "TriangleSelector.hpp" + +namespace Slic3r { + +static cv::Mat decode_texture_image(const TextureImage& img) { + if (img.data.empty()) + return {}; + + // Raw encoded image data (PNG/JPEG) from glTF loader: width == -1 + if (img.width <= 0 || img.height <= 0) { + std::vector buf(img.data.begin(), img.data.end()); + cv::Mat raw(1, static_cast(buf.size()), CV_8UC1, buf.data()); + cv::Mat decoded = cv::imdecode(raw, cv::IMREAD_COLOR); + return decoded; + } + + int cv_type = (img.channels == 4) ? CV_8UC4 : CV_8UC3; + std::vector pixel_buf(img.data.begin(), img.data.end()); + cv::Mat src(img.height, img.width, cv_type, pixel_buf.data()); + + cv::Mat bgr; + if (img.channels == 4) + cv::cvtColor(src, bgr, cv::COLOR_RGBA2BGR); + else if (img.channels == 3) + cv::cvtColor(src, bgr, cv::COLOR_RGB2BGR); + else + return {}; + + return bgr; +} + +static void build_tex2color_mesh( + const TexturedMesh& textured, + tex2color::TriMesh& mesh, + std::vector>& uv_coords) +{ + const size_t nv = textured.vertices.size(); + const size_t nf = textured.indices.size(); + + mesh.vertices.resize(nv); + for (size_t i = 0; i < nv; ++i) { + mesh.vertices[i] = Vec3f( + textured.vertices[i][0], + textured.vertices[i][1], + textured.vertices[i][2]); + } + + mesh.indices.resize(nf); + for (size_t i = 0; i < nf; ++i) { + mesh.indices[i] = Vec3i32( + textured.indices[i][0], + textured.indices[i][1], + textured.indices[i][2]); + } + + uv_coords.resize(nf); + for (size_t fi = 0; fi < nf; ++fi) { + uv_coords[fi].resize(3); + for (int vi = 0; vi < 3; ++vi) { + if (textured.has_face_uvs()) { + int uv_idx = textured.uv_indices[fi][vi]; + if (uv_idx >= 0 && static_cast(uv_idx) < textured.uv_coords.size()) { + uv_coords[fi][vi] = Vec2f( + textured.uv_coords[uv_idx][0], + textured.uv_coords[uv_idx][1]); + } else { + uv_coords[fi][vi] = Vec2f(0.f, 0.f); + } + } else { + int vtx_idx = textured.indices[fi][vi]; + if (vtx_idx >= 0 && static_cast(vtx_idx) < textured.uvs.size()) { + uv_coords[fi][vi] = Vec2f( + textured.uvs[vtx_idx][0], + textured.uvs[vtx_idx][1]); + } else { + uv_coords[fi][vi] = Vec2f(0.f, 0.f); + } + } + } + } +} + +static void extract_painted_mesh( + const tex2color::TriMesh& color_mesh, + const std::vector>& face_colors, + PaintedMesh& painted) +{ + const size_t nv = color_mesh.vertices.size(); + const size_t nf = color_mesh.indices.size(); + + painted.vertices.resize(nv); + for (size_t i = 0; i < nv; ++i) { + const auto& v = color_mesh.vertices[i]; + painted.vertices[i] = {v.x(), v.y(), v.z()}; + } + + painted.indices.resize(nf); + for (size_t i = 0; i < nf; ++i) { + const auto& f = color_mesh.indices[i]; + painted.indices[i] = {f[0], f[1], f[2]}; + } + + painted.face_colors = face_colors; + + std::set> unique_colors(face_colors.begin(), face_colors.end()); + painted.cluster_colors.assign(unique_colors.begin(), unique_colors.end()); +} + +// Build a vertically-stacked atlas from multiple textures and remap per-face UVs. +// +// Sub-textures are laid out left-aligned (x=0) at successive y offsets, with +// atlas_w taken as the maximum width across all sub-textures. UVs must therefore +// be remapped on BOTH axes so that faces belonging to a sub-texture narrower +// than atlas_w sample inside that sub-texture's region (left side of the atlas) +// instead of the right-side zero-padding. Materials that carry only a baseColor +// (no map_Kd / glTF baseColorTexture) get their own 1x1 swatch at the bottom of +// the atlas so their faces sample the correct flat colour rather than being +// silently aliased onto textures[0]. +static bool build_multi_texture_atlas( + const TexturedMesh& textured, + cv::Mat& out_atlas, + std::vector>& out_uv_coords) +{ + std::vector decoded; + decoded.reserve(textured.textures.size()); + for (const auto& ti : textured.textures) + decoded.push_back(decode_texture_image(ti)); + + const bool has_mapping = !textured.material_texture_map.empty(); + const size_t nf = textured.indices.size(); + + auto resolve_tex_idx = [&](int mat_idx) -> int { + if (!has_mapping || mat_idx < 0 + || static_cast(mat_idx) >= textured.material_texture_map.size()) + return -1; + const int ti = textured.material_texture_map[mat_idx]; + if (ti < 0 || static_cast(ti) >= decoded.size() || decoded[ti].empty()) + return -1; + return ti; + }; + + // Determine atlas width (max width across all textures) and per-texture row offsets. + int atlas_w = 0; + int atlas_h = 0; + std::vector y_offsets(decoded.size(), 0); + int first_usable_tex = -1; + for (size_t i = 0; i < decoded.size(); ++i) { + if (decoded[i].empty()) continue; + if (first_usable_tex < 0) first_usable_tex = static_cast(i); + y_offsets[i] = atlas_h; + atlas_w = std::max(atlas_w, decoded[i].cols); + atlas_h += decoded[i].rows; + } + if (atlas_w == 0 || atlas_h == 0) + return false; + + // Collect materials that have a baseColor but no usable texture so we can + // route their faces to a dedicated 1x1 solid swatch instead of aliasing + // them onto textures[0]. + std::map mat_solid_y; // mat_idx -> y row in atlas + std::map> mat_solid_color; // mat_idx -> baseColor (RGBA) + for (size_t fi = 0; fi < nf; ++fi) { + const int mat_idx = (fi < textured.material_ids.size()) ? textured.material_ids[fi] : -1; + if (mat_idx < 0) continue; + if (resolve_tex_idx(mat_idx) >= 0) continue; + if (static_cast(mat_idx) >= textured.material_colors.size()) continue; + if (mat_solid_y.find(mat_idx) != mat_solid_y.end()) continue; + mat_solid_y[mat_idx] = atlas_h++; + mat_solid_color[mat_idx] = textured.material_colors[mat_idx]; + } + + out_atlas = cv::Mat::zeros(atlas_h, atlas_w, CV_8UC3); + for (size_t i = 0; i < decoded.size(); ++i) { + if (decoded[i].empty()) continue; + cv::Mat roi = out_atlas(cv::Rect(0, y_offsets[i], decoded[i].cols, decoded[i].rows)); + decoded[i].copyTo(roi); + } + for (const auto& kv : mat_solid_color) { + const auto& c = kv.second; + // OpenCV stores BGR; baseColor is RGBA in [0,1]. + out_atlas.at(mat_solid_y[kv.first], 0) = cv::Vec3b( + static_cast(std::clamp(c[2] * 255.f, 0.f, 255.f)), + static_cast(std::clamp(c[1] * 255.f, 0.f, 255.f)), + static_cast(std::clamp(c[0] * 255.f, 0.f, 255.f))); + } + + out_uv_coords.resize(nf); + for (size_t fi = 0; fi < nf; ++fi) { + const int mat_idx = (fi < textured.material_ids.size()) ? textured.material_ids[fi] : -1; + const int tex_idx = resolve_tex_idx(mat_idx); + + // Pick the atlas region this face samples from. + int y_off = 0, x_off = 0, th = atlas_h, tw = atlas_w; + bool use_solid = false; + if (tex_idx >= 0) { + y_off = y_offsets[tex_idx]; + th = decoded[tex_idx].rows; + tw = decoded[tex_idx].cols; + } else if (mat_idx >= 0 && mat_solid_y.count(mat_idx) > 0) { + y_off = mat_solid_y[mat_idx]; + th = 1; + tw = 1; + use_solid = true; + } else if (first_usable_tex >= 0) { + // Last-resort fallback: faces without a material or without any + // baseColor still need somewhere to sample; the first usable + // texture preserves legacy behaviour and, with the per-axis + // remapping below, no longer aliases onto the zero-padded right + // margin even when sub-textures have unequal widths. + y_off = y_offsets[first_usable_tex]; + th = decoded[first_usable_tex].rows; + tw = decoded[first_usable_tex].cols; + } + + out_uv_coords[fi].resize(3); + for (int vi = 0; vi < 3; ++vi) { + float u = 0.f, v = 0.f; + if (textured.has_face_uvs()) { + int uv_idx = textured.uv_indices[fi][vi]; + if (uv_idx >= 0 && static_cast(uv_idx) < textured.uv_coords.size()) { + u = textured.uv_coords[uv_idx][0]; + v = textured.uv_coords[uv_idx][1]; + } + } else { + int vtx_idx = textured.indices[fi][vi]; + if (vtx_idx >= 0 && static_cast(vtx_idx) < textured.uvs.size()) { + u = textured.uvs[vtx_idx][0]; + v = textured.uvs[vtx_idx][1]; + } + } + if (use_solid) { + // Aim at the centre of the 1x1 swatch so bilinear sampling + // (in tex2color) cannot drift into neighbouring rows. + const float u_atlas = (x_off + 0.5f) / static_cast(atlas_w); + const float v_atlas = (y_off + 0.5f) / static_cast(atlas_h); + out_uv_coords[fi][vi] = Vec2f(u_atlas, v_atlas); + } else { + // Wrap to [0,1) on both axes (OBJ tile UVs may step outside + // the unit square), then scale by the sub-texture extents so + // samples land inside its actual region. Without scaling u, + // any sub-texture narrower than atlas_w would have all its + // faces sampled from the right-side zero-padding. + u = u - std::floor(u); + v = v - std::floor(v); + const float u_atlas = (x_off + u * tw) / static_cast(atlas_w); + const float v_atlas = (y_off + v * th) / static_cast(atlas_h); + out_uv_coords[fi][vi] = Vec2f(u_atlas, v_atlas); + } + } + } + return true; +} + +bool texture_to_painting( + const TexturedMesh& textured, + PaintedMesh& painted, + const TexturePaintingSettings& settings, + PaintProgressCallback progress, + PaintCancelCallback cancel) +{ + if (textured.vertices.empty() || textured.indices.empty() || textured.textures.empty()) + return false; + + cv::Mat texture; + tex2color::TriMesh input_mesh; + std::vector> uv_coords; + + const bool multi_tex = textured.textures.size() > 1 && !textured.material_texture_map.empty(); + + if (multi_tex) { + if (!build_multi_texture_atlas(textured, texture, uv_coords)) + return false; + // Build mesh geometry (atlas UVs already computed above) + const size_t nv = textured.vertices.size(); + const size_t nf = textured.indices.size(); + input_mesh.vertices.resize(nv); + for (size_t i = 0; i < nv; ++i) + input_mesh.vertices[i] = Vec3f( + textured.vertices[i][0], textured.vertices[i][1], textured.vertices[i][2]); + input_mesh.indices.resize(nf); + for (size_t i = 0; i < nf; ++i) + input_mesh.indices[i] = Vec3i32( + textured.indices[i][0], textured.indices[i][1], textured.indices[i][2]); + } else { + texture = decode_texture_image(textured.textures[0]); + if (texture.empty()) + return false; + build_tex2color_mesh(textured, input_mesh, uv_coords); + } + + tex2color::TextureToColorSettings algo_settings; + algo_settings.target_colors_num = settings.target_colors_num; + algo_settings.smooth_weight = settings.smooth_weight; + algo_settings.oversampling_iters = settings.oversampling_iters; + switch (settings.mesh_repair_decision) { + case TexturePaintingSettings::MeshRepairDecision::Ask: + algo_settings.mesh_repair_decision = tex2color::MeshRepairDecision::Ask; + break; + case TexturePaintingSettings::MeshRepairDecision::RepairAndImport: + algo_settings.mesh_repair_decision = tex2color::MeshRepairDecision::RepairAndImport; + break; + case TexturePaintingSettings::MeshRepairDecision::ImportWithoutRepair: + default: + algo_settings.mesh_repair_decision = tex2color::MeshRepairDecision::ImportWithoutRepair; + break; + } + + tex2color::AlgoProgressCallback algo_progress = nullptr; + if (progress) { + algo_progress = [&progress](tex2color::AlgoProgress p) { + progress(p.percent, p.message); + }; + } + + tex2color::AlgoCancelCallback algo_cancel = nullptr; + if (cancel) { + algo_cancel = [&cancel]() -> bool { return cancel(); }; + } + + tex2color::TriMesh color_mesh; + std::vector> face_colors; + algo_settings.mesh_repair_decision_required = settings.mesh_repair_decision_required; + algo_settings.mesh_repair_callback = settings.mesh_repair_callback; + + bool ok = tex2color::TextureToColor( + input_mesh, uv_coords, texture, + color_mesh, face_colors, + algo_settings, algo_progress, algo_cancel); + + if (!ok) + return false; + + extract_painted_mesh(color_mesh, face_colors, painted); + return true; +} + +bool face_colors_to_painting( + const TexturedMesh& mesh, + PaintedMesh& painted, + const TexturePaintingSettings& settings, + PaintProgressCallback progress, + PaintCancelCallback cancel) +{ + if (mesh.vertices.empty() || mesh.indices.empty() || mesh.precomputed_face_colors.empty()) + return false; + + // Build tex2color::TriMesh from input geometry + tex2color::TriMesh input_mesh; + input_mesh.vertices.resize(mesh.vertices.size()); + for (size_t i = 0; i < mesh.vertices.size(); ++i) + input_mesh.vertices[i] = Vec3f(mesh.vertices[i][0], mesh.vertices[i][1], mesh.vertices[i][2]); + input_mesh.indices.resize(mesh.indices.size()); + for (size_t i = 0; i < mesh.indices.size(); ++i) + input_mesh.indices[i] = Vec3i32(mesh.indices[i][0], mesh.indices[i][1], mesh.indices[i][2]); + + // Forward settings to tex2color + tex2color::TextureToColorSettings algo_settings; + algo_settings.target_colors_num = settings.target_colors_num; + algo_settings.smooth_weight = settings.smooth_weight; + switch (settings.mesh_repair_decision) { + case TexturePaintingSettings::MeshRepairDecision::Ask: + algo_settings.mesh_repair_decision = tex2color::MeshRepairDecision::Ask; + break; + case TexturePaintingSettings::MeshRepairDecision::RepairAndImport: + algo_settings.mesh_repair_decision = tex2color::MeshRepairDecision::RepairAndImport; + break; + case TexturePaintingSettings::MeshRepairDecision::ImportWithoutRepair: + default: + algo_settings.mesh_repair_decision = tex2color::MeshRepairDecision::ImportWithoutRepair; + break; + } + algo_settings.mesh_repair_decision_required = settings.mesh_repair_decision_required; + algo_settings.mesh_repair_callback = settings.mesh_repair_callback; + + tex2color::AlgoProgressCallback algo_progress = nullptr; + if (progress) { + algo_progress = [&progress](tex2color::AlgoProgress p) { + progress(p.percent, p.message); + }; + } + tex2color::AlgoCancelCallback algo_cancel = nullptr; + if (cancel) { + algo_cancel = [&cancel]() -> bool { return cancel(); }; + } + + tex2color::TriMesh out_mesh; + std::vector> out_face_colors; + bool ok = tex2color::ClusterAndSmooth( + input_mesh, mesh.precomputed_face_colors, out_mesh, out_face_colors, + algo_settings, algo_progress, algo_cancel, + mesh.precomputed_vertex_colors); + + if (!ok) + return false; + + extract_painted_mesh(out_mesh, out_face_colors, painted); + return true; +} + +double compute_delta_e( + const std::array& rgb1, + const std::array& rgba2) +{ + return tex2color::color_utils::calc_rgb_color_difference_by_ciede2000( + rgb1, + { + static_cast(rgba2[0] * 255.0f), + static_cast(rgba2[1] * 255.0f), + static_cast(rgba2[2] * 255.0f) + }); +} + +std::vector match_clusters_to_filaments( + const std::vector>& cluster_colors, + const std::vector>& filament_colors, + const std::vector& /*filament_names*/) +{ + std::vector matches(cluster_colors.size()); + + for (size_t ci = 0; ci < cluster_colors.size(); ++ci) { + matches[ci].cluster_index = static_cast(ci); + matches[ci].cluster_color = cluster_colors[ci]; + matches[ci].delta_e = 1e9; + + for (size_t fi = 0; fi < filament_colors.size(); ++fi) { + double de = compute_delta_e(cluster_colors[ci], filament_colors[fi]); + if (de < matches[ci].delta_e) { + matches[ci].delta_e = de; + matches[ci].filament_index = static_cast(fi); + matches[ci].filament_color = filament_colors[fi]; + } + } + } + return matches; +} + +bool apply_painted_mesh_to_volume( + const PaintedMesh& painted, + const std::vector& matches, + ModelVolume& volume) +{ + if (painted.face_colors.empty() || matches.empty()) + return false; + + const auto& cluster_colors = painted.cluster_colors; + std::map, int> color_to_filament; + for (const auto& m : matches) { + if (m.cluster_index >= 0 && m.cluster_index < (int)cluster_colors.size() && m.filament_index >= 0) + color_to_filament[cluster_colors[m.cluster_index]] = m.filament_index; + } + + indexed_triangle_set its; + its.vertices.resize(painted.vertices.size()); + for (size_t i = 0; i < painted.vertices.size(); ++i) { + its.vertices[i] = Vec3f( + painted.vertices[i][0], + painted.vertices[i][1], + painted.vertices[i][2]); + } + its.indices.resize(painted.indices.size()); + for (size_t i = 0; i < painted.indices.size(); ++i) { + its.indices[i] = Vec3i32( + painted.indices[i][0], + painted.indices[i][1], + painted.indices[i][2]); + } + + TriangleMesh new_mesh(std::move(its)); + + // The volume already went through ModelObject::add_volume -> + // center_geometry_after_creation, which translated its mesh by + // -source.mesh_offset (and folded that shift into the volume + // transformation). The painted mesh, however, is derived from the + // raw textured mesh and is therefore expressed in the original + // un-centered coordinate frame. Reuse the exact recorded shift to + // align it -- do NOT compute it from the bounding-box centers of + // the two meshes: tex2color::TextureToColor performs subdivision + // and CGAL polygon-soup repair, so the painted vertex count and + // bbox no longer match the original textured mesh and a bbox- + // center alignment would silently displace the geometry. + // + // If the model has been scaled by Model::convert_from_meters / + // convert_from_imperial_units after load, the painted mesh fed + // here is already in millimetres (Model::convert_* also scales + // texture_mesh in place) while source.mesh_offset was recorded + // before the conversion and therefore still lives in the original + // pre-scaled frame. Bring it into the same frame as the painted + // vertices so the alignment shift below stays correct on the + // textured-import path. This compensation is scoped to this + // function so that other (non-textured) import paths are not + // affected. + Vec3d mesh_offset = volume.source.mesh_offset; + double unit_scale = 1.0; + if (volume.source.is_converted_from_meters) + unit_scale = 1000.0; + else if (volume.source.is_converted_from_inches) + unit_scale = 25.4; + if (unit_scale != 1.0) + mesh_offset *= unit_scale; + + if (!mesh_offset.isApprox(Vec3d::Zero())) + new_mesh.translate(-mesh_offset.cast()); + new_mesh.set_init_shift(mesh_offset); + + // Log bbox drift for diagnostics. Subdivision + CGAL polygon-soup + // repair routinely changes vertex count and bbox, so moderate drift + // is expected and must not block the apply. + if (!new_mesh.empty() && !volume.mesh().empty()) { + const Vec3d new_center = new_mesh.bounding_box().center(); + const Vec3d cur_center = volume.mesh().bounding_box().center(); + const double diag = volume.mesh().bounding_box().size().norm(); + const double drift = (new_center - cur_center).norm(); + if (drift > 0.05 * std::max(1.0, diag)) + BOOST_LOG_TRIVIAL(warning) + << "apply_painted_mesh_to_volume: painted bbox center drifted by " + << drift << " (bbox diag=" << diag + << ", unit_scale=" << unit_scale + << ", from_meters=" << volume.source.is_converted_from_meters + << ", from_inches=" << volume.source.is_converted_from_inches << ")"; + else if (drift > 1e-3 * std::max(1.0, diag)) + BOOST_LOG_TRIVIAL(info) + << "apply_painted_mesh_to_volume: minor bbox drift " + << drift << " (bbox diag=" << diag + << ", unit_scale=" << unit_scale << ")"; + } + + volume.set_mesh(std::move(new_mesh)); + volume.calculate_convex_hull(); + + // Re-center the replaced mesh so its bbox center sits at the origin, + // matching what center_geometry_after_creation did for the original mesh. + // CGAL repair / subdivision may shift the bbox center (drift); without + // re-centering, the volume offset (which was computed for the original + // centered mesh) no longer matches, causing the model to float or clip. + // Pass false to keep source.mesh_offset unchanged. + volume.center_geometry_after_creation(false); + volume.invalidate_convex_hull_2d(); + + // Mesh geometry has been replaced; any per-face annotation indexed + // against the previous triangle set is now stale. mmu_segmentation_facets + // is rewritten below from the new selector; reset the others so future + // import paths that carry support / seam / fuzzy_skin painting cannot + // leak indices from the old mesh into the new one. + volume.supported_facets.reset(); + volume.fuzzy_skin_facets.reset(); + volume.seam_facets.reset(); + + if (ModelObject* obj = volume.get_object()) + obj->invalidate_bounding_box(); + + TriangleSelector selector(volume.mesh()); + for (size_t fi = 0; fi < painted.face_colors.size() && fi < (size_t)volume.mesh().its.indices.size(); ++fi) { + auto it = color_to_filament.find(painted.face_colors[fi]); + if (it != color_to_filament.end()) { + int extruder_idx = it->second; + auto state = static_cast( + static_cast(EnforcerBlockerType::Extruder1) + extruder_idx); + if (state <= EnforcerBlockerType::ExtruderMax) + selector.set_facet(static_cast(fi), state); + } + } + + volume.mmu_segmentation_facets.set(selector); + return true; +} + +bool decode_texture_to_pixels( + const TextureImage& img, + std::vector& out_pixels, + int& out_w, int& out_h) +{ + cv::Mat decoded = decode_texture_image(img); + if (decoded.empty()) + return false; + + // decoded is BGR, CV_8UC3 + out_w = decoded.cols; + out_h = decoded.rows; + size_t nbytes = (size_t)out_w * out_h * 3; + out_pixels.resize(nbytes); + + if (decoded.isContinuous()) { + std::memcpy(out_pixels.data(), decoded.data, nbytes); + } else { + for (int r = 0; r < out_h; ++r) + std::memcpy(out_pixels.data() + r * out_w * 3, decoded.ptr(r), out_w * 3); + } + return true; +} + +// Sample face color from texture using 3 explicit UV values (centroid + bilinear). +static std::array sample_face_from_uvs( + const cv::Mat& tex, + const std::array& uv0, + const std::array& uv1, + const std::array& uv2) +{ + float cu = (uv0[0] + uv1[0] + uv2[0]) / 3.f; + float cv_val = (uv0[1] + uv1[1] + uv2[1]) / 3.f; + + cu = cu - std::floor(cu); + cv_val = cv_val - std::floor(cv_val); + + float fx = cu * (tex.cols - 1); + float fy = cv_val * (tex.rows - 1); + + int x0 = std::clamp(static_cast(fx), 0, tex.cols - 1); + int y0 = std::clamp(static_cast(fy), 0, tex.rows - 1); + int x1 = std::min(x0 + 1, tex.cols - 1); + int y1 = std::min(y0 + 1, tex.rows - 1); + + float wx = fx - x0; + float wy = fy - y0; + + const int ch = tex.channels(); + auto sample = [&](int row, int col) -> std::array { + const uchar* ptr = tex.data + row * tex.step[0] + col * ch; + return {static_cast(ptr[2]), static_cast(ptr[1]), static_cast(ptr[0])}; + }; + + auto c00 = sample(y0, x0); + auto c10 = sample(y0, x1); + auto c01 = sample(y1, x0); + auto c11 = sample(y1, x1); + + std::array color; + for (int i = 0; i < 3; ++i) { + float top = c00[i] * (1.f - wx) + c10[i] * wx; + float bot = c01[i] * (1.f - wx) + c11[i] * wx; + color[i] = static_cast(std::clamp(top * (1.f - wy) + bot * wy, 0.f, 255.f)); + } + return color; +} + +// Legacy overload: look up UVs from per-vertex array by vertex indices. +static std::array sample_face_from_texture( + const cv::Mat& tex, + const std::vector>& uvs, + const std::array& face) +{ + std::array uv0 = {0.f, 0.f}, uv1 = {0.f, 0.f}, uv2 = {0.f, 0.f}; + if (face[0] >= 0 && static_cast(face[0]) < uvs.size()) uv0 = uvs[face[0]]; + if (face[1] >= 0 && static_cast(face[1]) < uvs.size()) uv1 = uvs[face[1]]; + if (face[2] >= 0 && static_cast(face[2]) < uvs.size()) uv2 = uvs[face[2]]; + return sample_face_from_uvs(tex, uv0, uv1, uv2); +} + +bool sample_original_face_colors( + const TexturedMesh& textured, + std::vector>& out_face_colors) +{ + if (textured.indices.empty()) + return false; + + // Decode all textures up front + std::vector decoded_textures; + decoded_textures.reserve(textured.textures.size()); + for (const auto& ti : textured.textures) { + decoded_textures.push_back(decode_texture_image(ti)); + } + + const bool has_mapping = !textured.material_texture_map.empty(); + const size_t nf = textured.indices.size(); + out_face_colors.resize(nf); + + for (size_t fi = 0; fi < nf; ++fi) { + int mat_idx = (fi < textured.material_ids.size()) ? textured.material_ids[fi] : -1; + + int tex_idx = -1; + if (has_mapping && mat_idx >= 0 && static_cast(mat_idx) < textured.material_texture_map.size()) + tex_idx = textured.material_texture_map[mat_idx]; + else if (!decoded_textures.empty()) + tex_idx = 0; // fallback: single-texture model + + if (tex_idx >= 0 && static_cast(tex_idx) < decoded_textures.size() + && !decoded_textures[tex_idx].empty()) { + if (textured.has_face_uvs()) { + const auto& ui = textured.uv_indices[fi]; + auto get_uv = [&](int vi) -> std::array { + int idx = ui[vi]; + if (idx >= 0 && static_cast(idx) < textured.uv_coords.size()) + return textured.uv_coords[idx]; + return {0.f, 0.f}; + }; + out_face_colors[fi] = sample_face_from_uvs( + decoded_textures[tex_idx], get_uv(0), get_uv(1), get_uv(2)); + } else { + out_face_colors[fi] = sample_face_from_texture( + decoded_textures[tex_idx], textured.uvs, textured.indices[fi]); + } + } else if (has_mapping && mat_idx >= 0 + && static_cast(mat_idx) < textured.material_colors.size()) { + // No texture — use baseColorFactor as solid color + const auto& c = textured.material_colors[mat_idx]; + out_face_colors[fi] = { + static_cast(std::clamp(c[0] * 255.f, 0.f, 255.f)), + static_cast(std::clamp(c[1] * 255.f, 0.f, 255.f)), + static_cast(std::clamp(c[2] * 255.f, 0.f, 255.f)) + }; + } else { + out_face_colors[fi] = {192, 192, 192}; // default gray + } + } + return true; +} + +} // namespace Slic3r diff --git a/src/libslic3r/TexturePainting.hpp b/src/libslic3r/TexturePainting.hpp new file mode 100644 index 0000000000..fc4688620b --- /dev/null +++ b/src/libslic3r/TexturePainting.hpp @@ -0,0 +1,137 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +struct indexed_triangle_set; + +namespace Slic3r { + +class TriangleMesh; +class ModelVolume; + +struct TextureImage { + int width = 0; + int height = 0; + int channels = 4; + std::vector data; +}; + +struct TexturedMesh { + std::vector> vertices; + std::vector> indices; + std::vector> uvs; + std::vector textures; + std::vector material_ids; + // material index -> index in textures[] (-1 if no texture, use material_colors) + std::vector material_texture_map; + // per-material baseColorFactor (RGBA 0-1), indexed by material index + std::vector> material_colors; + + // Per-face independent UV support (for OBJ where the same vertex can have + // different texture coordinates on different faces). + std::vector> uv_coords; // UV coordinate pool + std::vector> uv_indices; // per-face UV indices into uv_coords + + bool has_face_uvs() const { return !uv_indices.empty() && !uv_coords.empty(); } + + // Pre-computed per-face colors (e.g. from OBJ vertex colors or MTL Kd). + // When non-empty, the pipeline skips texture decode/sample/oversample and + // consumes these instead of sampling a texture. + // Each entry is {R, G, B} in [0..255]. + std::vector> precomputed_face_colors; + + // Per-vertex colors from OBJ (RGBA, [0..1]), indexed by vertex index. + // On a low-poly mesh these are quantized into a small palette and the mesh is + // split along the resulting cluster boundaries, so color borders stay sharp + // instead of being averaged away into a single color per face. + std::vector> precomputed_vertex_colors; +}; + +struct PaintedMesh { + std::vector> vertices; + std::vector> indices; + std::vector> face_colors; // per-face RGB [0..255] + std::vector> cluster_colors; +}; + +using PaintProgressCallback = std::function; +using PaintCancelCallback = std::function; +using PaintMeshRepairCallback = std::function progress_callback, + std::function cancel_callback, + std::string* error_message)>; + +struct TexturePaintingSettings { + std::size_t target_colors_num = 4; + double smooth_weight = 0.5; + std::size_t oversampling_iters = 0; + enum class MeshRepairDecision { + Ask, + ImportWithoutRepair, + RepairAndImport + }; + MeshRepairDecision mesh_repair_decision = MeshRepairDecision::ImportWithoutRepair; + bool* mesh_repair_decision_required = nullptr; + PaintMeshRepairCallback mesh_repair_callback; +}; + +struct FilamentMatch { + int cluster_index = -1; + int filament_index = -1; + double delta_e = 0.0; + std::array cluster_color = {0,0,0}; + std::array filament_color = {0,0,0,1}; +}; + +bool texture_to_painting( + const TexturedMesh& textured, + PaintedMesh& painted, + const TexturePaintingSettings& settings = {}, + PaintProgressCallback progress = nullptr, + PaintCancelCallback cancel = nullptr); +// Turn pre-computed per-face colors into a painted mesh, skipping texture decode +// and UV sampling. A low-poly mesh that also carries precomputed_vertex_colors is +// split along quantized color boundaries, which replaces its geometry. +bool face_colors_to_painting( + const TexturedMesh& mesh, + PaintedMesh& painted, + const TexturePaintingSettings& settings = {}, + PaintProgressCallback progress = nullptr, + PaintCancelCallback cancel = nullptr); + + +std::vector match_clusters_to_filaments( + const std::vector>& cluster_colors, + const std::vector>& filament_colors, + const std::vector& filament_names); + +double compute_delta_e( + const std::array& rgb1, + const std::array& rgba2); + +bool apply_painted_mesh_to_volume( + const PaintedMesh& painted, + const std::vector& matches, + ModelVolume& volume); + +// Decode a TextureImage (which may contain raw PNG/JPEG bytes) into BGR pixel data. +// On success, populates out_pixels (BGR, 3 bytes/pixel) and sets out_w/out_h. +bool decode_texture_to_pixels( + const TextureImage& img, + std::vector& out_pixels, + int& out_w, int& out_h); + +// Sample per-face colors from the correct texture per material_ids. +// Uses material_texture_map / material_colors for multi-material GLBs. +// Falls back to textures[0] when the mapping is absent. +bool sample_original_face_colors( + const TexturedMesh& textured, + std::vector>& out_face_colors); + +} // namespace Slic3r diff --git a/src/libslic3r/TextureToColor/Callbacks.hpp b/src/libslic3r/TextureToColor/Callbacks.hpp new file mode 100644 index 0000000000..70084f585d --- /dev/null +++ b/src/libslic3r/TextureToColor/Callbacks.hpp @@ -0,0 +1,15 @@ +#pragma once +#include + +namespace Slic3r { namespace tex2color { + +struct AlgoProgress { + int percent = 0; + const char* message = ""; +}; + +using AlgoProgressCallback = std::function; +using AlgoCancelCallback = std::function; + +} // namespace tex2color +} // namespace Slic3r diff --git a/src/libslic3r/TextureToColor/CgalUtils.hpp b/src/libslic3r/TextureToColor/CgalUtils.hpp new file mode 100644 index 0000000000..109d454827 --- /dev/null +++ b/src/libslic3r/TextureToColor/CgalUtils.hpp @@ -0,0 +1,173 @@ +#pragma once +#include "TriMesh.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { namespace tex2color { +namespace cgalutils { + +using Kernel = CGAL::Exact_predicates_inexact_constructions_kernel; +using CGALMesh = CGAL::Surface_mesh; + +inline CGALMesh trimesh_to_cgal(const TriMesh& mesh) { + CGALMesh cm; + std::vector vmap(mesh.vertices.size()); + for (size_t i = 0; i < mesh.vertices.size(); ++i) + vmap[i] = cm.add_vertex(Kernel::Point_3(mesh.vertices[i].x(), mesh.vertices[i].y(), mesh.vertices[i].z())); + for (const auto& f : mesh.indices) { + cm.add_face(vmap[f[0]], vmap[f[1]], vmap[f[2]]); + } + return cm; +} + +inline TriMesh cgal_to_trimesh(const CGALMesh& cm) { + TriMesh mesh; + std::map vmap; + size_t idx = 0; + for (auto v : cm.vertices()) { + if (!cm.is_valid(v) || cm.is_removed(v)) continue; + auto p = cm.point(v); + mesh.vertices.push_back(Vec3f((float)p.x(), (float)p.y(), (float)p.z())); + vmap[v] = idx++; + } + for (auto f : cm.faces()) { + if (!cm.is_valid(f) || cm.is_removed(f)) continue; + auto h = cm.halfedge(f); + auto v0 = cm.target(h); + auto v1 = cm.target(cm.next(h)); + auto v2 = cm.target(cm.next(cm.next(h))); + mesh.indices.push_back(Vec3i32((int)vmap[v0], (int)vmap[v1], (int)vmap[v2])); + } + return mesh; +} + +inline bool is_mesh_halfedge_compatible(const TriMesh& mesh) { + std::vector> vtx_to_adj_faces(mesh.vertices.size()); + std::size_t edge_id = 0; + std::vector> edge_to_faces; + std::vector> vtx_to_prev_vtxs(mesh.vertices.size()); + std::vector> vtx_to_next_vtxs(mesh.vertices.size()); + std::vector> vtx_vtx_to_edge(mesh.vertices.size()); + + for (std::size_t fid = 0; fid < mesh.indices.size(); ++fid) { + const TriFace& face = mesh.indices[fid]; + if (face[0] == face[1] || face[1] == face[2] || face[2] == face[0]) { + return false; + } + for (std::size_t i = 0; i < 3; ++i) { + if (static_cast(face[i]) >= mesh.vertices.size()) { + return false; + } + vtx_to_adj_faces[face[i]].insert(fid); + + std::size_t prev_vtx = face[(i + 2) % 3]; + std::size_t next_vtx = face[(i + 1) % 3]; + + if (vtx_to_prev_vtxs[face[i]].count(prev_vtx)) { + return false; + } + vtx_to_prev_vtxs[face[i]].insert(prev_vtx); + + if (vtx_to_next_vtxs[face[i]].count(next_vtx)) { + return false; + } + vtx_to_next_vtxs[face[i]].insert(next_vtx); + } + + for (std::size_t i = 0; i < 3; ++i) { + std::size_t va = face[i]; + std::size_t vb = face[(i + 1) % 3]; + if (!vtx_vtx_to_edge[va].count(vb)) { + vtx_vtx_to_edge[va][vb] = edge_id; + vtx_vtx_to_edge[vb][va] = edge_id; + ++edge_id; + edge_to_faces.emplace_back(std::unordered_set()); + } + edge_to_faces[vtx_vtx_to_edge[va][vb]].insert(fid); + } + } + + for (std::size_t vid = 0; vid < mesh.vertices.size(); ++vid) { + if (vtx_to_adj_faces[vid].empty()) { + continue; + } + std::unordered_set visited_faces; + std::queue face_queue; + face_queue.push(*(vtx_to_adj_faces[vid].begin())); + visited_faces.insert(*(vtx_to_adj_faces[vid].begin())); + while (!face_queue.empty()) { + std::size_t fid = face_queue.front(); + face_queue.pop(); + const TriFace& face = mesh.indices[fid]; + for (std::size_t i = 0; i < 3; ++i) { + if (static_cast(face[i]) != vid) { + continue; + } + std::size_t v_next = face[(i + 1) % 3]; + std::size_t v_prev = face[(i + 2) % 3]; + for (std::size_t nbr : {v_next, v_prev}) { + std::size_t eid = vtx_vtx_to_edge[vid][nbr]; + for (std::size_t adj_fid : edge_to_faces[eid]) { + if (!visited_faces.count(adj_fid) && vtx_to_adj_faces[vid].count(adj_fid)) { + visited_faces.insert(adj_fid); + face_queue.push(adj_fid); + } + } + } + break; + } + } + + for (std::size_t fid : vtx_to_adj_faces[vid]) { + if (!visited_faces.count(fid)) { + return false; + } + } + } + + return true; +} + +inline bool convert_trimesh_to_cgal(const TriMesh& mesh, CGALMesh& cgal_mesh) { + cgal_mesh = trimesh_to_cgal(mesh); + return cgal_mesh.number_of_faces() > 0 || mesh.indices.empty(); +} + +inline bool convert_trimesh_to_cgal( + const TriMesh& mesh, const std::vector& vertex_uvs, + CGALMesh& cgal_mesh, std::vector& cgal_vertex_uvs) +{ + cgal_mesh.clear(); + std::vector vmap(mesh.vertices.size()); + cgal_vertex_uvs.clear(); + + for (size_t i = 0; i < mesh.vertices.size(); ++i) { + vmap[i] = cgal_mesh.add_vertex(Kernel::Point_3( + mesh.vertices[i].x(), mesh.vertices[i].y(), mesh.vertices[i].z())); + } + + cgal_vertex_uvs.resize(cgal_mesh.num_vertices()); + for (size_t i = 0; i < mesh.vertices.size(); ++i) { + if (i < vertex_uvs.size()) + cgal_vertex_uvs[vmap[i]] = vertex_uvs[i]; + else + cgal_vertex_uvs[vmap[i]] = Vec2f(0.f, 0.f); + } + + for (const auto& f : mesh.indices) + cgal_mesh.add_face(vmap[f[0]], vmap[f[1]], vmap[f[2]]); + + return true; +} + +} // namespace cgalutils +} // namespace tex2color +} // namespace Slic3r diff --git a/src/libslic3r/TextureToColor/ColorUtils.cpp b/src/libslic3r/TextureToColor/ColorUtils.cpp new file mode 100644 index 0000000000..7127a5d364 --- /dev/null +++ b/src/libslic3r/TextureToColor/ColorUtils.cpp @@ -0,0 +1,1643 @@ +#include "ColorUtils.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "CgalUtils.hpp" +#include "libslic3r/AABBTreeIndirect.hpp" +#include + +namespace Slic3r { namespace tex2color { +namespace color_utils { + +// #define DEBUG_FLAG + +#ifndef M_PI +#define M_PI 3.1415926535897932 +#endif + +#ifndef EPSILON +#define EPSILON 1e-6 +#endif + +#ifndef DOUBLE_LIMITS +#define DOUBLE_LIMITS +#define Double_MAX std::numeric_limits::max() +#define Double_MIN -std::numeric_limits::max() +#endif // !DOUBLE_LIMITS + +namespace PMP = CGAL::Polygon_mesh_processing; + +using cgalutils::CGALMesh; +using CGALKernel = cgalutils::Kernel; + +static constexpr double TOPO_SMOOTH_WEIGHT_THRESHOLD = 0.3; + +namespace detail { +template +double average_edge_length_impl(const Mesh& m) { + double total = 0.0; + size_t count = 0; + for (auto e : m.edges()) { + auto h = m.halfedge(e); + auto p0 = m.point(m.source(h)); + auto p1 = m.point(m.target(h)); + total += std::sqrt(CGAL::squared_distance(p0, p1)); + ++count; + } + return count > 0 ? total / count : 1.0; +} +} // namespace detail + +typedef CGAL::Aff_transformation_3 Affine_transformation_3; +typedef boost::graph_traits::halfedge_descriptor halfedge_descriptor; +typedef boost::graph_traits::edge_descriptor edge_descriptor; +typedef boost::graph_traits::vertex_descriptor vertex_descriptor; +typedef CGAL::AABB_face_graph_triangle_primitive Primitive; +typedef CGAL::AABB_traits Traits; +typedef CGAL::AABB_tree Tree; +typedef CGALMesh::template Property_map VNMap; + +static inline ColorDouble convert_rgb_uint_to_rgb_double(const Color& color) { + return ColorDouble{static_cast(color[0]), static_cast(color[1]), static_cast(color[2])}; +} + +static void normalize(CGALKernel::Vector_3& vec) { + double squared_length = vec.squared_length(); + if (squared_length > EPSILON) { + vec /= sqrt(squared_length); + } +} + +static double get_angle_between_vectors(const CGALKernel::Vector_3& v1, const CGALKernel::Vector_3& v2) { + CGALKernel::Vector_3 dir1{v1}, dir2{v2}; + normalize(dir1); + normalize(dir2); + double product_dot = dir1.x() * dir2.x() + dir1.y() * dir2.y() + dir1.z() * dir2.z(); + if (product_dot > 1.0 - EPSILON) { + return 0.0; + } else if (product_dot < -1.0 + EPSILON) { + return 180.0; + } + return std::acos(product_dot) / M_PI * 180.0; +} + +static void calc_face_normals(const CGALMesh& mesh, std::vector& face_normals) { + std::size_t fcnt = mesh.number_of_faces(); + face_normals.resize(fcnt); + for (auto face : mesh.faces()) { + std::vector points; + for (auto vtx : mesh.vertices_around_face(mesh.halfedge(face))) { + points.push_back(mesh.point(vtx)); + } + Eigen::Vector3d pos1(points[0].x(), points[0].y(), points[0].z()); + Eigen::Vector3d pos2(points[1].x(), points[1].y(), points[1].z()); + Eigen::Vector3d pos3(points[2].x(), points[2].y(), points[2].z()); + face_normals[face] = (pos3 - pos2).cross(pos1 - pos2); + face_normals[face].normalize(); + } + return; +} + +static bool check_and_repair_self_intersect(CGALMesh& mesh, bool* is_self_intersect_status = nullptr) { + // true means the mesh is no self intersect now + // false means the mesh is still self intersect + auto is_self_intersect = PMP::does_self_intersect(mesh); + if (is_self_intersect_status != nullptr) { + *is_self_intersect_status = is_self_intersect; + } + if (is_self_intersect) { + bool repair = PMP::experimental::remove_self_intersections(mesh); + if (repair) { + return true; + } else { + return false; + } + } + return true; +} + +static bool save_polylines(const std::string& file_name, const std::vector>& polylines) { + std::vector points; + std::vector> lines; + for (auto& polyline : polylines) { + std::size_t begin_pt_idx = points.size(); + for (auto& pt : polyline) { + points.push_back(pt); + } + for (std::size_t i = 1; i < polyline.size(); ++i) { + lines.emplace_back(begin_pt_idx + i, begin_pt_idx + i + 1); // obj is begin at 1 + } + } + std::ofstream output_file(file_name, std::ios::out); + for (auto& point : points) { + output_file << "v " << point[0] << " " << point[1] << " " << point[2] << "\n"; + } + for (auto& line : lines) { + output_file << "l " << line.first << " " << line.second << "\n"; + } + output_file.close(); + return true; +} + +static bool smooth_region_topo_boundary(CGALMesh& mesh, std::vector& face_labels, std::size_t max_iters = 20) { + // Topological smoothing: reassign face labels + std::size_t iter = 0; + while (iter < max_iters) { + ++iter; + bool flip_flag = false; + for (auto face : mesh.faces()) { + std::size_t same_label_count = 0; + std::unordered_map map_label_to_cnt; + std::size_t max_adj_cnt = 0; + std::size_t max_adj_label = face_labels[face]; + for (auto adj_face : mesh.faces_around_face(mesh.halfedge(face))) { + if (adj_face == CGALMesh::null_face() || !mesh.is_valid(adj_face) || mesh.is_removed(adj_face)) { + continue; + } + if (face_labels[adj_face] == face_labels[face]) { + ++same_label_count; + } else { + ++map_label_to_cnt[face_labels[adj_face]]; + if (map_label_to_cnt[face_labels[adj_face]] > max_adj_cnt) { + max_adj_cnt = map_label_to_cnt[face_labels[adj_face]]; + max_adj_label = face_labels[adj_face]; + } + } + } + if (max_adj_cnt > same_label_count) { + face_labels[face] = max_adj_label; + flip_flag = true; + } + } + + if (!flip_flag) { + break; + } + } + return true; +} + +static bool smooth_region_geom_boundary(CGALMesh& mesh, std::vector& face_labels, const SmoothParameters& smooth_parameters) { + // 1. extract boundary vertices and make polines + std::unordered_map map_vtx_to_degree; + std::unordered_set segment_boundary_edges; + std::unordered_set feature_edges; + std::unordered_set feature_vertices; + constexpr double feature_angle = 45; + for (const auto& edge : mesh.edges()) { + if (!mesh.is_valid(edge) || mesh.is_border(edge)) { + continue; + } + auto source = mesh.source(mesh.halfedge(edge)); + auto target = mesh.target(mesh.halfedge(edge)); + auto face_1 = mesh.face(mesh.halfedge(edge)); + auto face_2 = mesh.face(mesh.opposite(mesh.halfedge(edge))); + auto normal_1 = PMP::compute_face_normal(face_1, mesh); + auto normal_2 = PMP::compute_face_normal(face_2, mesh); + double angle = get_angle_between_vectors(normal_1, normal_2); + if (angle > feature_angle) { + feature_edges.insert(edge); + feature_vertices.insert(source); + feature_vertices.insert(target); + } + + if (face_labels[face_1] == face_labels[face_2]) { + continue; + } + + segment_boundary_edges.insert(edge); + ++map_vtx_to_degree[source]; + ++map_vtx_to_degree[target]; + } + + // 2. smooth each polyline + std::vector> polylines; + std::unordered_set visited_edges; + + std::function&)> trace_polyline = [&](std::vector& polyline) -> void { + if (polyline.empty()) { + return; + } + CGAL::SM_Vertex_index curr_vtx = polyline.back(); + if (map_vtx_to_degree[curr_vtx] != 2) { + return; + } + for (const auto& halfedge : mesh.halfedges_around_target(mesh.halfedge(curr_vtx))) { + CGAL::SM_Edge_index edge = mesh.edge(halfedge); + if (visited_edges.count(edge) || !segment_boundary_edges.count(edge)) { + continue; + } + visited_edges.insert(edge); + CGAL::SM_Vertex_index adj_vtx = mesh.source(halfedge); + if (!map_vtx_to_degree.count(adj_vtx)) { + continue; + } + polyline.push_back(adj_vtx); + return trace_polyline(polyline); + } + }; + + // 2.1. open polyline: from T nodes search other 2-degree nodes + for (auto& [src_vtx, degree] : map_vtx_to_degree) { + if (degree == 2) { + continue; + } + for (auto& src_halfedge : mesh.halfedges_around_target(mesh.halfedge(src_vtx))) { + CGAL::SM_Edge_index src_edge = mesh.edge(src_halfedge); + if (visited_edges.count(src_edge) || !segment_boundary_edges.count(src_edge)) { + continue; + } + visited_edges.insert(src_edge); + CGAL::SM_Vertex_index adj_vtx = mesh.source(src_halfedge); + if (!map_vtx_to_degree.count(adj_vtx)) { + continue; + } + std::vector polyline{src_vtx, adj_vtx}; + trace_polyline(polyline); + polylines.push_back(std::move(polyline)); + } + } + + // 2.2. closed polylines + for (auto edge : segment_boundary_edges) { + if (visited_edges.count(edge)) { + continue; + } + visited_edges.insert(edge); + CGAL::SM_Halfedge_index halfedge = mesh.halfedge(edge); + std::vector polyline{mesh.source(halfedge), mesh.target(halfedge)}; + trace_polyline(polyline); + if (polyline.front() != polyline.back()) { + std::cerr << "[Error]: loop polyline but not closed!!!\n"; + } + polylines.push_back(std::move(polyline)); + } + + // 3. smooth boundary + Tree boundary_tree(mesh.faces().begin(), mesh.faces().end(), mesh); + boundary_tree.accelerate_distance_queries(); + + constexpr std::size_t max_iters = 5; + const double smooth_weight = smooth_parameters.smooth_weight; // Controls smoothing intensity; larger values produce smoother results. Range: 0.1~1.0. + double origin_weight = std::max(1.0 - smooth_weight, 0.0); + for (std::size_t iter = 0; iter < max_iters; ++iter) { + for (const auto& polyline : polylines) { + std::size_t pt_cnt = polyline.size(); + std::vector points(pt_cnt); + for (std::size_t pt_idx = 1; pt_idx + 1 < pt_cnt; ++pt_idx) { + std::vector pts{mesh.point(polyline[pt_idx - 1]), mesh.point(polyline[pt_idx + 1])}; + CGALKernel::Point_3 smooth_pt = CGAL::ORIGIN + ((CGAL::centroid(pts.begin(), pts.end()) - CGAL::ORIGIN) * smooth_weight + + (mesh.point(polyline[pt_idx]) - CGAL::ORIGIN) * origin_weight); + points[pt_idx] = boundary_tree.closest_point(smooth_pt); + } + + if (polyline.front() == polyline.back()) { + if (feature_vertices.count(polyline.front())) { + continue; + } + std::vector pts{mesh.point(polyline[1]), mesh.point(polyline[pt_cnt - 2])}; + CGALKernel::Point_3 smooth_pt = CGAL::ORIGIN + ((CGAL::centroid(pts.begin(), pts.end()) - CGAL::ORIGIN) * smooth_weight + + (mesh.point(polyline.front()) - CGAL::ORIGIN) * origin_weight); + mesh.point(polyline.front()) = boundary_tree.closest_point(smooth_pt); + } + + for (std::size_t pt_idx = 1; pt_idx + 1 < pt_cnt; ++pt_idx) { + if (feature_vertices.count(polyline[pt_idx])) { + continue; + } + mesh.point(polyline[pt_idx]) = points[pt_idx]; + } + } + } + + for (auto& polyline : polylines) { + for (auto& vtx : polyline) { + mesh.point(vtx) = boundary_tree.closest_point(mesh.point(vtx)); + } + } + + return true; +} + +// HSV, XYZ, and LAB are only used internally for computing color differences, so they are declared in this cpp file only. +typedef std::array HSV; +typedef std::array XYZ; // Intermediate space for converting between LAB and RGB +typedef std::array LAB; // CIELAB was designed to match human visual perception; the standard method for perceptual color difference +// Common white points +const XYZ D65_WHITE = {0.95047, 1.0, 1.08883}; + +/** + * @brief Convert an RGB color to the HSV color space. + * + * @param rgb Input RGB color [R, G, B], range 0~255. + * @return HSV output [H, S, V], H in 0~360, S and V in 0~1. + */ +static HSV convert_rgb_to_hsv(const RGB& rgb) { + // Normalize to [0, 1] + double r = rgb[0] / 255.0; + double g = rgb[1] / 255.0; + double b = rgb[2] / 255.0; + + double max = std::max({r, g, b}); + double min = std::min({r, g, b}); + double delta = max - min; + + // Compute hue H + double h = 0; + if (delta == 0) { + h = 0; // Gray; hue is undefined + } else { + if (max == r) { + h = 60.0 * fmod((g - b) / delta, 6.0); + } else if (max == g) { + h = 60.0 * ((b - r) / delta + 2.0); + } else { // max == b + h = 60.0 * ((r - g) / delta + 4.0); + } + if (h < 0) { + h += 360.0; + } + } + + // Compute saturation S + double s = (max == 0) ? 0 : (delta / max); + + // Compute value V + double v = max; + + return {h, s, v}; +} + +/** + * @brief Convert an HSV color to the RGB color space. + * + * @param hsv Input HSV color [H, S, V], H in 0~360, S and V in 0~1. + * @return RGB output [R, G, B], range 0~255. + */ +static RGB convert_hsv_to_rgb(const HSV& hsv) { + double h = hsv[0]; + double s = hsv[1]; + double v = hsv[2]; + + double c = v * s; + double x = c * (1 - std::abs(fmod(h / 60.0, 2.0) - 1)); + double m = v - c; + + double r, g, b; + + if (h < 60) { + r = c; + g = x; + b = 0; + } else if (h < 120) { + r = x; + g = c; + b = 0; + } else if (h < 180) { + r = 0; + g = c; + b = x; + } else if (h < 240) { + r = 0; + g = x; + b = c; + } else if (h < 300) { + r = x; + g = 0; + b = c; + } else { + r = c; + g = 0; + b = x; + } + + return {static_cast((r + m) * 255 + 0.5), static_cast((g + m) * 255 + 0.5), static_cast((b + m) * 255 + 0.5)}; +} + +static XYZ convert_rgb_to_xyz(const RGB& color_rgb) { + ColorDouble rgb{static_cast(color_rgb[0]), static_cast(color_rgb[1]), static_cast(color_rgb[2])}; + auto gammaCorrect = [](double v) -> double { + v = v / 255.0; + if (v > 0.04045) { + return std::pow((v + 0.055) / 1.055, 2.4); + } else { + return v / 12.92; + } + }; + + double r = gammaCorrect(rgb[0]); + double g = gammaCorrect(rgb[1]); + double b = gammaCorrect(rgb[2]); + + // sRGB to XYZ matrix + return {r * 0.4124564 + g * 0.3575761 + b * 0.1804375, r * 0.2126729 + g * 0.7151522 + b * 0.0721750, r * 0.0193339 + g * 0.1191920 + b * 0.9503041}; +} + +// sRGB non-linear channel values [0,1] (double) -> linear light -> XYZ; equivalent to convert_rgb_to_xyz when v=n/255 +static XYZ convert_srgb01_to_xyz(double rs, double gs, double bs) { + auto gamma_correct = [](double v) -> double { + v = std::clamp(v, 0.0, 1.0); + return (v > 0.04045) ? std::pow((v + 0.055) / 1.055, 2.4) : (v / 12.92); + }; + const double r = gamma_correct(rs); + const double g = gamma_correct(gs); + const double b = gamma_correct(bs); + return {r * 0.4124564 + g * 0.3575761 + b * 0.1804375, r * 0.2126729 + g * 0.7151522 + b * 0.0721750, r * 0.0193339 + g * 0.1191920 + b * 0.9503041}; +} + +static LAB convert_xyz_to_lab(const XYZ& xyz) { + auto f = [](double t) -> double { + const double delta = 6.0 / 29.0; + if (t > delta * delta * delta) { + return std::cbrt(t); + } else { + return t / (3.0 * delta * delta) + 4.0 / 29.0; + } + }; + + // D65 white point + double xn = D65_WHITE[0], yn = D65_WHITE[1], zn = D65_WHITE[2]; + + double fx = f(xyz[0] / xn); + double fy = f(xyz[1] / yn); + double fz = f(xyz[2] / zn); + + return {116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)}; +} + +// ColorDouble here represents sRGB [0,1]; see calc_rgb_color_difference_by_ciede2000_srgb01 header comment +static LAB convert_srgb01_to_lab(const ColorDouble& srgb01) { + return convert_xyz_to_lab(convert_srgb01_to_xyz(srgb01[0], srgb01[1], srgb01[2])); +} + +static LAB convert_rgb_to_lab(const RGB& rgb) { + return convert_xyz_to_lab(convert_rgb_to_xyz(rgb)); +} + +static Color convert_lab_to_rgb(const LAB& lab) { + // Lab → XYZ + const double delta = 6.0 / 29.0; + const double delta2x3 = 3.0 * delta * delta; + + double fy = (lab[0] + 16.0) / 116.0; + double fx = lab[1] / 500.0 + fy; + double fz = fy - lab[2] / 200.0; + + double x = D65_WHITE[0] * (fx > delta ? fx * fx * fx : delta2x3 * (fx - 4.0 / 29.0)); + double y = D65_WHITE[1] * (fy > delta ? fy * fy * fy : delta2x3 * (fy - 4.0 / 29.0)); + double z = D65_WHITE[2] * (fz > delta ? fz * fz * fz : delta2x3 * (fz - 4.0 / 29.0)); + + // XYZ -> linear RGB (sRGB inverse matrix) + double r_lin = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z; + double g_lin = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z; + double b_lin = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z; + + // linear RGB -> sRGB (inverse gamma correction) + auto inverse_gamma = [](double v) -> double { + v = std::max(v, 0.0); + return v <= 0.0031308 ? 12.92 * v : 1.055 * std::pow(v, 1.0 / 2.4) - 0.055; + }; + + auto to_uint8 = [](double v) -> std::size_t { return static_cast(std::clamp(std::round(v * 255.0), 0.0, 255.0)); }; + + return {to_uint8(inverse_gamma(r_lin)), to_uint8(inverse_gamma(g_lin)), to_uint8(inverse_gamma(b_lin))}; +} + +/** + * @brief CIEDE2000 color-difference computation. + * + * Currently the most accurate color-difference formula, recommended by CIE as the industry standard. + * + * @param lab1 LAB values of the first color. + * @param lab2 LAB values of the second color. + * @return Color difference (typically < 1.0 is imperceptible to the human eye). + */ +static double ciede2000(const std::array& lab1, const std::array& lab2) { + // Parameters in the CIE L*C*h* formula + double L1 = lab1[0], a1 = lab1[1], b1 = lab1[2]; + double L2 = lab2[0], a2 = lab2[1], b2 = lab2[2]; + + // Compute C1 and C2 + double C1 = std::sqrt(a1 * a1 + b1 * b1); + double C2 = std::sqrt(a2 * a2 + b2 * b2); + double C_avg = (C1 + C2) / 2.0; + + // G factor (compensates for non-linearity in the mid-low chroma region) + double C7 = C_avg * C_avg * C_avg * C_avg * C_avg * C_avg * C_avg; + double G = 0.5 * (1.0 - std::sqrt(C7 / (C7 + 6103515625.0))); + + // a1' and a2' + double a1_prime = (1.0 + G) * a1; + double a2_prime = (1.0 + G) * a2; + + // C'1 and C'2 + double C1_prime = std::sqrt(a1_prime * a1_prime + b1 * b1); + double C2_prime = std::sqrt(a2_prime * a2_prime + b2 * b2); + double C_prime_avg = (C1_prime + C2_prime) / 2.0; + + // h'1 and h'2 + double h1_prime = std::atan2(b1, a1_prime); + double h2_prime = std::atan2(b2, a2_prime); + if (h1_prime < 0) { + h1_prime += 2 * M_PI; + } + if (h2_prime < 0) { + h2_prime += 2 * M_PI; + } + + // Compute dh' + double dh_prime; + if (std::abs(h1_prime - h2_prime) <= M_PI) { + dh_prime = h2_prime - h1_prime; + } else if (h2_prime <= h1_prime) { + dh_prime = h2_prime - h1_prime + 2 * M_PI; + } else { + dh_prime = h2_prime - h1_prime - 2 * M_PI; + } + + // Compute dH' + double dH_prime = 2.0 * std::sqrt(C1_prime * C2_prime) * std::sin(dh_prime / 2.0); + + // Compute dL' + double dL_prime = L2 - L1; + + // Compute dC' + double dC_prime = C2_prime - C1_prime; + + // Compute h_prime_avg + double h_prime_avg; + if (std::abs(h1_prime - h2_prime) > M_PI) { + h_prime_avg = (h1_prime + h2_prime + 2 * M_PI) / 2.0; + } else { + h_prime_avg = (h1_prime + h2_prime) / 2.0; + } + + // Compute T + double T = 1.0 - 0.17 * std::cos(h_prime_avg - M_PI / 6.0) + 0.24 * std::cos(2.0 * h_prime_avg) + 0.32 * std::cos(3.0 * h_prime_avg + M_PI / 30.0) - + 0.20 * std::cos(4.0 * h_prime_avg - 3.0 * M_PI / 6.0); + + // Compute rotation term R_T = -R_C * sin(2*delta_theta), where delta_theta = 30 * exp(-((h_bar'-275)/25)^2) + // h_prime_avg is in radians; convert to degrees for delta_theta; 2*delta_theta = 60 * exp(...), convert back to radians for sin + double h_prime_avg_deg = h_prime_avg * 180.0 / M_PI; + double C_prime_avg_7 = std::pow(C_prime_avg, 7); + double R = -2.0 * std::sqrt(C_prime_avg_7 / (C_prime_avg_7 + 6103515625.0)) * + std::sin((60.0 * M_PI / 180.0) * std::exp(-std::pow((h_prime_avg_deg - 275.0) / 25.0, 2))); + + // Compute SL, SC, SH + double L_prime_avg = (L1 + L2) / 2.0; + double SL = 1.0 + 0.015 * std::pow(L_prime_avg - 50.0, 2) / std::sqrt(20 + std::pow(L_prime_avg - 50.0, 2)); + double SC = 1.0 + 0.045 * C_prime_avg; + double SH = 1.0 + 0.015 * C_prime_avg * T; + + // Final color difference + double deltaE = std::sqrt(std::pow(dL_prime / SL, 2) + std::pow(dC_prime / SC, 2) + std::pow(dH_prime / SH, 2) + R * (dC_prime / SC) * (dH_prime / SH)); + + return deltaE; +} + +double calc_rgb_color_difference_by_ciede2000(const RGB& rgb1, const RGB& rgb2) { + auto lab1 = convert_rgb_to_lab(rgb1); + auto lab2 = convert_rgb_to_lab(rgb2); + return ciede2000(lab1, lab2); +} + +double calc_rgb_color_difference_by_ciede2000_srgb01(const ColorDouble& rgb1, const ColorDouble& rgb2) { + const LAB lab1 = convert_srgb01_to_lab(rgb1); + const LAB lab2 = convert_srgb01_to_lab(rgb2); + return ciede2000(lab1, lab2); +} + +// Working-space distance function type: inputs are two colors in the same space (RGB-double or Lab) +using WorkingDistFunc = double (*)(const ColorDouble&, const ColorDouble&); + +// Farthest Point Sampling (FPS) initialization algorithm. +// The first center is the point nearest to the global centroid; subsequent centers are the points farthest from the existing set. +static std::vector farthest_point_sampling_init(const std::vector& working_colors, std::size_t k, WorkingDistFunc dist_func) { + std::vector centers(k); + + // 1. Compute the global centroid and pick the nearest point as the first center + ColorDouble centroid = {0.0, 0.0, 0.0}; + for (const auto& c : working_colors) { + centroid[0] += c[0]; + centroid[1] += c[1]; + centroid[2] += c[2]; + } + const auto n = static_cast(working_colors.size()); + centroid[0] /= n; + centroid[1] /= n; + centroid[2] /= n; + + double best_dist = std::numeric_limits::max(); + std::size_t first_idx = 0; + for (std::size_t i = 0; i < working_colors.size(); ++i) { + double d = dist_func(working_colors[i], centroid); + if (d < best_dist) { + best_dist = d; + first_idx = i; + } + } + centers[0] = working_colors[first_idx]; + + // Minimum distance from each point to the already-selected center set + std::vector min_distances(working_colors.size(), std::numeric_limits::max()); + + // 2. Greedily select the remaining K-1 centers: pick the point with the largest min_distance each time + for (std::size_t i = 1; i < k; ++i) { + const ColorDouble& last_center = centers[i - 1]; + + // Update each point's minimum distance with the newly added center + double farthest_dist = -1.0; + std::size_t farthest_idx = 0; + for (std::size_t c_idx = 0; c_idx < working_colors.size(); ++c_idx) { + double d = dist_func(working_colors[c_idx], last_center); + if (d < min_distances[c_idx]) { + min_distances[c_idx] = d; + } + if (min_distances[c_idx] > farthest_dist) { + farthest_dist = min_distances[c_idx]; + farthest_idx = c_idx; + } + } + + centers[i] = working_colors[farthest_idx]; + } + + return centers; +} + +bool remesh_mesh(TriMesh& bbs_mesh, std::vector& face_labels, double target_edge_length_ratio) { + if (face_labels.size() != bbs_mesh.indices.size()) { + BOOST_LOG_TRIVIAL(warning) << "Input mesh face count does not match label count"; + return false; + } + + // Back up face labels for recovery after remeshing. + std::vector face_labels_of_original_mesh(face_labels); + + CGALMesh cgal_mesh; + cgalutils::convert_trimesh_to_cgal(bbs_mesh, cgal_mesh); + + // AABBTreeIndirect references vertices/faces externally, so snapshot the + // pre-remesh geometry by moving it out of bbs_mesh (which is overwritten + // below with the post-remesh mesh). std::move on std::vector is O(1). + TriVertices old_vertices = std::move(bbs_mesh.vertices); + TriFaces old_indices = std::move(bbs_mesh.indices); + auto original_mesh_tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(old_vertices, old_indices); + + std::unordered_set feature_edges; + std::unordered_set feature_vertices; + CGALMesh::Property_map constrained_edges = + cgal_mesh.add_property_map("constrained_edges", false).first; + CGALMesh::Property_map constrained_vertices = + cgal_mesh.add_property_map("constrained_vertices", false).first; + + // An edge is considered a geometric feature edge if its dihedral angle is less than 135 degrees (loose threshold) + constexpr double feature_angle = 135; + + auto is_feature_edge = [&](CGAL::SM_Edge_index edge) -> bool { + if (cgal_mesh.is_border(edge)) { + return true; + } + auto halfedge_1 = cgal_mesh.halfedge(edge); + auto halfedge_2 = cgal_mesh.opposite(halfedge_1); + auto face_1 = cgal_mesh.face(halfedge_1); + auto face_2 = cgal_mesh.face(halfedge_2); + if (face_labels[face_1] != face_labels[face_2]) { + // Boundary between different color regions; treated as a feature edge + return true; + } + // TODO: CGAL remeshing tends to crash when too many constrained edges are added; needs handling + //auto normal_1 = PMP::compute_face_normal(face_1, cgal_mesh); + //auto normal_2 = PMP::compute_face_normal(face_2, cgal_mesh); + //double angle = 180 - get_angle_between_vectors(normal_1, normal_2); + //BOOST_LOG_TRIVIAL(debug) << "end.\n"; + //return angle > feature_angle; + return false; + }; + + for (auto edge : cgal_mesh.edges()) { + if (is_feature_edge(edge)) { + feature_edges.insert(edge); + feature_vertices.insert(cgal_mesh.source(cgal_mesh.halfedge(edge))); + feature_vertices.insert(cgal_mesh.target(cgal_mesh.halfedge(edge))); + constrained_edges[edge] = true; + constrained_vertices[cgal_mesh.source(cgal_mesh.halfedge(edge))] = true; + constrained_vertices[cgal_mesh.target(cgal_mesh.halfedge(edge))] = true; + } + } + +#ifdef DEBUG_FLAG + BOOST_LOG_TRIVIAL(debug) << "remesh_mesh: feature_edges.size() = " << feature_edges.size() << ".\n"; + + std::vector> polylines; + for (auto edge : feature_edges) { + auto src_vtx = cgal_mesh.source(cgal_mesh.halfedge(edge)); + auto trg_vtx = cgal_mesh.target(cgal_mesh.halfedge(edge)); + polylines.push_back({cgal_mesh.point(src_vtx), cgal_mesh.point(trg_vtx)}); + } + save_polylines("ColorUtils_remesh_feature_lines.obj", polylines); +#endif // DEBUG_FLAG + + std::size_t iters = 5; +#ifdef DEBUG_FLAG + BOOST_LOG_TRIVIAL(debug) << "PMP::isotropic_remeshing start...\n"; +#endif // DEBUG_FLAG + // TODO: CGAL remeshing preserves geometric boundaries but does not maintain face labels well; colors need to be recomputed + PMP::isotropic_remeshing(cgal_mesh.faces(), target_edge_length_ratio * detail::average_edge_length_impl(cgal_mesh), cgal_mesh, + CGAL::parameters::number_of_iterations(iters) + .protect_constraints(true) + .edge_is_constrained_map(constrained_edges) + .vertex_is_constrained_map(constrained_vertices) + .collapse_constraints(true)); +#ifdef DEBUG_FLAG + BOOST_LOG_TRIVIAL(debug) << "PMP::isotropic_remeshing finshed...\n"; +#endif // DEBUG_FLAG + if (PMP::does_self_intersect(cgal_mesh)) { + PMP::experimental::remove_self_intersections(cgal_mesh); + } + + bbs_mesh.clear(); + + std::unordered_map map_cgal_vtx_to_bbs_vtx; + TriVertices bbs_vertices; + TriFaces bbs_faces; + face_labels.clear(); + face_labels.reserve(cgal_mesh.number_of_faces()); + + for (const auto& cgal_vtx : cgal_mesh.vertices()) { + if (!cgal_mesh.is_valid(cgal_vtx) || cgal_mesh.is_removed(cgal_vtx) || cgal_mesh.is_isolated(cgal_vtx)) { + continue; + } + if (!map_cgal_vtx_to_bbs_vtx.count(cgal_vtx)) { + map_cgal_vtx_to_bbs_vtx[cgal_vtx] = bbs_vertices.size(); + const auto& cgal_point = cgal_mesh.point(cgal_vtx); + bbs_vertices.emplace_back(TriVertex(cgal_point.x(), cgal_point.y(), cgal_point.z())); + } + } + + for (const auto& cgal_face : cgal_mesh.faces()) { + if (!cgal_mesh.is_valid(cgal_face) || cgal_mesh.is_removed(cgal_face)) { + continue; + } + TriFace bbs_face; + std::size_t face_vid = 0; + for (auto cgal_vtx : cgal_mesh.vertices_around_face(cgal_mesh.halfedge(cgal_face))) { + do { if (!(map_cgal_vtx_to_bbs_vtx.count(cgal_vtx))) { BOOST_LOG_TRIVIAL(warning) << "CGAL mesh contains a face with an invalid vertex"; return false; } } while(0); + bbs_face[face_vid] = map_cgal_vtx_to_bbs_vtx[cgal_vtx]; + ++face_vid; + } + bbs_faces.push_back(std::move(bbs_face)); + } + + bbs_mesh = TriMesh(bbs_faces, bbs_vertices); + + face_labels.resize(bbs_mesh.indices.size()); + tbb::parallel_for(tbb::blocked_range(0, bbs_mesh.indices.size()), [&](const tbb::blocked_range& range) { + for (std::size_t fid = range.begin(); fid < range.end(); ++fid) { + auto& face = bbs_mesh.indices[fid]; + Vec3f face_centroid = (bbs_mesh.vertices[face[0]] + bbs_mesh.vertices[face[1]] + bbs_mesh.vertices[face[2]]) / 3.0; + size_t hit_idx = 0; + Vec3f closest; + AABBTreeIndirect::squared_distance_to_indexed_triangle_set( + old_vertices, old_indices, original_mesh_tree, face_centroid, hit_idx, closest); + face_labels[fid] = face_labels_of_original_mesh[hit_idx]; + } + }); + + return true; +} + +bool is_closed(const TriMesh& bbs_mesh) { + CGALMesh cgal_mesh; + cgalutils::convert_trimesh_to_cgal(bbs_mesh, cgal_mesh); + +#ifdef DEBUG_FLAG + std::size_t border_edges_count = 0; + std::size_t edges_count = 0; + for (auto edge : cgal_mesh.edges()) { + if (cgal_mesh.is_border(edge)) { + ++border_edges_count; + } + ++edges_count; + } + + BOOST_LOG_TRIVIAL(debug) << "border edges count = " << border_edges_count << "\n"; + BOOST_LOG_TRIVIAL(debug) << "edges count = " << edges_count << "\n"; + + std::size_t border_faces_count = 0; + std::size_t faces_count = 0; + for (auto face : cgal_mesh.faces()) { + for (auto halfedge : cgal_mesh.halfedges_around_face(cgal_mesh.halfedge(face))) { + auto edge = cgal_mesh.edge(halfedge); + if (cgal_mesh.is_border(edge)) { + ++border_faces_count; + break; + } + } + ++faces_count; + } + + BOOST_LOG_TRIVIAL(debug) << "border faces count = " << border_faces_count << "\n"; + BOOST_LOG_TRIVIAL(debug) << "faces count = " << faces_count << "\n"; + + BOOST_LOG_TRIVIAL(debug) << "vertices count = " << cgal_mesh.number_of_vertices() << "\n"; + std::size_t num_of_components = 0; + std::unordered_set visited_faces; + for (auto src_face : cgal_mesh.faces()) { + if (visited_faces.count(src_face)) { + continue; + } + ++num_of_components; + std::queue que; + que.push(src_face); + visited_faces.insert(src_face); + while (!que.empty()) { + auto curr_face = que.front(); + que.pop(); + for (auto adj_face : cgal_mesh.faces_around_face(cgal_mesh.halfedge(curr_face))) { + if (!cgal_mesh.is_valid(adj_face) || cgal_mesh.is_removed(adj_face) || visited_faces.count(adj_face)) { + continue; + } + que.push(adj_face); + visited_faces.insert(adj_face); + } + } + } + BOOST_LOG_TRIVIAL(debug) << "components count = " << num_of_components << "\n"; +#endif // DEBUG_FLAG + + return CGAL::is_closed(cgal_mesh); +} + +static bool smooth_region_labels(TriMesh& tri_mesh, std::vector& face_labels, const SmoothParameters& smooth_parameters) { + CGALMesh mesh; + cgalutils::convert_trimesh_to_cgal(tri_mesh, mesh); + + if (mesh.number_of_faces() != face_labels.size()) { + BOOST_LOG_TRIVIAL(warning) << "Face count does not match label count"; + return false; + } + + if (smooth_parameters.smooth_weight >= TOPO_SMOOTH_WEIGHT_THRESHOLD) { + // Topological smoothing: reassign face labels. + smooth_region_topo_boundary(mesh, face_labels); + } + + if (smooth_parameters.smooth_weight > EPSILON) { + // Geometric smoothing: smooth polylines and project back onto the original mesh. + smooth_region_geom_boundary(mesh, face_labels, smooth_parameters); + } + + tri_mesh = cgalutils::cgal_to_trimesh(mesh); + + return true; +} + +bool smooth_region(TriMesh& tri_mesh, std::vector>& face_colors, const SmoothParameters& smooth_parameters) { + // Convert colors to labels + std::size_t label_next = 0; + std::vector face_labels; + face_labels.reserve(face_colors.size()); + std::map, std::size_t> map_color_to_label; + std::unordered_map> map_label_to_color; + for (auto& color : face_colors) { + if (!map_color_to_label.count(color)) { + map_color_to_label[color] = label_next; + map_label_to_color[label_next] = color; + ++label_next; + } + face_labels.push_back(map_color_to_label[color]); + } + + if (!smooth_region_labels(tri_mesh, face_labels, smooth_parameters)) + return false; + + // Convert labels back to colors + for (std::size_t fid = 0; fid < face_labels.size(); ++fid) { + face_colors[fid] = map_label_to_color[face_labels[fid]]; + } + + return true; +} + +bool smooth_region(TriMesh& tri_mesh, std::vector& face_labels, const SmoothParameters& smooth_parameters) { + return smooth_region_labels(tri_mesh, face_labels, smooth_parameters); +} + +// Compute the squared Euclidean distance between two colors (RGB vectors) +double calc_rgb_color_difference_by_squared_rgb_double(const ColorDouble& c1, const ColorDouble& c2) { + double dr = c1[0] - c2[0]; + double dg = c1[1] - c2[1]; + double db = c1[2] - c2[2]; + return dr * dr + dg * dg + db * db; +} + +// Compute the squared Euclidean distance between two colors (RGB vectors) +double calc_rgb_color_difference_by_squared_rgb(const RGB& c1, const RGB& c2) { + auto c1d = convert_rgb_uint_to_rgb_double(c1); + auto c2d = convert_rgb_uint_to_rgb_double(c2); + return calc_rgb_color_difference_by_squared_rgb_double(c1d, c2d); +} + +// K-Means core: run FPS initialization + assign/update iterations in working space, return cluster centers +static std::vector kmeans_core(const std::vector& working_colors, std::size_t k, std::size_t max_iter, WorkingDistFunc dist_func, + const std::function& cancel_cb = nullptr) { + std::vector centers = farthest_point_sampling_init(working_colors, k, dist_func); + std::vector assignments(working_colors.size()); + + for (std::size_t iter = 0; iter < max_iter; ++iter) { + if (cancel_cb && cancel_cb()) return centers; + std::atomic changed(false); + + tbb::parallel_for(tbb::blocked_range(0, working_colors.size()), [&](const tbb::blocked_range& range) { + for (std::size_t i = range.begin(); i < range.end(); ++i) { + double min_dist = std::numeric_limits::max(); + std::size_t best_cluster = 0; + + for (std::size_t j = 0; j < k; ++j) { + double d = dist_func(working_colors[i], centers[j]); + if (d < min_dist) { + min_dist = d; + best_cluster = j; + } + } + + if (assignments[i] != best_cluster) { + changed.store(true, std::memory_order_relaxed); + assignments[i] = best_cluster; + } + } + }); + + if (!changed.load()) { + break; + } + + std::vector new_centers(k, {0.0, 0.0, 0.0}); + std::vector counts(k, 0); + + for (std::size_t i = 0; i < working_colors.size(); ++i) { + std::size_t cluster_id = assignments[i]; + ++counts[cluster_id]; + new_centers[cluster_id][0] += working_colors[i][0]; + new_centers[cluster_id][1] += working_colors[i][1]; + new_centers[cluster_id][2] += working_colors[i][2]; + } + + for (std::size_t i = 0; i < k; ++i) { + if (counts[i] == 0) { + double max_min_dist = -1.0; + std::size_t best_idx = 0; + for (std::size_t p = 0; p < working_colors.size(); ++p) { + double nearest = std::numeric_limits::max(); + for (std::size_t c = 0; c < k; ++c) { + if (c == i || counts[c] == 0) { + continue; + } + double d = dist_func(working_colors[p], centers[c]); + if (d < nearest) { + nearest = d; + } + } + if (nearest > max_min_dist) { + max_min_dist = nearest; + best_idx = p; + } + } + centers[i] = working_colors[best_idx]; + } else { + centers[i][0] = new_centers[i][0] / counts[i]; + centers[i][1] = new_centers[i][1] / counts[i]; + centers[i][2] = new_centers[i][2] / counts[i]; + } + } + } + + return centers; +} + +// K-Means clustering algorithm +std::vector cluster_k_means(const std::vector& colors, const ClusterParameters& cluster_parameters) { + std::size_t k = cluster_parameters.cluster_k; + std::size_t max_iter = cluster_parameters.max_iter; + + if (k == 0 || colors.empty()) { + return {}; + } + + const bool use_lab = (cluster_parameters.color_difference_method != ColorDifferenceMethod::RGB); + WorkingDistFunc working_dist_func = use_lab ? ciede2000 : calc_rgb_color_difference_by_squared_rgb_double; + + // ========================================== + // 1. Preprocessing: deduplicate + pre-convert to working space + // ========================================== + std::vector unique_colors = colors; + std::sort(unique_colors.begin(), unique_colors.end()); + auto last = std::unique(unique_colors.begin(), unique_colors.end()); + unique_colors.erase(last, unique_colors.end()); + +#ifdef DEBUG_FLAG + BOOST_LOG_TRIVIAL(debug) << "Input colors: " << colors.size() << ", Unique colors: " << unique_colors.size(); +#endif // DEBUG_FLAG + + if (unique_colors.size() < k) { + BOOST_LOG_TRIVIAL(warning) << "Unique color count (" << unique_colors.size() << ") is less than target K (" << k << "). Adjusting K."; + k = unique_colors.size(); + if (k == 0) { + return {}; + } + return unique_colors; + } + + std::vector working_colors(unique_colors.size()); + for (std::size_t i = 0; i < unique_colors.size(); ++i) { + if (use_lab) { + working_colors[i] = convert_rgb_to_lab(unique_colors[i]); + } else { + working_colors[i] = {static_cast(unique_colors[i][0]), static_cast(unique_colors[i][1]), static_cast(unique_colors[i][2])}; + } + } + + // ========================================== + // 2. K-Means clustering + // ========================================== + auto centers = kmeans_core(working_colors, k, max_iter, working_dist_func, cluster_parameters.cancel_callback); + + // ========================================== + // 3. Output: convert from working space back to RGB + // ========================================== + std::vector result(k); + for (std::size_t i = 0; i < k; ++i) { + if (use_lab) { + result[i] = convert_lab_to_rgb(centers[i]); + } else { + result[i] = {static_cast(std::round(centers[i][0])), static_cast(std::round(centers[i][1])), + static_cast(std::round(centers[i][2]))}; + } + } + + return result; +} + +std::vector cluster_to_specified_colors(const std::vector& colors, const std::vector& specified_colors) { + std::vector cluster_colors = colors; + std::vector specified_double_colors; + specified_double_colors.reserve(specified_colors.size()); + for (auto& color : specified_colors) { + specified_double_colors.push_back(ColorDouble{static_cast(color[0]), static_cast(color[1]), static_cast(color[2])}); + } + + tbb::parallel_for(tbb::blocked_range(0, cluster_colors.size()), [&](const tbb::blocked_range& range) { + for (std::size_t i = range.begin(); i < range.end(); ++i) { + ColorDouble p_color{static_cast(cluster_colors[i][0]), static_cast(cluster_colors[i][1]), + static_cast(cluster_colors[i][2])}; + + double min_dist = std::numeric_limits::max(); + std::size_t best_cluster = 0; + + for (std::size_t j = 0; j < specified_double_colors.size(); ++j) { + double d = calc_rgb_color_difference_by_squared_rgb_double(p_color, specified_double_colors[j]); + if (d < min_dist) { + min_dist = d; + best_cluster = j; + } + } + + cluster_colors[i] = specified_colors[best_cluster]; + } + }); + + return cluster_colors; +} + +// Color PCA struct +struct ColorPCA { + std::size_t color_idx; // Index of the original color in ColorList + double pca_value; // Projection value onto the first principal component + + ColorPCA(std::size_t c_idx, double pca_val) + : color_idx(c_idx), + pca_value(pca_val) {} + + // Comparison operator (ascending order) + bool operator<(const ColorPCA& other) const { return pca_value < other.pca_value; } + + // Equality check + bool operator==(const ColorPCA& other) const { return color_idx == other.color_idx; } +}; + +[[maybe_unused]] static std::vector sort_colors_by_pca(const std::vector& colors) { + const std::size_t n = colors.size(); + + if (n == 0) { + return {}; + } + + if (n == 1) { + return {{0, 0.0}}; + } + + // Step 1: Data preprocessing - normalize to [0, 1] + Eigen::MatrixXd data(n, 3); + + for (std::size_t i = 0; i < n; ++i) { + data(i, 0) = static_cast(colors[i][0]) / 255.0; // R + data(i, 1) = static_cast(colors[i][1]) / 255.0; // G + data(i, 2) = static_cast(colors[i][2]) / 255.0; // B + } + + // Step 2: Compute mean and center the data + Eigen::RowVector3d mean = data.colwise().mean(); + Eigen::MatrixXd centered = data.rowwise() - mean; + + // Step 3: Compute covariance matrix (3x3) + Eigen::Matrix3d cov = (centered.adjoint() * centered) / static_cast(n - 1); + + // Step 4: Eigenvalue decomposition + Eigen::SelfAdjointEigenSolver solver(cov); + + if (solver.info() != Eigen::Success) { +#ifdef DEBUG_FLAG + std::cerr << "PCA: Eigenvalue decomposition failed" << std::endl; +#endif // DEBUG_FLAG + // Fallback: return an approximate result sorted by luminance. + // Luminance is a key perceptual feature; convert RGB to grayscale (L = 0.299R + 0.587G + 0.114B) and sort in ascending order. + std::vector result; + result.reserve(n); + for (std::size_t i = 0; i < n; ++i) { + double luminance = 0.299 * colors[i][0] + 0.587 * colors[i][1] + 0.114 * colors[i][2]; + result.push_back({i, luminance}); + } + std::sort(result.begin(), result.end()); + return result; + } + + // Get eigenvalues and eigenvectors (sorted by eigenvalue in descending order) + Eigen::Vector3d eigenvalues = solver.eigenvalues(); + Eigen::Matrix3d eigenvectors = solver.eigenvectors(); + + // Step 5: Find the eigenvector corresponding to the largest eigenvalue (first principal component) + Eigen::MatrixXd::Index max_eigenvalue_idx; + eigenvalues.maxCoeff(&max_eigenvalue_idx); + + Eigen::Vector3d first_principal_component = eigenvectors.col(max_eigenvalue_idx); + + // Step 6: Project centered data onto the first principal component + Eigen::VectorXd projections = centered * first_principal_component; + + // Step 7: Build result and sort + std::vector result; + result.reserve(n); + + for (std::size_t i = 0; i < n; ++i) { + result.push_back({i, projections(i)}); + } + + std::sort(result.begin(), result.end()); + + return result; +} + +std::vector cluster_adaptive(const std::vector& colors, const ClusterParameters& cluster_parameters) { + if (colors.empty()) { + return {}; + } + + const double max_color_distance = cluster_parameters.max_color_distance; + const std::size_t max_iter = cluster_parameters.max_iter; + const bool use_lab = (cluster_parameters.color_difference_method != ColorDifferenceMethod::RGB); + WorkingDistFunc working_dist_func = use_lab ? ciede2000 : calc_rgb_color_difference_by_squared_rgb_double; + + // ========================================== + // 1. Deduplicate + convert to working space + // ========================================== + std::vector unique_colors = colors; + std::sort(unique_colors.begin(), unique_colors.end()); + auto last = std::unique(unique_colors.begin(), unique_colors.end()); + unique_colors.erase(last, unique_colors.end()); + + BOOST_LOG_TRIVIAL(debug) << "cluster_adaptive: colors=" << colors.size() + << " unique=" << unique_colors.size() + << " max_color_distance=" << max_color_distance; + + if (unique_colors.size() <= 1) { + return unique_colors; + } + + std::vector working_colors(unique_colors.size()); + for (std::size_t i = 0; i < unique_colors.size(); ++i) { + if (use_lab) { + working_colors[i] = convert_rgb_to_lab(unique_colors[i]); + } else { + working_colors[i] = {static_cast(unique_colors[i][0]), static_cast(unique_colors[i][1]), static_cast(unique_colors[i][2])}; + } + } + + // ========================================== + // 2. Binary search k: find the smallest k where P99 radius <= max_color_distance + // ========================================== + const std::size_t max_k = cluster_parameters.max_cluster_k; + std::size_t lo = 1; + std::size_t hi = std::min(max_k, unique_colors.size()); + std::size_t best_k = 0; + std::vector best_centers; + + constexpr double kRadiusPercentile = 0.99; + + auto calc_max_radius = [&](const std::vector& centers) -> double { + std::vector distances(working_colors.size()); + tbb::parallel_for(tbb::blocked_range(0, working_colors.size()), [&](const tbb::blocked_range& range) { + for (std::size_t i = range.begin(); i < range.end(); ++i) { + double min_dist = std::numeric_limits::max(); + for (const auto& center : centers) { + double d = working_dist_func(working_colors[i], center); + if (d < min_dist) { + min_dist = d; + } + } + distances[i] = min_dist; + } + }); + if (distances.empty()) { + return 0.0; + } + std::size_t idx = std::min(static_cast(distances.size() * kRadiusPercentile), distances.size() - 1); + std::nth_element(distances.begin(), distances.begin() + idx, distances.end()); + return distances[idx]; + }; + + const auto& cancel_cb = cluster_parameters.cancel_callback; + + while (lo <= hi) { + if (cancel_cb && cancel_cb()) return {}; + std::size_t mid = lo + (hi - lo) / 2; + auto centers = kmeans_core(working_colors, mid, max_iter, working_dist_func, cancel_cb); + if (cancel_cb && cancel_cb()) return {}; + double max_radius = calc_max_radius(centers); + + BOOST_LOG_TRIVIAL(debug) << "cluster_adaptive binary search: k=" << mid << " max_radius=" << max_radius; + + if (max_radius <= max_color_distance) { + best_k = mid; + best_centers = std::move(centers); + hi = mid - 1; + } else { + lo = mid + 1; + } + } + + if (best_k == 0) { + best_k = std::min(max_k, unique_colors.size()); + best_centers = kmeans_core(working_colors, best_k, max_iter, working_dist_func, cancel_cb); + BOOST_LOG_TRIVIAL(warning) << "cluster_adaptive: binary search found no k satisfying max_radius<=" + << max_color_distance << ", fallback to k=" << best_k; + } + + BOOST_LOG_TRIVIAL(debug) << "cluster_adaptive: best_k=" << best_k; + + // ========================================== + // 3. Convert centers back to RGB + // ========================================== + std::vector result(best_k); + for (std::size_t i = 0; i < best_k; ++i) { + if (use_lab) { + result[i] = convert_lab_to_rgb(best_centers[i]); + } else { + result[i] = {static_cast(std::round(best_centers[i][0])), static_cast(std::round(best_centers[i][1])), + static_cast(std::round(best_centers[i][2]))}; + } + } + + return result; +} + +static std::vector> get_connected_face_groups(const CGALMesh& mesh) { + std::vector> face_groups; + std::unordered_set visited_faces; + for (auto src_face : mesh.faces()) { + if (visited_faces.count(src_face)) { + continue; + } + std::vector face_group; + std::queue que; + que.push(src_face); + visited_faces.insert(src_face); + while (!que.empty()) { + auto curr_face = que.front(); + que.pop(); + face_group.push_back(curr_face); + for (auto adj_face : mesh.faces_around_face(mesh.halfedge(curr_face))) { + if (!mesh.is_valid(adj_face) || mesh.is_removed(adj_face) || visited_faces.count(adj_face)) { + continue; + } + que.push(adj_face); + visited_faces.insert(adj_face); + } + } + face_groups.push_back(std::move(face_group)); + } + return face_groups; +} + +bool get_components(const TriMesh& bbs_mesh, const std::vector& bbs_vertex_uvs, std::vector& component_meshes, + std::vector>& component_vertex_uvs) { + component_meshes.clear(); + component_vertex_uvs.clear(); + + if (bbs_mesh.vertices.size() != bbs_vertex_uvs.size()) { + BOOST_LOG_TRIVIAL(warning) << "Input mesh vertex count does not match texture coordinate count"; + return false; + } + + CGALMesh cgal_mesh; + std::vector cgal_vertex_uvs; + if (!cgalutils::convert_trimesh_to_cgal(bbs_mesh, bbs_vertex_uvs, cgal_mesh, cgal_vertex_uvs)) { + BOOST_LOG_TRIVIAL(warning) << "Mesh conversion failed"; + return false; + } + + auto face_groups = get_connected_face_groups(cgal_mesh); + + for (const auto& faces : face_groups) { + std::unordered_map map_cgal_vtx_to_bbs_vtx; + TriVertices bbs_vertices; + TriFaces bbs_faces; + std::vector bbs_vertex_uvs; + + for (auto cgal_face : faces) { + TriFace bbs_face; + std::size_t face_vid = 0; + for (auto cgal_vtx : cgal_mesh.vertices_around_face(cgal_mesh.halfedge(cgal_face))) { + if (!map_cgal_vtx_to_bbs_vtx.count(cgal_vtx)) { + map_cgal_vtx_to_bbs_vtx[cgal_vtx] = bbs_vertices.size(); + const auto& cgal_point = cgal_mesh.point(cgal_vtx); + bbs_vertices.push_back(TriVertex(cgal_point.x(), cgal_point.y(), cgal_point.z())); + bbs_vertex_uvs.push_back(cgal_vertex_uvs[cgal_vtx]); + } + bbs_face[face_vid] = map_cgal_vtx_to_bbs_vtx[cgal_vtx]; + ++face_vid; + } + bbs_faces.push_back(std::move(bbs_face)); + } + + component_meshes.push_back(TriMesh(bbs_faces, bbs_vertices)); + component_vertex_uvs.push_back(std::move(bbs_vertex_uvs)); + } + + return true; +} + +bool calc_nearest_color_id(const std::vector& colors, const RGB& color, std::size_t& nearest_color_id) { + if (colors.empty()) { + BOOST_LOG_TRIVIAL(warning) << "No color list provided"; + return false; + } + double min_dist = std::numeric_limits::max(); + nearest_color_id = 0; + for (std::size_t i = 0; i < colors.size(); ++i) { + double dist = calc_rgb_color_difference_by_ciede2000(colors[i], color); + if (dist < min_dist) { + min_dist = dist; + nearest_color_id = i; + } + } + return true; +} + +bool mesh_cluster(const TriMesh& bbs_mesh, const std::vector& cluster_centers, std::vector& map_face_to_rgb, + std::vector& map_face_to_cluster_id) { + if (cluster_centers.empty()) { + BOOST_LOG_TRIVIAL(warning) << "No cluster centers provided"; + return false; + } + + CGALMesh mesh; + cgalutils::convert_trimesh_to_cgal(bbs_mesh, mesh); + if (mesh.number_of_faces() != bbs_mesh.indices.size()) { + BOOST_LOG_TRIVIAL(warning) << "CGAL mesh face count does not match BBS mesh face count"; + return false; + } + if (mesh.number_of_faces() != map_face_to_rgb.size()) { + BOOST_LOG_TRIVIAL(warning) << "CGAL mesh face count does not match RGB count"; + return false; + } + + if (cluster_centers.size() == 1) { + std::fill(map_face_to_rgb.begin(), map_face_to_rgb.end(), cluster_centers[0]); +#ifdef DEBUG_FLAG + BOOST_LOG_TRIVIAL(debug) << "input cluster centers' size is 1, so we set all face RGB as same with it and return.\n"; +#endif + return true; + } + + std::vector map_face_to_area(mesh.number_of_faces()); + tbb::parallel_for(tbb::blocked_range(0, mesh.number_of_faces()), [&](const tbb::blocked_range& range) { + for (std::size_t fid = range.begin(); fid < range.end(); ++fid) { + CGAL::SM_Face_index face(fid); + map_face_to_area[fid] = std::max(PMP::face_area(face, mesh), EPSILON); + } + }); + + // Step 1: Identify faces that definitely belong to a cluster center. + // A face is definitively assigned when dist1 * absolute_difference_times < dist2 (nearest vs. second-nearest center). + constexpr double absolute_difference_times = 1.5; + // dE <= 1.0: imperceptible to the human eye, high-precision color matching + // dE <= 2.0: slight difference, noticeable by experts; printing / image processing standard + // dE <= 3.0: noticeable by ordinary observers; general quality control + constexpr double difference_epsilon = 3.0; + constexpr std::size_t invalid_cluster_id = std::numeric_limits::max(); + map_face_to_cluster_id.resize(mesh.number_of_faces(), invalid_cluster_id); + std::vector>> map_face_to_dists(mesh.number_of_faces(), + std::vector>(cluster_centers.size())); + tbb::parallel_for(tbb::blocked_range(0, mesh.number_of_faces()), [&](const tbb::blocked_range& range) { + for (std::size_t fid = range.begin(); fid < range.end(); ++fid) { + CGAL::SM_Face_index face(fid); + std::vector>& dist_and_cid_vec = map_face_to_dists[fid]; + for (std::size_t cluster_id = 0; cluster_id < cluster_centers.size(); ++cluster_id) { + double dist = calc_rgb_color_difference_by_ciede2000(map_face_to_rgb[fid], cluster_centers[cluster_id]); + //dist_and_cid_vec.emplace_back(dist, cluster_id); + dist_and_cid_vec[cluster_id] = std::pair{dist, cluster_id}; + } + std::sort(dist_and_cid_vec.begin(), dist_and_cid_vec.end()); + if (dist_and_cid_vec[0].first < difference_epsilon || dist_and_cid_vec[0].first * absolute_difference_times < dist_and_cid_vec[1].first) { + map_face_to_cluster_id[fid] = dist_and_cid_vec[0].second; + } + } + }); + + std::unordered_set unclusted_fids; + for (std::size_t fid = 0; fid < mesh.number_of_faces(); ++fid) { + if (map_face_to_cluster_id[fid] == invalid_cluster_id) { + unclusted_fids.insert(fid); + } + } + + auto convert_unclustered_to_clustered = [&](const std::unordered_set& iter_clustered_fids) -> bool { + if (iter_clustered_fids.empty()) { + return false; + } + for (auto& fid : iter_clustered_fids) { + unclusted_fids.erase(fid); + } + return true; + }; + + // Step 2: Flood. Use faces computed in the previous step as seeds and propagate outward. + while (!unclusted_fids.empty()) { + bool changed = false; + std::unordered_set iter_clustered_fids; + // If an uncolored face has an adjacent color whose count exceeds the sum of all other colors, assign that color + for (auto fid : unclusted_fids) { + CGAL::SM_Face_index face(fid); + std::size_t count = 0; + std::unordered_map map_cluster_id_to_count; + for (auto adj_face : mesh.faces_around_face(mesh.halfedge(face))) { + if (!mesh.is_valid(adj_face) || mesh.is_removed(adj_face)) { + continue; + } + ++count; + ++map_cluster_id_to_count[map_face_to_cluster_id[adj_face]]; + } + for (auto& [cluster_id, cnt] : map_cluster_id_to_count) { + if (cluster_id != invalid_cluster_id && cnt * 2 > count) { + map_face_to_cluster_id[fid] = cluster_id; + iter_clustered_fids.insert(fid); + break; + } + } + } + changed = convert_unclustered_to_clustered(iter_clustered_fids) || changed; + iter_clustered_fids.clear(); + + // If a face's nearest cluster center (by color distance) happens to have an adjacent face, assign that color too + for (auto fid : unclusted_fids) { + CGAL::SM_Face_index face(fid); + std::unordered_set adj_cluster_ids; + for (auto adj_face : mesh.faces_around_face(mesh.halfedge(face))) { + if (!mesh.is_valid(adj_face) || mesh.is_removed(adj_face)) { + continue; + } + if (map_face_to_cluster_id[adj_face] == invalid_cluster_id) { + continue; + } + adj_cluster_ids.insert(map_face_to_cluster_id[adj_face]); + } + if (adj_cluster_ids.count(map_face_to_dists[fid].front().second)) { + map_face_to_cluster_id[fid] = map_face_to_dists[fid].front().second; + iter_clustered_fids.insert(fid); + } + } + changed = convert_unclustered_to_clustered(iter_clustered_fids) || changed; + iter_clustered_fids.clear(); + + // If no face colors were modified in this iteration, stop + if (!changed) { + break; + } + } + + // Step 3: Handle remaining unclustered faces (run after the above operations complete) + constexpr bool use_average_color = true; + for (auto src_fid : std::vector(unclusted_fids.begin(), unclusted_fids.end())) { + if (!unclusted_fids.count(src_fid)) { + continue; + } + // Compute connected unclustered faces + std::queue que; + std::unordered_set connected_unclustered_faces; + que.push(src_fid); + connected_unclustered_faces.insert(src_fid); + double sum_r = 0, sum_g = 0, sum_b = 0; + double sum_area = 0.0; + std::unordered_map map_cluster_id_to_adj_area; + while (!que.empty()) { + auto curr_fid = que.front(); + que.pop(); + sum_r += map_face_to_area[curr_fid] * map_face_to_rgb[curr_fid][0]; + sum_g += map_face_to_area[curr_fid] * map_face_to_rgb[curr_fid][1]; + sum_b += map_face_to_area[curr_fid] * map_face_to_rgb[curr_fid][2]; + sum_area += map_face_to_area[curr_fid]; + CGAL::SM_Face_index curr_face(curr_fid); + for (auto adj_face : mesh.faces_around_face(mesh.halfedge(curr_face))) { + if (!mesh.is_valid(adj_face) || mesh.is_removed(adj_face)) { // Invalid face + continue; + } + if (map_face_to_cluster_id[adj_face] != invalid_cluster_id) { + map_cluster_id_to_adj_area[map_face_to_cluster_id[adj_face]] += map_face_to_area[adj_face]; + } else { + if (!connected_unclustered_faces.count(adj_face)) { // Already clustered or already recorded + que.push(adj_face); + connected_unclustered_faces.insert(adj_face); + } + } + } + } + std::size_t matched_cluster_id = invalid_cluster_id; + if (use_average_color) { + // Use average color + RGB average_color{static_cast(sum_r / sum_area), static_cast(sum_g / sum_area), + static_cast(sum_b / sum_area)}; + double min_dist = std::numeric_limits::max(); + for (auto& [cluster_id, area] : map_cluster_id_to_adj_area) { + double dist = calc_rgb_color_difference_by_ciede2000(average_color, cluster_centers[cluster_id]); + if (dist < min_dist) { + min_dist = dist; + matched_cluster_id = cluster_id; + } + } + } else { + // Use adjacent area + double adj_max_area = 0.0; + for (auto& [cluster_id, area] : map_cluster_id_to_adj_area) { + if (area > adj_max_area) { + adj_max_area = area; + matched_cluster_id = cluster_id; + } + } + } + for (auto fid : connected_unclustered_faces) { + map_face_to_cluster_id[fid] = matched_cluster_id; + unclusted_fids.erase(fid); + } + } + + // Convert cluster center IDs to colors + for (std::size_t fid = 0; fid < mesh.number_of_faces(); ++fid) { + if (map_face_to_cluster_id[fid] == invalid_cluster_id) { + map_face_to_rgb[fid] = cluster_centers[0]; + map_face_to_cluster_id[fid] = 0; // Prevent out-of-bounds errors when using cluster_id later + } else { + map_face_to_rgb[fid] = cluster_centers[map_face_to_cluster_id[fid]]; + } + } + + return true; +} + +} // namespace color_utils + +} // namespace tex2color +} // namespace Slic3r diff --git a/src/libslic3r/TextureToColor/ColorUtils.hpp b/src/libslic3r/TextureToColor/ColorUtils.hpp new file mode 100644 index 0000000000..05849109f4 --- /dev/null +++ b/src/libslic3r/TextureToColor/ColorUtils.hpp @@ -0,0 +1,207 @@ +#pragma once + +#include "Callbacks.hpp" +#include "TriMesh.hpp" + +namespace Slic3r { namespace tex2color { + +namespace color_utils { +struct ClusterParameters; + +typedef std::array Color; // RGB: [R, G, B] 0~255 +typedef std::vector ColorList; +typedef std::array ColorDouble; +typedef std::array RGB; + +// Function pointer type that points to a specific color-difference function based on the chosen method. +using DistanceFunction = double (*)(const Color&, const Color&); + +// Color space used for computing color differences. +enum struct ColorDifferenceMethod : std::size_t { + RGB = 0, // Simplest and fastest + Lab = 1 // Most perceptually accurate +}; + +struct ClusterParameters { + ColorDifferenceMethod color_difference_method = ColorDifferenceMethod::Lab; // Method for measuring color difference; Lab is the most accurate + + double max_color_distance = 25; // Max intra-cluster radius (CIEDE2000 dE) for adaptive clustering; ignored by the fixed-K algorithm + + std::size_t cluster_k = 10; // Target number of cluster centers; ignored by the adaptive algorithm + + std::size_t max_cluster_k = 32; // Max cluster count upper bound for adaptive algorithm + + std::size_t max_iter = 50; // Maximum number of iterations + + std::function cancel_callback; // Optional cancellation check; returns true when the caller requests abort +}; + +struct SmoothParameters { + double smooth_weight = 0.5; // Controls smoothing intensity; larger values produce smoother results. Range: [0.0, 1.0] +}; + +/** + * @brief Compute the squared Euclidean distance between two RGB colors. + * + * @param[in] rgb1 First RGB color [R, G, B], range 0~255. + * @param[in] rgb2 Second RGB color [R, G, B], range 0~255. + * @return Squared Euclidean distance: (R1-R2)^2 + (G1-G2)^2 + (B1-B2)^2. + */ +double calc_rgb_color_difference_by_squared_rgb(const RGB& rgb1, const RGB& rgb2); + +/** + * @brief Compute the squared Euclidean distance between two RGB colors (double precision). + * + * @param[in] c1 First RGB color [R, G, B], as double. + * @param[in] c2 Second RGB color [R, G, B], as double. + * @return Squared Euclidean distance: (R1-R2)^2 + (G1-G2)^2 + (B1-B2)^2. + */ +double calc_rgb_color_difference_by_squared_rgb_double(const ColorDouble& c1, const ColorDouble& c2); + +/** + * @brief Compute the CIEDE2000 color difference between two RGB colors. + * + * Currently the most accurate color-difference formula, recommended by CIE as the industry standard. + * - dE <= 1.0: imperceptible to the human eye, high-precision color matching. + * - dE <= 2.0: slight difference, noticeable by experts; printing / image processing standard. + * - dE <= 3.0: noticeable by ordinary observers; general quality control. + * + * @param[in] rgb1 First RGB color [R, G, B], range 0~255. + * @param[in] rgb2 Second RGB color [R, G, B], range 0~255. + * @return CIEDE2000 color difference; smaller values indicate more similar colors. + */ +double calc_rgb_color_difference_by_ciede2000(const RGB& rgb1, const RGB& rgb2); + +/** + * @brief Compute the CIEDE2000 color difference between two sRGB colors (double precision, non-linear channels in [0,1]). + * + * Uses the same XYZ/Lab/dE00 pipeline as calc_rgb_color_difference_by_ciede2000 but without uint8 + * quantization or the intermediate x255 conversion; suitable for bisection, color blending, and other + * iterative scenarios. Note: ColorDouble here represents [R,G,B] in [0,1], which differs from the + * 0~255 scale used by other interfaces in this file. Callers should follow the naming convention. + * + * @param[in] rgb1 rgb2 sRGB non-linear channel values, recommended range [0,1]. + */ +double calc_rgb_color_difference_by_ciede2000_srgb01(const ColorDouble& rgb1, const ColorDouble& rgb2); + +/** + * @brief K-Means clustering algorithm that minimizes the sum of squared errors. + * + * Uses K-Means++ initialization to iteratively find the optimal cluster centers. + * + * @param[in] colors Input color list. + * @param[in] cluster_parameters Clustering parameters including cluster count, max iterations, color-difference method, etc. + * @return List of cluster-center colors whose size equals cluster_parameters.cluster_k. + */ +std::vector cluster_k_means(const std::vector& colors, const ClusterParameters& cluster_parameters); + +/** + * @brief Adaptive K-Means clustering that determines an appropriate number of clusters under a max color-distance constraint. + * + * Automatically finds the optimal cluster count via binary search so that max_color_distance is satisfied. + * + * @param[in] colors Input color list. + * @param[in] cluster_parameters Clustering parameters; cluster_k is ignored and determined automatically. + * @return List of cluster-center colors whose count is determined by the algorithm based on max_color_distance. + */ +std::vector cluster_adaptive(const std::vector& colors, const ClusterParameters& cluster_parameters); + +/** + * @brief Cluster a color list to a set of specified cluster centers. + * + * For each input color, find the nearest specified cluster center and replace it. + * + * @param[in] colors Input color list. + * @param[in] specified_colors Specified cluster-center colors. + * @return Clustered color list where each color is replaced by its nearest center. + */ +std::vector cluster_to_specified_colors(const std::vector& colors, const std::vector& specified_colors); + +/** + * @brief Remesh the mesh while preserving color boundaries. + * + * Performs isotropic remeshing while protecting color boundaries. Edges whose two adjacent + * faces have different colors are marked as feature edges and will not be modified. + * + * @param[in,out] mesh Input mesh; modified in-place after remeshing. + * @param[in,out] face_labels Face color labels; updated to match the new mesh. + * @param[in] target_edge_length_ratio Ratio of target average edge length to input average edge length; >1 simplifies, <1 refines. + * @return true on success, false on failure. + */ +bool remesh_mesh(TriMesh& mesh, std::vector& face_labels, double target_edge_length_ratio); + +/** + * @brief Check whether the mesh is closed (watertight). + * + * A mesh is closed if it has no boundary edges, i.e. every edge is shared by exactly two faces. + * + * @param[in] tri_mesh Input mesh. + * @return true if the mesh is closed, false if it has boundary edges. + */ +bool is_closed(const TriMesh& tri_mesh); + +/** + * @brief Smooth region boundaries (RGB color labels). + * + * Applies topological smoothing (label reassignment) and geometric smoothing (boundary vertex relocation). + * + * @param[in,out] tri_mesh Input mesh; modified in-place after smoothing. + * @param[in,out] face_labels Face color labels (RGB format); updated after smoothing. + * @param[in] smooth_parameters Smoothing control parameters. + * @return true on success, false on failure. + */ +bool smooth_region(TriMesh& tri_mesh, std::vector>& face_labels, const SmoothParameters& smooth_parameters = SmoothParameters()); + +/** + * @brief Smooth region boundaries (integer labels). + * + * Applies topological smoothing (label reassignment) and geometric smoothing (boundary vertex relocation). + * + * @param[in,out] tri_mesh Input mesh; modified in-place after smoothing. + * @param[in,out] face_labels Integer face labels; updated after smoothing. + * @param[in] smooth_parameters Smoothing control parameters. + * @return true on success, false on failure. + */ +bool smooth_region(TriMesh& tri_mesh, std::vector& face_labels, const SmoothParameters& smooth_parameters = SmoothParameters()); + +/** + * @brief Split the mesh into connected components. + * + * Based on face connectivity, the mesh is split into independent components, each forming a + * standalone mesh. Texture coordinates for each component are preserved. + * + * @param[in] mesh Input mesh. + * @param[in] vertex_uvs Vertex texture coordinates. + * @param[out] component_meshes Output list of component meshes. + * @param[out] component_vertex_uvs Output list of texture coordinates per component. + * @return true on success, false on failure. + */ +bool get_components(const TriMesh& mesh, const std::vector& vertex_uvs, std::vector& component_meshes, + std::vector>& component_vertex_uvs); + +/** + * @brief Find the ID of the nearest color in a color list to a given color. + * + * @param[in] colors Color list. + * @param[in] color Target color. + * @param[out] nearest_color_id ID of the nearest color found. + * @return true on success, false on failure. + */ +bool calc_nearest_color_id(const std::vector& colors, const RGB& color, std::size_t& nearest_color_id); + +/** + * @brief Cluster mesh face colors based on given cluster centers. + * + * @param[in] mesh Input mesh. + * @param[in] cluster_centers Cluster-center RGB colors. + * @param[in, out] map_face_to_rgb RGB color per face; updated to the nearest cluster center after clustering. + * @param[out] map_face_to_cluster_id Cluster-center ID per face; updated to the nearest cluster center ID. + * @return true on success, false on failure. + */ +bool mesh_cluster(const TriMesh& mesh, const std::vector& cluster_centers, std::vector& map_face_to_rgb, + std::vector& map_face_to_cluster_id); + +} // namespace color_utils + +} // namespace tex2color +} // namespace Slic3r diff --git a/src/libslic3r/TextureToColor/Repair.hpp b/src/libslic3r/TextureToColor/Repair.hpp new file mode 100644 index 0000000000..44dc30c144 --- /dev/null +++ b/src/libslic3r/TextureToColor/Repair.hpp @@ -0,0 +1,252 @@ +#pragma once +#include "TriMesh.hpp" +#include "CgalUtils.hpp" +#include "Callbacks.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { namespace tex2color { + +namespace PMP = CGAL::Polygon_mesh_processing; + +// Default upper bound on the number of half-edges in any single boundary cycle +// that CloseBoundariesAndRepairManifoldness will attempt to triangulate. The +// cost of triangulate_hole grows non-linearly with cycle length, so this caps +// the worst-case per-hole work rather than the aggregate boundary size: a mesh +// with many small holes is still fully repaired, while a mesh containing one +// pathologically large hole skips triangulation entirely. +inline constexpr std::size_t MAX_REPAIRABLE_MESH_HOLE_EDGES = 500; + +// Default upper bound on the aggregate number of boundary half-edges in the +// mesh (summed across every boundary cycle). When the total boundary length is +// excessive, even if each individual cycle is short, triangulating all of them +// usually indicates a severely fragmented input (e.g. heavily damaged scans) +// and rarely yields a usable result, so we skip hole closing entirely. +inline constexpr std::size_t MAX_REPAIRABLE_MESH_BOUNDARY_EDGES = 5000; + +struct RepairSetting +{ + // Skip triangulating a boundary cycle whose half-edge count exceeds this. + std::size_t max_hole_edges = MAX_REPAIRABLE_MESH_HOLE_EDGES; + // Skip hole closing entirely when the total boundary half-edge count + // (summed across all cycles) exceeds this. + std::size_t max_boundary_edges = MAX_REPAIRABLE_MESH_BOUNDARY_EDGES; +}; + +struct BoundaryEdgeStats +{ + std::size_t total_boundary_edges = 0; + std::size_t max_cycle_edges = 0; + std::size_t cycle_count = 0; +}; + +// Read-only inspection of the mesh's boundary cycles. Caller is responsible for +// any pre-processing (e.g. stitch_borders) needed for the count to be meaningful. +inline BoundaryEdgeStats ComputeBoundaryEdgeStats(const cgalutils::CGALMesh& cgal_mesh) +{ + using CGALMesh = cgalutils::CGALMesh; + using HalfedgeDescriptor = boost::graph_traits::halfedge_descriptor; + + std::vector border_cycles; + PMP::extract_boundary_cycles(cgal_mesh, std::back_inserter(border_cycles)); + + BoundaryEdgeStats stats; + stats.cycle_count = border_cycles.size(); + for (const HalfedgeDescriptor h0 : border_cycles) { + std::size_t len = 0; + HalfedgeDescriptor h = h0; + do { + ++len; + h = next(h, cgal_mesh); + } while (h != h0); + stats.max_cycle_edges = std::max(stats.max_cycle_edges, len); + stats.total_boundary_edges += len; + } + return stats; +} + +// Unconditionally close every boundary cycle of the mesh and repair non-manifold +// vertices. The caller (e.g. RepairMesh) is expected to gate this call based on +// boundary statistics; entering this function always triggers triangulation. +inline void CloseBoundariesAndRepairManifoldness(cgalutils::CGALMesh& cgal_mesh) +{ + using CGALMesh = cgalutils::CGALMesh; + using HalfedgeDescriptor = boost::graph_traits::halfedge_descriptor; + using FaceDescriptor = boost::graph_traits::face_descriptor; + + PMP::stitch_borders(cgal_mesh); + PMP::duplicate_non_manifold_vertices(cgal_mesh); + + std::vector border_cycles; + PMP::extract_boundary_cycles(cgal_mesh, std::back_inserter(border_cycles)); + + for (const HalfedgeDescriptor h : border_cycles) { + std::vector patch_faces; + PMP::triangulate_hole(cgal_mesh, h, std::back_inserter(patch_faces)); + } + + PMP::remove_degenerate_faces(cgal_mesh); + PMP::duplicate_non_manifold_vertices(cgal_mesh); +} + +inline bool RepairMesh(const TriMesh& mesh, + std::shared_ptr& out_mesh, + AlgoProgressCallback progress_callback = nullptr, + AlgoCancelCallback cancel_callback = nullptr, + const RepairSetting& setting = RepairSetting{}) +{ + using Clock = std::chrono::steady_clock; + auto elapsed_ms = [](Clock::time_point t0) { + return std::chrono::duration_cast(Clock::now() - t0).count(); + }; + + const Clock::time_point t_total = Clock::now(); + + // Convert TriMesh to polygon soup (point container + triangle index container) + std::vector soup_points; + std::vector> soup_triangles; + + soup_points.reserve(mesh.vertices.size()); + for (const TriVertex& v : mesh.vertices) { + soup_points.emplace_back(v.x(), v.y(), v.z()); + } + + soup_triangles.reserve(mesh.indices.size()); + for (const TriFace& f : mesh.indices) { + soup_triangles.push_back({static_cast(f[0]), + static_cast(f[1]), + static_cast(f[2])}); + } + + if (progress_callback) { + progress_callback({30, "Repairing polygon soup"}); + } + if (cancel_callback && cancel_callback()) { + return false; + } + + { + const auto t0 = Clock::now(); + PMP::repair_polygon_soup(soup_points, soup_triangles); + BOOST_LOG_TRIVIAL(info) << "TextureToColor: RepairMesh stage=repair_polygon_soup took=" + << elapsed_ms(t0) << " ms"; + } + + if (progress_callback) { + progress_callback({50, "Orienting polygon soup"}); + } + if (cancel_callback && cancel_callback()) { + return false; + } + + { + const auto t0 = Clock::now(); + PMP::orient_polygon_soup(soup_points, soup_triangles); + BOOST_LOG_TRIVIAL(info) << "TextureToColor: RepairMesh stage=orient_polygon_soup took=" + << elapsed_ms(t0) << " ms"; + } + + if (progress_callback) { + progress_callback({70, "Converting to CGAL mesh"}); + } + if (cancel_callback && cancel_callback()) { + return false; + } + + cgalutils::CGALMesh cgal_mesh; + { + const auto t0 = Clock::now(); + PMP::polygon_soup_to_polygon_mesh(soup_points, soup_triangles, cgal_mesh); + BOOST_LOG_TRIVIAL(info) << "TextureToColor: RepairMesh stage=polygon_soup_to_polygon_mesh took=" + << elapsed_ms(t0) << " ms"; + } + + { + const auto t0 = Clock::now(); + PMP::remove_degenerate_faces(cgal_mesh); + BOOST_LOG_TRIVIAL(info) << "TextureToColor: RepairMesh stage=remove_degenerate_faces took=" + << elapsed_ms(t0) << " ms"; + } + + if (progress_callback) { + progress_callback({80, "Closing mesh boundaries"}); + } + if (cancel_callback && cancel_callback()) { + return false; + } + + // Stitch borders and duplicate non-manifold vertices first so that the + // boundary statistics below reflect the post-stitch topology; otherwise + // boundaries that would close on stitching inflate the counts and may + // cause the gate to skip hole filling unnecessarily. + BoundaryEdgeStats stats; + { + const auto t0 = Clock::now(); + PMP::stitch_borders(cgal_mesh); + PMP::duplicate_non_manifold_vertices(cgal_mesh); + stats = ComputeBoundaryEdgeStats(cgal_mesh); + BOOST_LOG_TRIVIAL(info) << "TextureToColor: RepairMesh stage=boundary_stats took=" + << elapsed_ms(t0) << " ms" + << " total_boundary_edges=" << stats.total_boundary_edges + << " max_cycle_edges=" << stats.max_cycle_edges + << " cycle_count=" << stats.cycle_count; + } + + const bool can_repair_holes = + stats.total_boundary_edges <= setting.max_boundary_edges && + stats.max_cycle_edges <= setting.max_hole_edges; + + if (can_repair_holes) { + const auto t0 = Clock::now(); + CloseBoundariesAndRepairManifoldness(cgal_mesh); + BOOST_LOG_TRIVIAL(info) << "TextureToColor: RepairMesh stage=close_boundaries took=" + << elapsed_ms(t0) << " ms"; + } else { + BOOST_LOG_TRIVIAL(info) + << "TextureToColor: RepairMesh skip hole closing" + << ", total_boundary_edges=" << stats.total_boundary_edges + << " (limit=" << setting.max_boundary_edges << ")" + << ", max_cycle_edges=" << stats.max_cycle_edges + << " (limit=" << setting.max_hole_edges << ")" + << ", cycle_count=" << stats.cycle_count; + } + + if (progress_callback) { + progress_callback({85, "Converting from CGAL mesh"}); + } + if (cancel_callback && cancel_callback()) { + return false; + } + + std::shared_ptr out; + { + const auto t0 = Clock::now(); + out = std::make_shared(cgalutils::cgal_to_trimesh(cgal_mesh)); + BOOST_LOG_TRIVIAL(info) << "TextureToColor: RepairMesh stage=cgal_to_trimesh took=" + << elapsed_ms(t0) << " ms"; + } + + out_mesh = std::move(out); + if (progress_callback) { + progress_callback({100, "Done"}); + } + + BOOST_LOG_TRIVIAL(info) << "TextureToColor: RepairMesh total=" << elapsed_ms(t_total) << " ms"; + + return true; +} + +} // namespace tex2color +} // namespace Slic3r diff --git a/src/libslic3r/TextureToColor/TextureToColor.cpp b/src/libslic3r/TextureToColor/TextureToColor.cpp new file mode 100644 index 0000000000..e5dde36714 --- /dev/null +++ b/src/libslic3r/TextureToColor/TextureToColor.cpp @@ -0,0 +1,1043 @@ +#include "TextureToColor.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include "CgalUtils.hpp" +#include "ColorUtils.hpp" +#include "libslic3r/TriangleMesh.hpp" +#include +#include +#include "Repair.hpp" +#include "libslic3r/AABBTreeIndirect.hpp" +#include + +namespace Slic3r { namespace tex2color { + +using namespace color_utils; + +// #define OUTPUT_TEST_RESULT + +static void SaveToOFF(const std::string& path, const TriMesh& mesh, const std::vector& face_colors) +{ + std::filesystem::create_directories(std::filesystem::path(path).parent_path()); + std::ofstream ofs(path); + if (!ofs.is_open()) { + BOOST_LOG_TRIVIAL(warning) << "SaveToOFF: failed to open " << path; + return; + } + + const auto& vertices = mesh.vertices; + const auto& faces = mesh.indices; + + ofs << "OFF\n"; + ofs << vertices.size() << " " << faces.size() << " 0\n"; + + for (const auto& v : vertices) { + ofs << v.x() << " " << v.y() << " " << v.z() << "\n"; + } + + for (std::size_t i = 0; i < faces.size(); ++i) { + const auto& f = faces[i]; + ofs << "3 " << f[0] << " " << f[1] << " " << f[2]; + if (i < face_colors.size()) { + ofs << " " << face_colors[i][0] / 255.0 + << " " << face_colors[i][1] / 255.0 + << " " << face_colors[i][2] / 255.0 + << " 1.0"; + } + ofs << "\n"; + } +} + +static std::vector count_cluster_label_usage(const std::vector& face_labels, std::size_t cluster_count) +{ + std::vector usage(cluster_count, 0); + for (std::size_t label : face_labels) { + if (label < cluster_count) { + ++usage[label]; + } + } + return usage; +} + +static bool discard_unused_cluster_centers(std::vector& cluster_centers, std::vector& face_labels, const char* stage_name) +{ + if (cluster_centers.empty()) { + BOOST_LOG_TRIVIAL(warning) << "TextureToColor: cannot discard unused cluster centers at " << stage_name + << ", no cluster center is available."; + return false; + } + + const std::vector usage = count_cluster_label_usage(face_labels, cluster_centers.size()); + std::vector label_remap(cluster_centers.size(), std::numeric_limits::max()); + std::vector used_cluster_centers; + used_cluster_centers.reserve(cluster_centers.size()); + + for (std::size_t cluster_id = 0; cluster_id < cluster_centers.size(); ++cluster_id) { + if (usage[cluster_id] == 0) { + continue; + } + label_remap[cluster_id] = used_cluster_centers.size(); + used_cluster_centers.push_back(cluster_centers[cluster_id]); + } + + if (used_cluster_centers.size() == cluster_centers.size()) { + return true; + } + if (used_cluster_centers.empty()) { + BOOST_LOG_TRIVIAL(warning) << "TextureToColor: cannot discard unused cluster centers at " << stage_name + << ", no face uses any valid cluster center."; + return false; + } + + for (std::size_t& label : face_labels) { + if (label >= label_remap.size() || label_remap[label] == std::numeric_limits::max()) { + BOOST_LOG_TRIVIAL(warning) << "TextureToColor: cannot remap cluster label " << label + << " at " << stage_name << "."; + return false; + } + label = label_remap[label]; + } + + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: discarded " << (cluster_centers.size() - used_cluster_centers.size()) + << " unused adaptive cluster centers at " << stage_name << "."; + cluster_centers = std::move(used_cluster_centers); + return true; +} + +static bool ensure_all_cluster_centers_used(const std::vector& source_face_colors, const std::vector& cluster_centers, + std::vector& face_labels, const char* stage_name) +{ + if (source_face_colors.size() != face_labels.size()) { + BOOST_LOG_TRIVIAL(warning) << "TextureToColor: cannot preserve cluster colors at " << stage_name + << ", face color count does not match label count."; + return false; + } + if (cluster_centers.empty()) { + BOOST_LOG_TRIVIAL(warning) << "TextureToColor: cannot preserve cluster colors at " << stage_name + << ", no cluster center is available."; + return false; + } + if (cluster_centers.size() > face_labels.size()) { + BOOST_LOG_TRIVIAL(warning) << "TextureToColor: cannot use all cluster centers at " << stage_name + << ", centers=" << cluster_centers.size() << " faces=" << face_labels.size() << "."; + return false; + } + + std::vector usage = count_cluster_label_usage(face_labels, cluster_centers.size()); + std::size_t missing_count = 0; + for (std::size_t cluster_id = 0; cluster_id < usage.size(); ++cluster_id) { + if (usage[cluster_id] != 0) { + continue; + } + ++missing_count; + + double best_cost = std::numeric_limits::max(); + std::size_t best_face_id = std::numeric_limits::max(); + std::size_t best_old_cluster_id = std::numeric_limits::max(); + + for (std::size_t fid = 0; fid < face_labels.size(); ++fid) { + const std::size_t old_cluster_id = face_labels[fid]; + if (old_cluster_id >= cluster_centers.size() || usage[old_cluster_id] <= 1) { + continue; + } + + const double old_dist = calc_rgb_color_difference_by_ciede2000(source_face_colors[fid], cluster_centers[old_cluster_id]); + const double new_dist = calc_rgb_color_difference_by_ciede2000(source_face_colors[fid], cluster_centers[cluster_id]); + const double cost = new_dist - old_dist; + if (cost < best_cost) { + best_cost = cost; + best_face_id = fid; + best_old_cluster_id = old_cluster_id; + } + } + + if (best_face_id == std::numeric_limits::max()) { + BOOST_LOG_TRIVIAL(warning) << "TextureToColor: failed to assign a seed face for unused cluster " << cluster_id + << " at " << stage_name << "."; + continue; + } + + face_labels[best_face_id] = cluster_id; + --usage[best_old_cluster_id]; + ++usage[cluster_id]; + } + + if (missing_count > 0) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: reassigned seed faces for " << missing_count + << " unused cluster centers at " << stage_name << "."; + } + + for (std::size_t count : usage) { + if (count == 0) { + return false; + } + } + return true; +} + +// Bilinear interpolation texture sampling; sub-pixel precision avoids nearest-neighbor aliasing +static RGB get_pixel_color(float u, float v, const cv::Mat& texture) { + u = u - std::floor(u); + v = v - std::floor(v); + + // glTF UV convention: (0,0) = top-left, v increases downward + float fx = u * (texture.cols - 1); + float fy = v * (texture.rows - 1); + + int x0 = std::clamp(static_cast(fx), 0, texture.cols - 1); + int y0 = std::clamp(static_cast(fy), 0, texture.rows - 1); + int x1 = std::min(x0 + 1, texture.cols - 1); + int y1 = std::min(y0 + 1, texture.rows - 1); + + float wx = fx - x0; + float wy = fy - y0; + + const int ch = texture.channels(); + auto sample = [&](int row, int col) -> std::array { + const uchar* ptr = texture.data + row * texture.step[0] + col * ch; + return {static_cast(ptr[2]), static_cast(ptr[1]), static_cast(ptr[0])}; + }; + + auto c00 = sample(y0, x0); + auto c10 = sample(y0, x1); + auto c01 = sample(y1, x0); + auto c11 = sample(y1, x1); + + // Bilinear blend: lerp(lerp(c00,c10,wx), lerp(c01,c11,wx), wy) + RGB color; + for (int i = 0; i < 3; ++i) { + float top = c00[i] * (1.0f - wx) + c10[i] * wx; + float bot = c01[i] * (1.0f - wx) + c11[i] * wx; + color[i] = static_cast(std::clamp(top * (1.0f - wy) + bot * wy, 0.0f, 255.0f)); + } + return color; +} + +// 7-point triangular Gaussian quadrature barycentric coordinates and weights (precision sufficient for capturing texture detail within faces) +static constexpr std::array, 7> GAUSS_TRI_BARY = {{ + {1.0f / 3.0f, 1.0f / 3.0f, 1.0f / 3.0f}, + {0.059715871f, 0.470142064f, 0.470142064f}, + {0.470142064f, 0.059715871f, 0.470142064f}, + {0.470142064f, 0.470142064f, 0.059715871f}, + {0.797426985f, 0.101286507f, 0.101286507f}, + {0.101286507f, 0.797426985f, 0.101286507f}, + {0.101286507f, 0.101286507f, 0.797426985f}, +}}; +static constexpr std::array GAUSS_TRI_WEIGHT = {0.225f, 0.132394152f, 0.132394152f, 0.132394152f, 0.125939181f, 0.125939181f, 0.125939181f}; +static_assert( + []() constexpr { + float sum = 0.0f; + for (auto w : GAUSS_TRI_WEIGHT) { + sum += w; + } + return sum > 0.999f && sum < 1.001f; + }(), + "Sum of Gaussian quadrature weights must be 1.0"); + +// Multi-point Gaussian quadrature sampling on a single face; returns weighted average color. +// GAUSS_TRI_WEIGHT sums to 1.0 (Hammer quadrature formula), no normalization needed. +static RGB sample_face_color(const std::array& uvs, const cv::Mat& texture) { + float r = 0.0f, g = 0.0f, b = 0.0f; + for (int k = 0; k < 7; ++k) { + float u = GAUSS_TRI_BARY[k][0] * uvs[0].x() + GAUSS_TRI_BARY[k][1] * uvs[1].x() + GAUSS_TRI_BARY[k][2] * uvs[2].x(); + float v = GAUSS_TRI_BARY[k][0] * uvs[0].y() + GAUSS_TRI_BARY[k][1] * uvs[1].y() + GAUSS_TRI_BARY[k][2] * uvs[2].y(); + RGB c = get_pixel_color(u, v, texture); + float w = GAUSS_TRI_WEIGHT[k]; + r += w * c[0]; + g += w * c[1]; + b += w * c[2]; + } + return RGB{static_cast(std::clamp(r, 0.0f, 255.0f)), static_cast(std::clamp(g, 0.0f, 255.0f)), + static_cast(std::clamp(b, 0.0f, 255.0f))}; +} + +// Use array instead of vector for UV storage to avoid per-face heap allocations at million-face scale +using FaceUVArray = std::array; + +static bool linear_subdivision(TriMesh& mesh, std::vector& uv_coords, const std::function& sub_progress = nullptr) { + const auto& original_vertices = mesh.vertices; + const auto& original_faces = mesh.indices; + TriVertices sub_vertices = mesh.vertices; + sub_vertices.reserve(original_vertices.size() + original_faces.size() * 3); + TriFaces sub_faces; + std::vector sub_uv_coords; + + // Single-level flat map with edge key encoding replaces nested unordered_map; + // merges two vertex indices into a single uint64_t to reduce hash lookups and indirection. + if (original_vertices.size() >= (1ULL << 32)) [[unlikely]] { + BOOST_LOG_TRIVIAL(warning) << "[boundary] " << __FUNCTION__ << " vertex_count=" << original_vertices.size() << " exceeds 32-bit edge_key encoding range, skipping subdivision"; + return false; + } + auto edge_key = [](std::size_t a, std::size_t b) -> uint64_t { + return a < b ? ((static_cast(a) << 32) | b) : ((static_cast(b) << 32) | a); + }; + std::unordered_map map_edge_to_sub_vtx; + map_edge_to_sub_vtx.reserve(original_faces.size() * 3 / 2); + + for (const auto& face : original_faces) { + for (std::size_t i = 0; i < 3; ++i) { + std::size_t vtx_1 = face[i]; + std::size_t vtx_2 = face[(i + 1) % 3]; + uint64_t key = edge_key(vtx_1, vtx_2); + if (map_edge_to_sub_vtx.count(key) > 0) { + continue; + } + TriVertex edge_vtx = (original_vertices[vtx_1] + original_vertices[vtx_2]) * 0.5; + map_edge_to_sub_vtx[key] = sub_vertices.size(); + sub_vertices.push_back(edge_vtx); + } + } + if (sub_progress) { + sub_progress(50); + } + + // Subdivide faces and their UVs: each original face splits into 4 sub-faces (parallel writes, no contention) + const std::size_t N = original_faces.size(); + sub_faces.resize(N * 4); + sub_uv_coords.resize(N * 4); + std::atomic has_missing_edge{false}; + + tbb::parallel_for(tbb::blocked_range(0, N), [&](const tbb::blocked_range& range) { + for (std::size_t fid = range.begin(); fid < range.end(); ++fid) { + const std::size_t base = fid * 4; + const auto& face = original_faces[fid]; + std::size_t vtx_0 = face[0]; + std::size_t vtx_1 = face[1]; + std::size_t vtx_2 = face[2]; + + auto it01 = map_edge_to_sub_vtx.find(edge_key(vtx_0, vtx_1)); + auto it12 = map_edge_to_sub_vtx.find(edge_key(vtx_1, vtx_2)); + auto it20 = map_edge_to_sub_vtx.find(edge_key(vtx_2, vtx_0)); + if (it01 == map_edge_to_sub_vtx.end() || it12 == map_edge_to_sub_vtx.end() || it20 == map_edge_to_sub_vtx.end()) [[unlikely]] { + has_missing_edge.store(true, std::memory_order_relaxed); + Vec3i32 degen(vtx_0, vtx_0, vtx_0); + FaceUVArray degen_uv = {uv_coords[fid][0], uv_coords[fid][0], uv_coords[fid][0]}; + for (int k = 0; k < 4; ++k) { + sub_faces[base + k] = degen; + sub_uv_coords[base + k] = degen_uv; + } + continue; + } + std::size_t e01 = it01->second; + std::size_t e12 = it12->second; + std::size_t e20 = it20->second; + + const Vec2f& uv0 = uv_coords[fid][0]; + const Vec2f& uv1 = uv_coords[fid][1]; + const Vec2f& uv2 = uv_coords[fid][2]; + Vec2f uv_e01 = (uv0 + uv1) * 0.5f; + Vec2f uv_e12 = (uv1 + uv2) * 0.5f; + Vec2f uv_e20 = (uv2 + uv0) * 0.5f; + + sub_faces[base + 0] = Vec3i32(vtx_0, e01, e20); + sub_uv_coords[base + 0] = {uv0, uv_e01, uv_e20}; + + sub_faces[base + 1] = Vec3i32(e01, vtx_1, e12); + sub_uv_coords[base + 1] = {uv_e01, uv1, uv_e12}; + + sub_faces[base + 2] = Vec3i32(e01, e12, e20); + sub_uv_coords[base + 2] = {uv_e01, uv_e12, uv_e20}; + + sub_faces[base + 3] = Vec3i32(e20, e12, vtx_2); + sub_uv_coords[base + 3] = {uv_e20, uv_e12, uv2}; + } + }); + // Remove degenerate triangles (three identical vertices) to avoid impacting downstream SDF / Remesh steps + if (has_missing_edge.load(std::memory_order_relaxed)) { + std::size_t write_idx = 0; + for (std::size_t i = 0; i < sub_faces.size(); ++i) { + if (sub_faces[i][0] == sub_faces[i][1] && sub_faces[i][1] == sub_faces[i][2]) { + continue; + } + if (write_idx != i) { + sub_faces[write_idx] = sub_faces[i]; + sub_uv_coords[write_idx] = sub_uv_coords[i]; + } + ++write_idx; + } + BOOST_LOG_TRIVIAL(warning) << "[warning] linear_subdivision has missing edge vertex, removed " << (sub_faces.size() - write_idx) << " degenerate triangles"; + sub_faces.resize(write_idx); + sub_uv_coords.resize(write_idx); + } + + if (sub_progress) { + sub_progress(100); + } + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: input faces count = " << mesh.indices.size() << "."; + mesh = TriMesh(sub_faces, sub_vertices); + uv_coords = std::move(sub_uv_coords); + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: output faces count = " << mesh.indices.size() << "."; + return true; +} + +using VertexColor = std::array; + +// Quantize continuous per-vertex colors into a small palette of cluster centers. +// The legacy OBJ vertex-color import consumed discrete filament ids, so split +// decisions could be made by comparing integers. Quantizing up front restores +// that property for the adaptive splitter below. +static bool quantize_vertex_colors( + const std::vector& vertex_colors, + const TextureToColorSettings& settings, + AlgoCancelCallback cancel_callback, + std::vector& out_centers, + std::vector& out_vertex_cluster_ids) +{ + out_centers.clear(); + out_vertex_cluster_ids.clear(); + if (vertex_colors.empty()) + return false; + + std::vector vertex_rgb(vertex_colors.size()); + for (std::size_t i = 0; i < vertex_colors.size(); ++i) { + for (int c = 0; c < 3; ++c) { + float v = std::clamp(vertex_colors[i][c] * 255.0f, 0.0f, 255.0f); + vertex_rgb[i][c] = static_cast(v); + } + } + + ClusterParameters para; + para.cancel_callback = cancel_callback ? [&]() { return cancel_callback(); } : std::function{}; + if (settings.target_colors_num == 0) { + para.max_color_distance = settings.max_color_distance; + para.max_cluster_k = settings.max_cluster_k; + out_centers = cluster_adaptive(vertex_rgb, para); + } else { + para.cluster_k = settings.target_colors_num; + out_centers = cluster_k_means(vertex_rgb, para); + } + if (out_centers.empty()) { + BOOST_LOG_TRIVIAL(debug) << "quantize_vertex_colors: no cluster center generated."; + return false; + } + + out_vertex_cluster_ids.resize(vertex_rgb.size()); + for (std::size_t i = 0; i < vertex_rgb.size(); ++i) { + std::size_t nearest_id = 0; + if (!calc_nearest_color_id(out_centers, vertex_rgb[i], nearest_id)) + nearest_id = 0; + out_vertex_cluster_ids[i] = nearest_id; + } + BOOST_LOG_TRIVIAL(debug) << "quantize_vertex_colors: quantized " << vertex_rgb.size() + << " vertex colors into " << out_centers.size() << " clusters."; + return true; +} + +// Single-level adaptive subdivision driven by per-vertex cluster ids. +// +// Reproduces the split topology that the legacy OBJ vertex-color import encoded +// into mmu_segmentation_facets (TriangleSelector::perform_split cases 1/2/3), but +// materializes it as real geometry. An edge is split at its midpoint if and only +// if its two endpoints belong to different clusters. Because that predicate reads +// only the shared endpoints, adjacent faces always reach the same conclusion and +// no T-junctions can appear. +static bool adaptive_split_by_vertex_clusters( + TriMesh& mesh, + const std::vector& vertex_cluster_ids, + const std::vector& cluster_centers, + std::vector& out_face_colors) +{ + const TriVertices original_vertices = mesh.vertices; + const TriFaces original_faces = mesh.indices; + if (original_vertices.empty() || original_faces.empty() || cluster_centers.empty()) + return false; + if (vertex_cluster_ids.size() != original_vertices.size()) { + BOOST_LOG_TRIVIAL(warning) << "adaptive_split_by_vertex_clusters: cluster id count (" + << vertex_cluster_ids.size() << ") != vertex count (" + << original_vertices.size() << ")."; + return false; + } + if (original_vertices.size() >= (1ULL << 32)) [[unlikely]] { + BOOST_LOG_TRIVIAL(warning) << "adaptive_split_by_vertex_clusters: vertex_count=" + << original_vertices.size() << " exceeds 32-bit edge_key range."; + return false; + } + + TriVertices out_vertices = original_vertices; + TriFaces out_faces; + out_faces.reserve(original_faces.size() * 5); + out_face_colors.clear(); + out_face_colors.reserve(original_faces.size() * 5); + + auto edge_key = [](std::size_t a, std::size_t b) -> uint64_t { + return a < b ? ((static_cast(a) << 32) | b) + : ((static_cast(b) << 32) | a); + }; + std::unordered_map edge_to_mid; + edge_to_mid.reserve(original_faces.size() * 3 / 2); + + // Midpoints on shared edges must be deduplicated so that neighbouring faces + // reference the same vertex instead of coincident duplicates. + auto midpoint_of_edge = [&](std::size_t a, std::size_t b) -> std::size_t { + const uint64_t key = edge_key(a, b); + auto it = edge_to_mid.find(key); + if (it != edge_to_mid.end()) + return it->second; + const std::size_t idx = out_vertices.size(); + out_vertices.push_back((original_vertices[a] + original_vertices[b]) * 0.5f); + edge_to_mid.emplace(key, idx); + return idx; + }; + // Points strictly inside an original face are never shared, so they skip the map. + // The midpoint is computed before push_back so a reallocation cannot dangle it. + auto append_interior_midpoint = [&](std::size_t a, std::size_t b) -> std::size_t { + const TriVertex mid = (out_vertices[a] + out_vertices[b]) * 0.5f; + const std::size_t idx = out_vertices.size(); + out_vertices.push_back(mid); + return idx; + }; + auto emit = [&](std::size_t a, std::size_t b, std::size_t c, std::size_t cluster_id) { + out_faces.push_back(Vec3i32(static_cast(a), static_cast(b), static_cast(c))); + out_face_colors.push_back(cluster_centers[cluster_id]); + }; + + for (const auto& f : original_faces) { + const std::size_t v[3] = {static_cast(f[0]), static_cast(f[1]), static_cast(f[2])}; + const std::size_t c[3] = {vertex_cluster_ids[v[0]], vertex_cluster_ids[v[1]], vertex_cluster_ids[v[2]]}; + + // Case A: uniform cluster, keep the face untouched. + if (c[0] == c[1] && c[1] == c[2]) { + emit(v[0], v[1], v[2], c[0]); + continue; + } + + // Case B: two vertices share a cluster and the third is isolated. Split the + // two edges incident to the isolated vertex, which are exactly the + // cross-cluster ones; the opposite edge stays intact. + int iso = -1; + if (c[1] == c[2]) iso = 0; + else if (c[2] == c[0]) iso = 1; + else if (c[0] == c[1]) iso = 2; + if (iso >= 0) { + const int i = iso, j = (iso + 1) % 3, k = (iso + 2) % 3; + const std::size_t m_ij = midpoint_of_edge(v[i], v[j]); + const std::size_t m_ki = midpoint_of_edge(v[k], v[i]); + emit(v[i], m_ij, m_ki, c[i]); + emit(m_ij, v[j], m_ki, c[j]); + emit(v[j], v[k], m_ki, c[j]); + continue; + } + + // Case C: all three clusters differ. Split every edge, then cut the centre + // triangle once more. The centre is equidistant from all three clusters, so + // the legacy heuristic selects the cut by widest interior angle, which is + // the vertex opposite the longest edge. + const std::size_t m01 = midpoint_of_edge(v[0], v[1]); + const std::size_t m12 = midpoint_of_edge(v[1], v[2]); + const std::size_t m20 = midpoint_of_edge(v[2], v[0]); + emit(v[0], m01, m20, c[0]); + emit(m01, v[1], m12, c[1]); + emit(m12, v[2], m20, c[2]); + + const TriVertex& p0 = original_vertices[v[0]]; + const TriVertex& p1 = original_vertices[v[1]]; + const TriVertex& p2 = original_vertices[v[2]]; + const float sq_opposite_v0 = (p2 - p1).squaredNorm(); + const float sq_opposite_v1 = (p0 - p2).squaredNorm(); + const float sq_opposite_v2 = (p1 - p0).squaredNorm(); + int widest = 0; + float widest_len = sq_opposite_v0; + if (sq_opposite_v1 > widest_len) { widest = 1; widest_len = sq_opposite_v1; } + if (sq_opposite_v2 > widest_len) { widest = 2; } + + if (widest == 0) { + const std::size_t mc = append_interior_midpoint(m20, m01); + emit(m12, m20, mc, c[1]); + emit(mc, m01, m12, c[2]); + } else if (widest == 1) { + const std::size_t mc = append_interior_midpoint(m01, m12); + emit(m20, m01, mc, c[0]); + emit(mc, m12, m20, c[2]); + } else { + const std::size_t mc = append_interior_midpoint(m12, m20); + emit(m01, m12, mc, c[1]); + emit(mc, m20, m01, c[0]); + } + } + + BOOST_LOG_TRIVIAL(info) << "adaptive_split_by_vertex_clusters: faces " << original_faces.size() + << " -> " << out_faces.size() << ", vertices " << original_vertices.size() + << " -> " << out_vertices.size(); + mesh = TriMesh(out_faces, out_vertices); + return true; +} + +// Shared pipeline: mesh repair -> color clustering -> label assignment -> smoothing. +// Called by both TextureToColor (after UV sampling) and ClusterAndSmooth (after vertex-color oversample). +// progress_callback reports 0~100 within this function; the caller maps it to its own global range. +static bool repair_cluster_smooth( + TriMesh& mesh, + std::vector& face_colors, + std::vector& out_clustered_face_colors, + const TextureToColorSettings& settings, + AlgoProgressCallback progress_callback, + AlgoCancelCallback cancel_callback, + const char* log_prefix) +{ + auto report = [&](int pct, const char* msg) { + if (progress_callback) + progress_callback({pct, msg}); + }; + auto cancelled = [&]() -> bool { + if (cancel_callback && cancel_callback()) { + BOOST_LOG_TRIVIAL(debug) << log_prefix << " cancelled"; + return true; + } + return false; + }; + + report(0, "Repairing mesh"); + if (cancelled()) return false; + + // Resample face colors onto a repaired mesh via centroid nearest-neighbor. + auto resample_face_colors = [&](TriMesh&& repaired_mesh) -> bool { + TriVertices old_vertices = std::move(mesh.vertices); + TriFaces old_indices = std::move(mesh.indices); + auto aabb_tree = AABBTreeIndirect::build_aabb_tree_over_indexed_triangle_set(old_vertices, old_indices); + mesh = std::move(repaired_mesh); + + if (is_closed(mesh)) { + BOOST_LOG_TRIVIAL(debug) << log_prefix << ": repaired mesh is closed."; + } else { + BOOST_LOG_TRIVIAL(debug) << log_prefix << ": repaired mesh is open."; + } + + std::vector new_face_colors(mesh.facets_count()); + tbb::parallel_for(tbb::blocked_range(0, mesh.facets_count()), [&](const tbb::blocked_range& range) { + for (std::size_t fid = range.begin(); fid < range.end(); ++fid) { + const auto& face = mesh.indices[fid]; + Vec3f center = (mesh.vertices[face[0]] + mesh.vertices[face[1]] + mesh.vertices[face[2]]) / 3.0f; + size_t hit_idx = 0; + Vec3f closest; + AABBTreeIndirect::squared_distance_to_indexed_triangle_set( + old_vertices, old_indices, aabb_tree, center, hit_idx, closest); + new_face_colors[fid] = face_colors[hit_idx]; + } + }); + face_colors = std::move(new_face_colors); + return true; + }; + + auto repair_and_resample = [&]() -> bool { + std::shared_ptr repaired_mesh; + if (!RepairMesh(mesh, repaired_mesh)) { + BOOST_LOG_TRIVIAL(debug) << log_prefix << ": RepairMesh failed."; + return false; + } + if (cancelled()) return false; + return resample_face_colors(std::move(*repaired_mesh)); + }; + + { + TriangleMesh stats_mesh(static_cast(mesh)); + const auto& stats = stats_mesh.stats(); + // Orca's TriangleMeshStats only counts open edges: manifold() is open_edges == 0, and + // there are no separate non-manifold edge/vertex counters to test or log here. + if (!stats.manifold()) { + BOOST_LOG_TRIVIAL(info) << log_prefix << ": mesh has non-manifold geometry or open boundaries, open_edges=" + << stats.open_edges; + if (settings.mesh_repair_decision == MeshRepairDecision::Ask) { + if (settings.mesh_repair_decision_required) + *settings.mesh_repair_decision_required = true; + return false; + } + if (settings.mesh_repair_decision == MeshRepairDecision::RepairAndImport) { + indexed_triangle_set repaired_its; + std::string repair_error; + bool repaired = settings.mesh_repair_callback && settings.mesh_repair_callback( + static_cast(mesh), repaired_its, + [&](const char* message, unsigned /*percent*/) { + report(5, message ? message : "Repairing mesh"); + }, + [&]() { return cancelled(); }, &repair_error); + if (repaired) { + if (cancelled()) return false; + BOOST_LOG_TRIVIAL(info) << log_prefix << ": Windows 3D mesh repair finished."; + if (!resample_face_colors(TriMesh(std::move(repaired_its)))) + return false; + } else { + BOOST_LOG_TRIVIAL(warning) << log_prefix << ": Windows 3D mesh repair failed: " << repair_error; + } + } else { + BOOST_LOG_TRIVIAL(info) << log_prefix << ": importing mesh without Windows 3D repair."; + } + } + } + + if (!cgalutils::is_mesh_halfedge_compatible(mesh)) { + BOOST_LOG_TRIVIAL(info) << log_prefix << ": mesh not halfedge-compatible, attempting RepairMesh."; + if (!repair_and_resample()) + return false; + } + +#ifdef OUTPUT_TEST_RESULT + SaveToOFF(std::string(log_prefix) + "_1_repair.off", mesh, face_colors); +#endif + + report(20, "Color clustering"); + if (cancelled()) return false; + + // Clustering + std::vector cluster_centers; + out_clustered_face_colors = face_colors; + std::vector clustered_face_labels(face_colors.size()); + const bool adaptive_cluster = settings.target_colors_num == 0; + + if (adaptive_cluster) { + BOOST_LOG_TRIVIAL(debug) << log_prefix << ": use cluster adaptive method."; + ClusterParameters para; + para.max_color_distance = settings.max_color_distance; + para.max_cluster_k = settings.max_cluster_k; + para.cancel_callback = cancel_callback ? [&]() { return cancel_callback(); } : std::function{}; + cluster_centers = cluster_adaptive(face_colors, para); + if (cancelled()) return false; + } else { + BOOST_LOG_TRIVIAL(debug) << log_prefix << ": use cluster k-means method."; + ClusterParameters para; + para.cluster_k = settings.target_colors_num; + para.cancel_callback = cancel_callback ? [&]() { return cancel_callback(); } : std::function{}; + cluster_centers = cluster_k_means(face_colors, para); + if (cancelled()) return false; + } + + BOOST_LOG_TRIVIAL(debug) << log_prefix << ": k = " << cluster_centers.size() << "."; + if (cluster_centers.empty()) { + BOOST_LOG_TRIVIAL(debug) << log_prefix << ": no cluster center generated."; + return false; + } + + report(40, "Assigning cluster labels"); + if (cancelled()) return false; + + // Assign each face to nearest cluster center + { + std::atomic done{0}; + std::atomic cancel_requested{false}; + const size_t total = mesh.indices.size(); + const size_t interval = std::max(total / 20, 1); + tbb::parallel_for(tbb::blocked_range(0, total), [&](const tbb::blocked_range& range) { + for (std::size_t fid = range.begin(); fid < range.end(); ++fid) { + if (cancel_requested.load(std::memory_order_relaxed)) return; + std::size_t nearest_id = 0; + calc_nearest_color_id(cluster_centers, face_colors[fid], nearest_id); + clustered_face_labels[fid] = nearest_id; + out_clustered_face_colors[fid] = cluster_centers[nearest_id]; + size_t cnt = done.fetch_add(1, std::memory_order_relaxed) + 1; + if (cnt % interval == 0) { + if (cancelled()) { cancel_requested.store(true, std::memory_order_relaxed); return; } + } + } + }); + if (cancel_requested.load() || cancelled()) return false; + } + if (adaptive_cluster) { + if (!discard_unused_cluster_centers(cluster_centers, clustered_face_labels, "cluster assignment")) + return false; + } else { + ensure_all_cluster_centers_used(face_colors, cluster_centers, clustered_face_labels, "cluster assignment"); + } + +#ifdef OUTPUT_TEST_RESULT + { + std::vector tmp = out_clustered_face_colors; + for (std::size_t i = 0; i < tmp.size(); ++i) + tmp[i] = cluster_centers[clustered_face_labels[i]]; + SaveToOFF(std::string(log_prefix) + "_3_cluster.off", mesh, tmp); + } +#endif + + report(65, "Smoothing colors"); + if (cancelled()) return false; + + SmoothParameters smooth_parameters; + smooth_parameters.smooth_weight = settings.smooth_weight; + if (!smooth_region(mesh, clustered_face_labels, smooth_parameters)) { + BOOST_LOG_TRIVIAL(debug) << log_prefix << ": smooth region failed."; + return false; + } + if (adaptive_cluster) { + if (!discard_unused_cluster_centers(cluster_centers, clustered_face_labels, "color smoothing")) + return false; + } else { + ensure_all_cluster_centers_used(face_colors, cluster_centers, clustered_face_labels, "color smoothing"); + } + + report(90, "Updating face colors"); + if (cancelled()) return false; + + for (std::size_t i = 0; i < out_clustered_face_colors.size(); ++i) + out_clustered_face_colors[i] = cluster_centers[clustered_face_labels[i]]; + +#ifdef OUTPUT_TEST_RESULT + SaveToOFF(std::string(log_prefix) + "_4_smooth.off", mesh, out_clustered_face_colors); +#endif + + report(100, "Completed"); + return true; +} + +bool TextureToColor(const TriMesh& texture_mesh, const std::vector>& texture_mesh_uv_coords, const cv::Mat& texture, TriMesh& color_mesh, + std::vector>& face_colors, const TextureToColorSettings& settings, AlgoProgressCallback progress_callback, + AlgoCancelCallback cancel_callback) { + auto report = [&](int pct, const char* msg) { + if (progress_callback) { + progress_callback({pct, msg}); + } + }; + auto sub_report = [&](int sub_pct, int range_start, int range_end, const char* msg) { + int pct = range_start + sub_pct * (range_end - range_start) / 100; + report(pct, msg); + }; + auto cancelled = [&]() -> bool { + if (cancel_callback && cancel_callback()) { + BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << " cancelled"; + return true; + } + return false; + }; + + color_mesh.clear(); + face_colors.clear(); + + report(0, "Initializing"); + if (cancelled()) { + return false; + } + + if (texture_mesh.indices.size() == 0) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: texture mesh has no faces."; + return false; + } + if (texture.empty()) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: texture is empty."; + return false; + } + if (texture.channels() < 3) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: texture must have at least 3 channels, got " << texture.channels(); + return false; + } + if (texture_mesh_uv_coords.size() != texture_mesh.indices.size()) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: uv_coords size is not equal to texture mesh faces size."; + return false; + } + for (std::size_t fid = 0; fid < texture_mesh.indices.size(); ++fid) { + if (texture_mesh_uv_coords[fid].size() != 3) { + BOOST_LOG_TRIVIAL(debug) << "TextureToColor: uv_coords of single face size is not equal to 3."; + return false; + } + } + color_mesh = texture_mesh; + + using Clock = std::chrono::high_resolution_clock; + const auto t_total_start = Clock::now(); + auto t_step = t_total_start; + auto lap = [&](const char* step_name) { + auto now = Clock::now(); + double ms = std::chrono::duration(now - t_step).count(); + BOOST_LOG_TRIVIAL(debug) << "[timing] " << step_name << ": " << ms << "ms" + << " faces=" << color_mesh.facets_count(); + t_step = now; + }; + + report(5, "Oversampling"); + if (cancelled()) { + return false; + } + + // Step 1: Oversampling (subdivision while propagating UVs) + // Convert external vector> to internal vector> to eliminate inner-level heap allocations + std::vector color_mesh_uv_coords(texture_mesh_uv_coords.size()); + for (std::size_t i = 0; i < texture_mesh_uv_coords.size(); ++i) { + color_mesh_uv_coords[i] = {texture_mesh_uv_coords[i][0], texture_mesh_uv_coords[i][1], texture_mesh_uv_coords[i][2]}; + } + { + // Estimate total iterations and map each iteration's sub-progress to the [5, 25] range + size_t estimated_iters = 0; + if (settings.oversampling_iters > 0) { + estimated_iters = settings.oversampling_iters; + } else { + size_t fc = color_mesh.facets_count(); + while (fc < settings.oversampling_min_face_count) { + fc *= 4; + ++estimated_iters; + } + if (estimated_iters == 0) { + estimated_iters = 1; + } + } + + auto make_iter_progress = [&](size_t iter) { + return [&, iter, estimated_iters](int pct) { + int iter_start = static_cast(iter * 100 / estimated_iters); + int iter_end = static_cast((iter + 1) * 100 / estimated_iters); + int sub_pct = iter_start + pct * (iter_end - iter_start) / 100; + sub_report(sub_pct, 5, 25, "Oversampling"); + }; + }; + + if (settings.oversampling_iters > 0) { + for (size_t i = 0; i < settings.oversampling_iters && color_mesh.facets_count() * 4.0 < settings.oversampling_max_face_count; ++i) { + if (cancelled()) return false; + linear_subdivision(color_mesh, color_mesh_uv_coords, make_iter_progress(i)); + } + } else { + size_t iter = 0; + while (color_mesh.facets_count() < settings.oversampling_min_face_count) { + if (cancelled()) return false; + linear_subdivision(color_mesh, color_mesh_uv_coords, make_iter_progress(iter++)); + } + } + } + + lap("Oversampling"); + + face_colors.resize(color_mesh.indices.size()); + + report(25, "Computing face colors"); + if (cancelled()) { + return false; + } + + // Step 2: Compute each face's color (7-point Gaussian quadrature + bilinear interpolation sampling) + { + std::atomic done_faces{0}; + std::atomic cancel_requested{false}; + const size_t total_faces = color_mesh.indices.size(); + const size_t report_interval = std::max(total_faces / 20, 1); + tbb::parallel_for(tbb::blocked_range(0, total_faces), [&](const tbb::blocked_range& range) { + for (std::size_t fid = range.begin(); fid < range.end(); ++fid) { + if (cancel_requested.load(std::memory_order_relaxed)) return; + face_colors[fid] = sample_face_color(color_mesh_uv_coords[fid], texture); + size_t cnt = done_faces.fetch_add(1, std::memory_order_relaxed) + 1; + if (cnt % report_interval == 0) { + if (cancelled()) { cancel_requested.store(true, std::memory_order_relaxed); return; } + sub_report(static_cast(cnt * 100 / total_faces), 25, 40, "Computing face colors"); + } + } + }); + if (cancel_requested.load() || cancelled()) return false; + } + lap("Computing face colors"); +#ifdef OUTPUT_TEST_RESULT + SaveToOFF("texture_to_color_0_initialize.off", color_mesh, face_colors); +#endif + + // Map progress from repair_cluster_smooth's [0,100] to TextureToColor's [40,100] + AlgoProgressCallback rcs_progress = nullptr; + if (progress_callback) { + rcs_progress = [&](AlgoProgress p) { + int mapped_pct = 40 + p.percent * 60 / 100; + progress_callback({mapped_pct, p.message}); + }; + } + + std::vector clustered_face_colors; + if (!repair_cluster_smooth(color_mesh, face_colors, clustered_face_colors, + settings, rcs_progress, cancel_callback, "TextureToColor")) + return false; + + face_colors = std::move(clustered_face_colors); + lap("Repair + Clustering + Smoothing"); + double total_ms = std::chrono::duration(Clock::now() - t_total_start).count(); + BOOST_LOG_TRIVIAL(debug) << "[timing] TextureToColor total: " << total_ms << "ms" + << " faces=" << color_mesh.facets_count(); + report(100, "Completed"); + return true; +} + +bool ClusterAndSmooth(const TriMesh& mesh, + const std::vector>& input_face_colors, + TriMesh& out_mesh, + std::vector>& out_face_colors, + const TextureToColorSettings& settings, + AlgoProgressCallback progress_callback, + AlgoCancelCallback cancel_callback, + const std::vector>& vertex_colors) +{ + auto report = [&](int pct, const char* msg) { + if (progress_callback) + progress_callback({pct, msg}); + }; + auto cancelled = [&]() -> bool { + if (cancel_callback && cancel_callback()) { + BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << " cancelled"; + return true; + } + return false; + }; + + out_mesh = mesh; + out_face_colors.clear(); + + if (mesh.indices.empty() || input_face_colors.empty()) { + BOOST_LOG_TRIVIAL(debug) << "ClusterAndSmooth: empty mesh or face colors."; + return false; + } + if (input_face_colors.size() != mesh.indices.size()) { + BOOST_LOG_TRIVIAL(warning) << "ClusterAndSmooth: face_colors size (" + << input_face_colors.size() << ") != indices size (" + << mesh.indices.size() << "), clamping."; + } + + report(0, "Initializing"); + if (cancelled()) return false; + + // Prepare face colors aligned to mesh size + std::vector face_colors(out_mesh.indices.size()); + for (size_t i = 0; i < out_mesh.indices.size(); ++i) { + if (i < input_face_colors.size()) + face_colors[i] = input_face_colors[i]; + else + face_colors[i] = {128, 128, 128}; + } + + // Low-poly vertex-color meshes take the legacy OBJ import route: quantize the + // vertex colors, then split only across cluster boundaries. Colors are exact + // cluster centers afterwards, so repair / re-clustering / smoothing are skipped + // to match the legacy behaviour, which never touched the mesh either. + // A vertex color count that disagrees with the mesh falls through to the generic + // pipeline below rather than failing the import outright. + if (!vertex_colors.empty() && + vertex_colors.size() == out_mesh.vertices.size() && + out_mesh.facets_count() < settings.oversampling_min_face_count) { + report(10, "Quantizing vertex colors"); + std::vector cluster_centers; + std::vector vertex_cluster_ids; + if (!quantize_vertex_colors(vertex_colors, settings, cancel_callback, cluster_centers, vertex_cluster_ids)) { + BOOST_LOG_TRIVIAL(debug) << "ClusterAndSmooth: vertex color quantization failed."; + return false; + } + if (cancelled()) return false; + + report(50, "Splitting color boundaries"); + if (!adaptive_split_by_vertex_clusters(out_mesh, vertex_cluster_ids, cluster_centers, face_colors)) { + BOOST_LOG_TRIVIAL(debug) << "ClusterAndSmooth: adaptive vertex-color split failed."; + return false; + } + if (cancelled()) return false; + + out_face_colors = std::move(face_colors); + report(100, "Completed"); + return true; + } + + std::vector clustered_face_colors; + if (!repair_cluster_smooth(out_mesh, face_colors, clustered_face_colors, + settings, progress_callback, cancel_callback, + "ClusterAndSmooth")) + return false; + + out_face_colors = std::move(clustered_face_colors); + return true; +} + +} // namespace tex2color +} // namespace Slic3r diff --git a/src/libslic3r/TextureToColor/TextureToColor.hpp b/src/libslic3r/TextureToColor/TextureToColor.hpp new file mode 100644 index 0000000000..019a113fc4 --- /dev/null +++ b/src/libslic3r/TextureToColor/TextureToColor.hpp @@ -0,0 +1,103 @@ +#pragma once + +#include "Callbacks.hpp" +#include "TriMesh.hpp" +#include "opencv2/core.hpp" +#include +#include + +namespace Slic3r { namespace tex2color { + +enum class MeshRepairDecision { + Ask, + ImportWithoutRepair, + RepairAndImport +}; + +using MeshRepairCallback = std::function progress_callback, + std::function cancel_callback, + std::string* error_message)>; + +struct TextureToColorSettings { + std::size_t target_colors_num = 4; // 目标颜色数量, 为0时, 自适应计算; 否则计算指定数目的颜色聚类 + + double smooth_weight = 0.5; // 光顺权重, 范围[0, 1], 0表示不进行光顺, 1表示完全光顺 + + // 当超采样迭代次数大于0时, 进行指定迭代次数的超采样; 否则, 自适应超采样 + std::size_t oversampling_iters = 0; // 超采样迭代次数 + std::size_t oversampling_min_face_count = 10000; // 自适应采样: 当face_count小于oversampling_min_face_count时, 进行超采样 + std::size_t oversampling_max_face_count = 1000000; // 无论输入参数如何, 超采样后的面片数不能超过oversampling_max_face_count + + double max_color_distance = 25.0; // 自适应聚类允许的最大簇内半径(CIEDE2000 ΔE) + std::size_t max_cluster_k = 32; // 自适应聚类的最大颜色数量上限 + + MeshRepairDecision mesh_repair_decision = MeshRepairDecision::ImportWithoutRepair; + + // Set by TextureToColor when Ask is selected and mesh repair needs user confirmation. + bool* mesh_repair_decision_required = nullptr; + + MeshRepairCallback mesh_repair_callback; +}; + +/** + * @brief 将纹理贴图转换为网格面片颜色, 并通过聚类和光顺生成可用于多色打印的着色网格 + * + * 基于纹理网格的UV坐标对纹理图像进行采样, 计算每个面片的颜色, + * 然后对颜色进行聚类(K-Means或自适应)和区域光顺, 最终输出带颜色信息的网格 + * + * @param[in] texture_mesh 带有UV坐标的输入三角网格 + * @param[in] uv_coords 每个面片的UV坐标, 大小等于面片数, 每个面片有三个UV坐标 + * @param[in] texture 纹理图像 + * @param[out] color_mesh 输出的着色网格 + * @param[out] face_colors 输出的着色网格的面片颜色, 大小等于面片数, 颜色值为[R, G, B], 范围0~255 + * @param[in] settings 算法参数, 包括目标颜色数量、光顺权重等 + * @param[in] progress_callback 进度回调函数 + * @param[in] cancel_callback 取消回调函数 + * @return 成功返回true, 输入数据无效(空网格、无UV、空纹理等)返回false + */ +bool TextureToColor(const TriMesh& texture_mesh, const std::vector>& uv_coords, const cv::Mat& texture, TriMesh& color_mesh, + std::vector>& face_colors, const TextureToColorSettings& settings = TextureToColorSettings(), + AlgoProgressCallback progress_callback = nullptr, AlgoCancelCallback cancel_callback = nullptr); + +/** + * @brief Turn pre-computed per-face colors into a clustered color mesh (no texture/UV). + * + * Used for OBJ vertex colors and MTL face colors, which bypass texture sampling. + * Two routes are possible: + * - Low-poly meshes carrying per-vertex colors: the vertex colors are quantized + * into a small palette and the mesh is geometrically split along cluster + * boundaries, reproducing the split topology of the legacy OBJ vertex-color + * import. Output colors are then exact cluster centers, so mesh repair, + * re-clustering and smoothing are skipped. + * - Everything else: mesh repair, color clustering (K-Means or adaptive) and + * region smoothing, sharing the same pipeline as TextureToColor. + * + * @param[in] mesh Input triangle mesh + * @param[in] input_face_colors Pre-computed per-face RGB colors [0..255] + * @param[out] out_mesh Output mesh. Geometry is subdivided on the + * vertex-color route, and may still be replaced + * by mesh repair on the generic route. + * @param[out] out_face_colors Output per-face colors, one entry per out_mesh face + * @param[in] settings Algorithm parameters (target_colors_num, smooth_weight; + * oversampling_min_face_count doubles as the low-poly + * threshold for the vertex-color route) + * @param[in] progress_callback Progress callback + * @param[in] cancel_callback Cancel callback + * @param[in] vertex_colors Optional per-vertex RGBA [0..1]. Must match + * mesh.vertices in size to enable the vertex-color + * route; otherwise it is ignored. + * @return true on success, false on failure or cancellation + */ +bool ClusterAndSmooth(const TriMesh& mesh, + const std::vector>& input_face_colors, + TriMesh& out_mesh, + std::vector>& out_face_colors, + const TextureToColorSettings& settings = TextureToColorSettings(), + AlgoProgressCallback progress_callback = nullptr, + AlgoCancelCallback cancel_callback = nullptr, + const std::vector>& vertex_colors = {}); + +} // namespace tex2color +} // namespace Slic3r diff --git a/src/libslic3r/TextureToColor/TriMesh.hpp b/src/libslic3r/TextureToColor/TriMesh.hpp new file mode 100644 index 0000000000..d557ae1d2e --- /dev/null +++ b/src/libslic3r/TextureToColor/TriMesh.hpp @@ -0,0 +1,28 @@ +#pragma once +#include +#include "Point.hpp" + +namespace Slic3r { namespace tex2color { + +using TriVertex = stl_vertex; +using TriVertices = std::vector; +using TriFace = stl_triangle_vertex_indices; +using TriFaces = std::vector; + +struct TriMesh : ::indexed_triangle_set { + TriMesh() = default; + TriMesh(const TriMesh&) = default; + TriMesh& operator=(const TriMesh&) = default; + TriMesh(TriMesh&&) = default; + TriMesh& operator=(TriMesh&&) = default; + TriMesh(const ::indexed_triangle_set& d) : ::indexed_triangle_set(d) {} + TriMesh(::indexed_triangle_set&& d) : ::indexed_triangle_set(std::move(d)) {} + TriMesh(std::vector indices_, + std::vector vertices_) + : ::indexed_triangle_set(std::move(indices_), std::move(vertices_)) {} + + std::size_t facets_count() const { return indices.size(); } +}; + +} // namespace tex2color +} // namespace Slic3r diff --git a/src/libslic3r/TriangleMeshSlicer.cpp b/src/libslic3r/TriangleMeshSlicer.cpp index c403a6bd92..417ca354d3 100644 --- a/src/libslic3r/TriangleMeshSlicer.cpp +++ b/src/libslic3r/TriangleMeshSlicer.cpp @@ -146,6 +146,85 @@ public: using IntersectionLines = std::vector; +// Orca: A planar face is commonly represented by multiple triangles. A slicing plane then crosses +// their shared edges and creates intermediate 2D points which are not part of the model contour. +// Track only edges whose two incident triangles lie in the same geometric plane within the slicing +// coordinate precision, so those artificial junctions can be omitted without simplifying genuine, +// nearly-collinear geometry. +using CoplanarEdges = std::vector; + +static CoplanarEdges coplanar_edges(const indexed_triangle_set &mesh, const std::vector &face_edge_ids, + const Transform3d &trafo) +{ + struct FacePlane { + Vec3d origin { Vec3d::Zero() }; + Vec3d normal { Vec3d::Zero() }; + bool valid { false }; + }; + + // Orca: Edge IDs are dense but may include boundary edges referenced by just one face. + int num_edges = 0; + for (const Vec3i32 &edge_ids : face_edge_ids) + num_edges = std::max(num_edges, edge_ids.maxCoeff() + 1); + + CoplanarEdges coplanar(num_edges, false); + std::vector first_face(num_edges, -1); + std::vector first_face_edge(num_edges, -1); + std::vector face_planes(face_edge_ids.size()); + std::vector face_plane_computed(face_edge_ids.size(), false); + auto transformed_vertex = [&mesh, &trafo](int vertex_idx) { + return trafo * mesh.vertices[vertex_idx].cast(); + }; + // Orca: Compute planes lazily. The single-plane slicer masks most faces, so eagerly calculating + // every plane would defeat part of that optimization. + auto face_plane = [&mesh, &face_planes, &face_plane_computed, &transformed_vertex](int face_idx) -> const FacePlane& { + if (! face_plane_computed[face_idx]) { + const Vec3i32 &face = mesh.indices[face_idx]; + const Vec3d a = transformed_vertex(face(0)); + const Vec3d b = transformed_vertex(face(1)); + const Vec3d c = transformed_vertex(face(2)); + FacePlane &plane = face_planes[face_idx]; + plane.origin = a; + plane.normal = (b - a).cross(c - a); + const double normal_length = plane.normal.norm(); + if (normal_length > 0.) { + plane.normal /= normal_length; + plane.valid = true; + } + face_plane_computed[face_idx] = true; + } + return face_planes[face_idx]; + }; + const double plane_distance_tolerance = SCALING_FACTOR; + for (int face_idx = 0; face_idx < int(face_edge_ids.size()); ++ face_idx) { + for (int edge_idx = 0; edge_idx < 3; ++ edge_idx) { + const int edge_id = face_edge_ids[face_idx](edge_idx); + if (edge_id < 0) + continue; + if (first_face[edge_id] == -1) { + first_face[edge_id] = face_idx; + first_face_edge[edge_id] = edge_idx; + } else { + const int first_face_idx = first_face[edge_id]; + const FacePlane &first_plane = face_plane(first_face_idx); + const FacePlane &second_plane = face_plane(face_idx); + const int first_opposite_idx = mesh.indices[first_face_idx]((first_face_edge[edge_id] + 2) % 3); + const int second_opposite_idx = mesh.indices[face_idx]((edge_idx + 2) % 3); + const Vec3d first_opposite = transformed_vertex(first_opposite_idx); + const Vec3d second_opposite = transformed_vertex(second_opposite_idx); + // Orca: A shared edge guarantees that the planes intersect, but not that they coincide. + // Check both opposite vertices against the neighboring plane using one coord_t as the + // distance tolerance. The normal dot product only preserves face orientation; it does + // not classify a shallow angle as coplanar (see #15364). + coplanar[edge_id] = first_plane.valid && second_plane.valid && first_plane.normal.dot(second_plane.normal) > 0. && + std::abs(first_plane.normal.dot(second_opposite - first_plane.origin)) <= plane_distance_tolerance && + std::abs(second_plane.normal.dot(first_opposite - second_plane.origin)) <= plane_distance_tolerance; + } + } + } + return coplanar; +} + enum class FacetSliceType { NoSlice = 0, Slicing = 1, @@ -1057,7 +1136,8 @@ struct OpenPolyline { // called by make_loops() to connect sliced triangles into closed loops and open polylines by the triangle connectivity. // Only connects segments crossing triangles of the same orientation. -static void chain_lines_by_triangle_connectivity(IntersectionLines &lines, Polygons &loops, std::vector &open_polylines) +static void chain_lines_by_triangle_connectivity(IntersectionLines &lines, const CoplanarEdges &coplanar_edges, + Polygons &loops, std::vector &open_polylines) { // Build a map of lines by edge_a_id and a_id. std::vector by_edge_a_id; @@ -1134,6 +1214,11 @@ static void chain_lines_by_triangle_connectivity(IntersectionLines &lines, Polyg (first_line->a_id != -1 && first_line->a_id == last_line->b_id)) { // The current loop is complete. Add it to the output. assert(first_line->a == last_line->b); + // Orca: The seed point is also a triangle junction. Handle it explicitly because it + // is never visited through the next_line branch below when the loop closes. + if (first_line->edge_a_id >= 0 && first_line->edge_a_id < int(coplanar_edges.size()) && + coplanar_edges[first_line->edge_a_id]) + loop_pts.erase(loop_pts.begin()); loops.emplace_back(std::move(loop_pts)); #ifdef SLIC3R_TRIANGLEMESH_DEBUG printf(" Discovered %s polygon of %d points\n", (p.is_counter_clockwise() ? "ccw" : "cw"), (int)p.points.size()); @@ -1153,7 +1238,12 @@ static void chain_lines_by_triangle_connectivity(IntersectionLines &lines, Polyg next_line->a.x, next_line->a.y, next_line->b.x, next_line->b.y); */ assert(last_line->b == next_line->a); - loop_pts.emplace_back(next_line->a); + // Orca: Skip only junctions introduced by triangulating one planar face. Unlike a generic + // collinearity cleanup, this preserves intentional shallow corners used when comparing + // adjacent layers for bridges and overhang perimeters (see #15364). + if (next_line->edge_a_id < 0 || next_line->edge_a_id >= int(coplanar_edges.size()) || + ! coplanar_edges[next_line->edge_a_id]) + loop_pts.emplace_back(next_line->a); last_line = next_line; next_line->set_skip(); } @@ -1382,7 +1472,8 @@ static void chain_open_polylines_close_gaps(std::vector &open_poly static Polygons make_loops( // Lines will have their flags modified. - IntersectionLines &lines) + IntersectionLines &lines, + const CoplanarEdges &coplanar_edges) { Polygons loops; #if 0 @@ -1412,7 +1503,7 @@ static Polygons make_loops( #endif /* SLIC3R_DEBUG_SLICE_PROCESSING */ std::vector open_polylines; - chain_lines_by_triangle_connectivity(lines, loops, open_polylines); + chain_lines_by_triangle_connectivity(lines, coplanar_edges, loops, open_polylines); #ifdef SLIC3R_DEBUG_SLICE_PROCESSING { @@ -1484,6 +1575,7 @@ template static std::vector make_loops( // Lines will have their flags modified. std::vector &lines, + const CoplanarEdges &coplanar_edges, const MeshSlicingParams ¶ms, ThrowOnCancel throw_on_cancel) { @@ -1491,20 +1583,13 @@ static std::vector make_loops( layers.resize(lines.size()); tbb::parallel_for( tbb::blocked_range(0, lines.size()), - [&lines, &layers, ¶ms, throw_on_cancel](const tbb::blocked_range &range) { + [&lines, &layers, &coplanar_edges, ¶ms, throw_on_cancel](const tbb::blocked_range &range) { for (size_t line_idx = range.begin(); line_idx < range.end(); ++ line_idx) { if ((line_idx & 0x0ffff) == 0) throw_on_cancel(); Polygons &polygons = layers[line_idx]; - polygons = make_loops(lines[line_idx]); - - // Orca: A planar quad represented by two triangles contributes a point where the - // slicing plane crosses the shared diagonal. After rounding to coord_t this - // point may be very slightly off the otherwise straight contour edge. Apart - // from being redundant, such points make the subsequent contour - // simplification depend on the slice height (and may move seam candidates). - remove_collinear(polygons); + polygons = make_loops(lines[line_idx], coplanar_edges); auto this_mode = line_idx < params.slicing_mode_normal_below_layer ? params.mode_below : params.mode; if (! polygons.empty()) { @@ -1633,7 +1718,7 @@ static std::vector make_slab_loops( #endif /* SLIC3R_DEBUG_SLICE_PROCESSING */ Polygons &loops = layers[line_idx]; std::vector open_polylines; - chain_lines_by_triangle_connectivity(in, loops, open_polylines); + chain_lines_by_triangle_connectivity(in, {}, loops, open_polylines); #ifdef SLIC3R_DEBUG_SLICE_PROCESSING { SVG svg(debug_out_path("make_slab_loops-out-%d-%d-%s.svg", iRun, line_idx, ProjectionFromTop ? "top" : "bottom").c_str(), bbox_svg); @@ -1673,7 +1758,7 @@ static ExPolygons make_expolygons_simple(std::vector &lines) ExPolygons slices; Polygons holes; - for (Polygon &loop : make_loops(lines)) + for (Polygon &loop : make_loops(lines, {})) if (loop.area() >= 0.) slices.emplace_back(std::move(loop)); else @@ -1878,6 +1963,7 @@ std::vector slice_mesh( BOOST_LOG_TRIVIAL(debug) << "slice_mesh to polygons"; std::vector lines; + CoplanarEdges coplanar; { //FIXME facets_edges is likely not needed and quite costly to calculate. @@ -1885,6 +1971,8 @@ std::vector slice_mesh( // However facets_edges assigns a single edge ID to two triangles only, thus when factoring facets_edges out, one will have // to make sure that no code relies on it. std::vector face_edge_ids = its_face_edge_ids(mesh); + // Orca: Keep the coplanarity classification aligned with the edge IDs used to chain this slice. + coplanar = coplanar_edges(mesh, face_edge_ids, params.trafo); if (zs.size() <= 1) { // It likely is not worthwile to copy the vertices. Apply the transformation in place. if (is_identity(params.trafo)) { @@ -1906,7 +1994,7 @@ std::vector slice_mesh( throw_on_cancel(); - std::vector layers = make_loops(lines, params, throw_on_cancel); + std::vector layers = make_loops(lines, coplanar, params, throw_on_cancel); #ifdef SLIC3R_DEBUG { @@ -1952,6 +2040,7 @@ Polygons slice_mesh( const MeshSlicingParams ¶ms) { std::vector lines; + CoplanarEdges coplanar; { bool trafo_identity = is_identity(params.trafo); @@ -1987,6 +2076,8 @@ Polygons slice_mesh( // 3) Calculate face neighbors for just the faces in face_mask. std::vector face_edge_ids = its_face_edge_ids(mesh, face_mask); + // Orca: The single-plane path has its own masked edge-ID space, so classify that space separately. + coplanar = coplanar_edges(mesh, face_edge_ids, params.trafo); // 4) Slice "face_mask" triangles, collect line segments. // It likely is not worthwile to copy the vertices. Apply the transformation in place. @@ -2002,7 +2093,7 @@ Polygons slice_mesh( } // 5) Chain the line segments. - std::vector layers = make_loops(lines, params, [](){}); + std::vector layers = make_loops(lines, coplanar, params, [](){}); assert(layers.size() == 1); return layers.front(); } diff --git a/src/libslic3r/TriangleSelector.cpp b/src/libslic3r/TriangleSelector.cpp index 3b032cc57e..b47004fca5 100644 --- a/src/libslic3r/TriangleSelector.cpp +++ b/src/libslic3r/TriangleSelector.cpp @@ -1736,13 +1736,22 @@ TriangleSelector::TriangleSplittingData TriangleSelector::serialize() const { data.used_states[n] = true; if (n >= 3) { - assert(n <= 16); - if (n <= 16) { - // Store "11" plus 4 bits of (n-3). - data.bitstream.insert(data.bitstream.end(), { true, true }); - n -= 3; + assert(n <= int(EnforcerBlockerType::ExtruderMax)); + // Store "11" plus 4 bits of (n-3), which covers states 3..17. State 18 and + // above set that nibble to 0b1111 and store (n-18) in a second nibble. This is + // the encoding the CONST_FILAMENTS table in Model.cpp already writes for + // colored mesh imports. + data.bitstream.insert(data.bitstream.end(), { true, true }); + auto &bitstream = data.bitstream; + auto push_nibble = [&bitstream](int value) { for (size_t bit_idx = 0; bit_idx < 4; ++bit_idx) - data.bitstream.push_back(n & (uint64_t(0b0001) << bit_idx)); + bitstream.push_back(value & (uint64_t(0b0001) << bit_idx)); + }; + if (n <= 17) { + push_nibble(n - 3); + } else { + push_nibble(0b1111); + push_nibble(n - 18); } } else { // Simple case, compatible with PrusaSlicer 2.3.1 and older for storing paint on supports and seams. @@ -1810,6 +1819,12 @@ void TriangleSelector::deserialize(const TriangleSplittingData &data, n |= data.bitstream[ibit ++] << i; return n; }; + // Decode a leaf state stored behind the "11" prefix: one nibble of (state-3) for states + // 3..17, or 0b1111 followed by a nibble of (state-18) above that. + auto decode_leaf_state = [&next_nibble]() { + const int nibble = next_nibble(); + return EnforcerBlockerType(nibble == 0b1111 ? next_nibble() + 18 : nibble + 3); + }; parents.clear(); while (true) { @@ -1818,8 +1833,8 @@ void TriangleSelector::deserialize(const TriangleSplittingData &data, int num_of_split_sides = code & 0b11; int num_of_children = num_of_split_sides == 0 ? 0 : num_of_split_sides + 1; bool is_split = num_of_children != 0; - // Only valid if not is_split. Value of the second nibble was subtracted by 3, so it is added back. - auto state = is_split ? EnforcerBlockerType::NONE : EnforcerBlockerType((code & 0b1100) == 0b1100 ? next_nibble() + 3 : code >> 2); + // Only valid if not is_split. + auto state = is_split ? EnforcerBlockerType::NONE : ((code & 0b1100) == 0b1100 ? decode_leaf_state() : EnforcerBlockerType(code >> 2)); // BBS if (state == to_delete_filament) @@ -1916,7 +1931,14 @@ void TriangleSelector::TriangleSplittingData::update_used_states(const size_t bi if (const bool is_split = (code & 0b11) != 0; is_split) continue; - const uint8_t facet_state = (code & 0b1100) == 0b1100 ? read_next_nibble() + 3 : code >> 2; + uint8_t facet_state; + if ((code & 0b1100) == 0b1100) { + // Leaf behind the "11" prefix: one nibble of (state-3), or 0b1111 + (state-18). + const uint8_t nibble = read_next_nibble(); + facet_state = nibble == 0b1111 ? uint8_t(read_next_nibble() + 18) : uint8_t(nibble + 3); + } else { + facet_state = code >> 2; + } assert(facet_state < this->used_states.size()); if (facet_state >= this->used_states.size()) continue; @@ -1946,9 +1968,13 @@ bool TriangleSelector::has_facets(const TriangleSplittingData &data, const Enfor auto num_children_or_state = [&next_nibble]() -> int { int code = next_nibble(); int num_of_split_sides = code & 0b11; - return num_of_split_sides == 0 ? - ((code & 0b1100) == 0b1100 ? next_nibble() + 3 : code >> 2) : - - num_of_split_sides - 1; + if (num_of_split_sides != 0) + return - num_of_split_sides - 1; + if ((code & 0b1100) != 0b1100) + return code >> 2; + // Leaf behind the "11" prefix: one nibble of (state-3), or 0b1111 + (state-18). + const int nibble = next_nibble(); + return nibble == 0b1111 ? next_nibble() + 18 : nibble + 3; }; int state = num_children_or_state(); @@ -1983,6 +2009,20 @@ void TriangleSelector::seed_fill_unselect_all_triangles() triangle.unselect_by_seed_fill(); } +void TriangleSelector::shift_states_above(EnforcerBlockerType threshold, int delta) +{ + for (Triangle &triangle : m_triangles) { + if (triangle.is_split() || !triangle.valid()) + continue; + EnforcerBlockerType s = triangle.get_state(); + if (s >= threshold && s != EnforcerBlockerType::NONE) { + int new_val = (int)s + delta; + if (new_val >= 0) + triangle.set_state(EnforcerBlockerType(new_val)); + } + } +} + void TriangleSelector::seed_fill_apply_on_triangles(EnforcerBlockerType new_state) { for (Triangle &triangle : m_triangles) diff --git a/src/libslic3r/TriangleSelector.hpp b/src/libslic3r/TriangleSelector.hpp index 11517f5c6c..594f710e45 100644 --- a/src/libslic3r/TriangleSelector.hpp +++ b/src/libslic3r/TriangleSelector.hpp @@ -17,7 +17,9 @@ enum class EnforcerBlockerType : int8_t { BLOCKER = 2, // For the fuzzy skin, we use just two values (NONE and FUZZY_SKIN). FUZZY_SKIN = ENFORCER, - // Maximum is 15. The value is serialized in TriangleSelector into 6 bits using a 2 bit prefix code. + // States 3..17 are serialized into 6 bits using a 2 bit prefix code; states 18 and above use + // one additional nibble (see TriangleSelector::serialize). ExtruderMax matches the last entry + // of CONST_FILAMENTS in Model.cpp, which encodes the same range for colored mesh imports. Extruder1 = ENFORCER, Extruder2 = BLOCKER, Extruder3, @@ -34,7 +36,23 @@ enum class EnforcerBlockerType : int8_t { Extruder14, Extruder15, Extruder16, - ExtruderMax = Extruder16 + Extruder17, + Extruder18, + Extruder19, + Extruder20, + Extruder21, + Extruder22, + Extruder23, + Extruder24, + Extruder25, + Extruder26, + Extruder27, + Extruder28, + Extruder29, + Extruder30, + Extruder31, + Extruder32, + ExtruderMax = Extruder32 }; // Type alias for the state mapping array to improve code readability @@ -369,6 +387,9 @@ public: // For all triangles, remove the flag indicating that the triangle was selected by seed fill. void seed_fill_unselect_all_triangles(); + // Shift all triangle states >= threshold by delta (used when inserting filaments) + void shift_states_above(EnforcerBlockerType threshold, int delta); + // For all triangles selected by seed fill, set new EnforcerBlockerType and remove flag indicating that triangle was selected by seed fill. // The operation may merge split triangles if they are being assigned the same color. void seed_fill_apply_on_triangles(EnforcerBlockerType new_state); diff --git a/src/libslic3r/libslic3r.h b/src/libslic3r/libslic3r.h index f4291d36df..6584566f40 100644 --- a/src/libslic3r/libslic3r.h +++ b/src/libslic3r/libslic3r.h @@ -64,6 +64,11 @@ static constexpr double LARGE_BED_THRESHOLD = 2147; // Orca: maximum number of extruders is 64. For SEMM printers, it defines maximum filament number. static constexpr size_t MAXIMUM_EXTRUDER_NUMBER = 64; +// Orca: how many filament slots syncing an AMS setup may create. This was derived from +// EnforcerBlockerType::ExtruderMax, but that cap now covers 32 paintable filaments, so the AMS +// limit is pinned here to keep sync behaving as it does for projects without mixed-color filaments. +static constexpr size_t MAXIMUM_AMS_SYNC_FILAMENT_NUMBER = 16; + // Orca: maximum line width is 5 times the nozzle diameter static constexpr float MAX_LINE_WIDTH_MULTIPLIER = 5; diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index a9a0bd661a..bb4d137f2b 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -38,6 +38,8 @@ set(SLIC3R_GUI_SOURCES GUI/AuxiliaryDataViewModel.hpp GUI/AuxiliaryDialog.cpp GUI/AuxiliaryDialog.hpp + GUI/AVVideoDecoder.cpp + GUI/AVVideoDecoder.hpp GUI/Auxiliary.hpp GUI/BackgroundSlicingProcess.cpp GUI/BackgroundSlicingProcess.hpp @@ -354,6 +356,16 @@ set(SLIC3R_GUI_SOURCES GUI/Monitor.hpp GUI/MonitorPage.cpp GUI/MonitorPage.hpp + GUI/MixedFilamentDialog.cpp + GUI/MixedFilamentDialog.hpp + GUI/GradientCurveEditor.cpp + GUI/GradientCurveEditor.hpp + GUI/ColorDecomposeDialog.cpp + GUI/ColorDecomposeDialog.hpp + GUI/ColorDecomposeSupport.cpp + GUI/ColorDecomposeSupport.hpp + GUI/TextureImportDialog.cpp + GUI/TextureImportDialog.hpp GUI/Mouse3DController.cpp GUI/Mouse3DController.hpp GUI/MsgDialog.cpp @@ -604,6 +616,8 @@ set(SLIC3R_GUI_SOURCES GUI/WipeTowerDialog.cpp GUI/wxExtensions.cpp GUI/wxExtensions.hpp + GUI/wxMediaCtrl3.cpp + GUI/wxMediaCtrl3.h plugin/PythonInterpreter.cpp plugin/PythonInterpreter.hpp plugin/PythonPluginBridge.cpp @@ -792,21 +806,8 @@ if (APPLE) GUI/DeepLinkHandlerMac.mm GUI/DeepLinkHandlerMac.h GUI/GUI_UtilsMac.mm - GUI/wxMediaCtrl2.mm - GUI/wxMediaCtrl2.h ) FIND_LIBRARY(DISKARBITRATION_LIBRARY DiskArbitration) -else () - list(APPEND SLIC3R_GUI_SOURCES - GUI/wxMediaCtrl2.cpp - GUI/wxMediaCtrl2.h - ) -endif () - -if (UNIX AND NOT APPLE) - list(APPEND SLIC3R_GUI_SOURCES - GUI/Printer/gstbambusrc.c - ) endif () set(ORCA_UPDATER_SIG_KEY_B64 "${ORCA_UPDATER_SIG_KEY}") @@ -906,6 +907,26 @@ if (SLIC3R_PCH AND NOT SLIC3R_SYNTAXONLY) add_precompiled_header(libslic3r_gui pchheader.hpp FORCEINCLUDE) endif () +if (APPLE) + # Static FFmpeg from the deps install: nothing to bundle into the .app, + # no rpath/install_name handling. Order matters: avcodec -> swscale -> avutil. + find_library(LIBAVCODEC_LIBRARY NAMES libavcodec.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) + find_library(LIBSWSCALE_LIBRARY NAMES libswscale.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) + find_library(LIBAVUTIL_LIBRARY NAMES libavutil.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) + if (NOT LIBAVCODEC_LIBRARY OR NOT LIBSWSCALE_LIBRARY OR NOT LIBAVUTIL_LIBRARY) + message(FATAL_ERROR "Static FFmpeg (libavcodec.a/libswscale.a/libavutil.a) not found under ${CMAKE_PREFIX_PATH}/lib. Rebuild the deps — FFMPEG builds static-only on macOS.") + endif () + target_link_libraries(libslic3r_gui ${LIBAVCODEC_LIBRARY} ${LIBSWSCALE_LIBRARY} ${LIBAVUTIL_LIBRARY}) + target_include_directories(libslic3r_gui SYSTEM PRIVATE ${CMAKE_PREFIX_PATH}/include) +else () + pkg_check_modules(LIBAV REQUIRED IMPORTED_TARGET + libavcodec + libswscale + libavutil + ) + target_link_libraries(libslic3r_gui PkgConfig::LIBAV) +endif() + # We need to implement some hacks for wxWidgets and touch the underlying GTK # layer and sub-libraries. This forces us to use the include locations and # link these libraries. @@ -932,11 +953,6 @@ if (UNIX AND NOT APPLE) target_compile_definitions(libslic3r_gui PRIVATE wxHAVE_GDK_WAYLAND) endif () - # We add GStreamer for bambu:/// support. - pkg_check_modules(GSTREAMER REQUIRED gstreamer-1.0) - pkg_check_modules(GST_BASE REQUIRED gstreamer-base-1.0) - target_link_libraries(libslic3r_gui ${GSTREAMER_LIBRARIES} ${GST_BASE_LIBRARIES}) - target_include_directories(libslic3r_gui SYSTEM PRIVATE ${GSTREAMER_INCLUDE_DIRS} ${GST_BASE_INCLUDE_DIRS}) endif () # Add a definition so that we can tell we are compiling slic3r. diff --git a/src/slic3r/GUI/3DScene.cpp b/src/slic3r/GUI/3DScene.cpp index bf5d1f2421..f65c0e3532 100644 --- a/src/slic3r/GUI/3DScene.cpp +++ b/src/slic3r/GUI/3DScene.cpp @@ -682,13 +682,19 @@ void GLVolume::simple_render(GLShaderProgram* shader, ModelObjectPtrs& model_obj if (shader) { if (idx == 0) { int extruder_id = model_volume->extruder_id(); - //to make black not too hard too see - ColorRGBA new_color = adjust_color_for_rendering(extruder_colors[extruder_id - 1]); - if (ban_light) { - new_color[3] = (255 - (extruder_id - 1))/255.0f; + // ORCA: extruder_id may be 0 (unset) or point past the colour list after a + // filament is deleted/remapped, so clamp the index instead of reading out of + // bounds. + if (!extruder_colors.empty()) { + int color_idx = std::clamp(extruder_id - 1, 0, int(extruder_colors.size()) - 1); + //to make black not too hard too see + ColorRGBA new_color = adjust_color_for_rendering(extruder_colors[color_idx]); + if (ban_light) { + new_color[3] = (255 - color_idx)/255.0f; + } + m.set_color(new_color); + // shader->set_uniform("uniform_color", new_color); } - m.set_color(new_color); - // shader->set_uniform("uniform_color", new_color); } else { if (idx <= extruder_colors.size()) { diff --git a/src/slic3r/GUI/AVVideoDecoder.cpp b/src/slic3r/GUI/AVVideoDecoder.cpp new file mode 100644 index 0000000000..d7b8432bd3 --- /dev/null +++ b/src/slic3r/GUI/AVVideoDecoder.cpp @@ -0,0 +1,170 @@ +#include "AVVideoDecoder.hpp" + +#include + +extern "C" +{ + #include + #include +} + +AVVideoDecoder::AVVideoDecoder() +{ + codec_ctx_ = avcodec_alloc_context3(nullptr); +} + +AVVideoDecoder::~AVVideoDecoder() +{ + if (sws_ctx_) + sws_freeContext(sws_ctx_); + if (frame_) + av_frame_free(&frame_); + if (codec_ctx_) + avcodec_free_context(&codec_ctx_); +} + +int AVVideoDecoder::open(Bambu_StreamInfo const &info) +{ + auto codec_id = info.sub_type == AVC1 ? AV_CODEC_ID_H264 : AV_CODEC_ID_MJPEG; + auto codec = avcodec_find_decoder(codec_id); + if (codec == nullptr) { + fprintf(stderr, "AVVideoDecoder: unsupported codec!\n"); + return -1; // Codec not found + } + /* open the coderc */ + if (avcodec_open2(codec_ctx_, codec, nullptr) < 0) { + fprintf(stderr, "AVVideoDecoder: could not open codec\n"); + return -1; + } + + // Allocate an AVFrame structure + frame_ = av_frame_alloc(); + if (frame_ == nullptr) + return -1; + + return 0; +} + +int AVVideoDecoder::decode(const Bambu_Sample &sample) +{ + int ret = -1; + AVPacket *pkt = av_packet_alloc(); + if (!pkt) { + return ret; + } + + ret = av_new_packet(pkt, sample.size); + if (ret != 0) { + av_packet_free(&pkt); + return ret; + } + + memcpy(pkt->data, sample.buffer, size_t(sample.size)); + + ret = avcodec_send_packet(codec_ctx_, pkt); + if (ret == 0) { + got_frame_ = avcodec_receive_frame(codec_ctx_, frame_) == 0; + } + + av_packet_unref(pkt); + av_packet_free(&pkt); + return ret; +} + +int AVVideoDecoder::flush() +{ + int ret = avcodec_send_packet(codec_ctx_, nullptr); + got_frame_ = avcodec_receive_frame(codec_ctx_, frame_) == 0; + return ret; +} + +void AVVideoDecoder::close() +{ +} + +bool AVVideoDecoder::toWxImage(wxImage &image, wxSize const &size2) +{ + if (!got_frame_) + return false; + + auto size1 = size2; + if (!size1.IsFullySpecified()) + size1 = {frame_->width, frame_->height }; + auto size = size1; + if (size.GetWidth() & 0x0f) { + size.SetWidth((size.GetWidth() & ~0x0f) + 0x10); + if (size.GetWidth() != width_) { + std::fill(bits_.begin(), bits_.end(), 0); + width_ = size.GetWidth(); + } + } + AVPixelFormat wxFmt = AV_PIX_FMT_RGB24; + sws_ctx_ = sws_getCachedContext(sws_ctx_, + frame_->width, frame_->height, AVPixelFormat(frame_->format), + size1.GetWidth(), size1.GetHeight(), wxFmt, + SWS_GAUSS, + nullptr, nullptr, nullptr); + if (sws_ctx_ == nullptr) + return false; + int length = size.GetWidth() * size.GetHeight() * 3; + if (bits_.size() < length) + bits_.resize(length); + uint8_t * datas[] = { bits_.data() }; + int strides[] = { size.GetWidth() * 3 }; + int result_h = sws_scale(sws_ctx_, frame_->data, frame_->linesize, 0, frame_->height, datas, strides); + if (result_h != size.GetHeight()) { + return false; + } + // Copy: the frame outlives this decoder and is painted by the GUI thread while the + // next sws_scale is already overwriting bits_, so it must own its pixels. The Windows + // path below needs no equivalent, wxBitmap copies the bits into GDI. + image = wxImage(size.GetWidth(), size.GetHeight(), bits_.data(), true).Copy(); + if (!image.IsOk()) { + fprintf(stderr, "AVVideoDecoder: image not ok %dx%d\n", size.GetWidth(), size.GetHeight()); + return false; + } + return true; +} + +bool AVVideoDecoder::toWxBitmap(wxBitmap &bitmap, wxSize const &size2) +{ + if (!got_frame_) + return false; + + auto size1 = size2; + if (!size1.IsFullySpecified()) + size1 = {frame_->width, frame_->height }; + auto size = size1; + if (size.GetWidth() & 0x0f) { + size.SetWidth((size.GetWidth() & ~0x0f) + 0x10); + if (size.GetWidth() != width_) { + std::fill(bits_.begin(), bits_.end(), 0); + width_ = size.GetWidth(); + } + } + AVPixelFormat wxFmt = AV_PIX_FMT_RGB32; + sws_ctx_ = sws_getCachedContext(sws_ctx_, + frame_->width, frame_->height, AVPixelFormat(frame_->format), + size1.GetWidth(), size1.GetHeight(), wxFmt, + SWS_GAUSS, + nullptr, nullptr, nullptr); + if (sws_ctx_ == nullptr) + return false; + int length = size.GetWidth() * size.GetHeight() * 4; + if (bits_.size() < length) + bits_.resize(length); + uint8_t *datas[] = { bits_.data() }; + int strides[] = { size.GetWidth() * 4 }; + int result_h = sws_scale(sws_ctx_, frame_->data, frame_->linesize, 0, frame_->height, datas, strides); + if (result_h != size.GetHeight()) { + fprintf(stderr, "AVVideoDecoder: result_h %d %d\n", result_h, size.GetHeight()); + return false; + } + bitmap = wxBitmap((char const *) bits_.data(), size.GetWidth(), size.GetHeight(), 32); + assert(bitmap.IsOk()); + if (!bitmap.IsOk()) { + fprintf(stderr, "AVVideoDecoder: bitmap not ok %dx%d\n", size.GetWidth(), size.GetHeight()); + return false; + } + return true; +} diff --git a/src/slic3r/GUI/AVVideoDecoder.hpp b/src/slic3r/GUI/AVVideoDecoder.hpp new file mode 100644 index 0000000000..4111e860a2 --- /dev/null +++ b/src/slic3r/GUI/AVVideoDecoder.hpp @@ -0,0 +1,46 @@ +#ifndef AVVIDEODECODER_HPP +#define AVVIDEODECODER_HPP + +#include "Printer/BambuTunnel.h" + +extern "C" { + #include + #include +} +#include +#include +#include +#include + +class wxBitmap; + +class AVVideoDecoder +{ +public: + AVVideoDecoder(); + + ~AVVideoDecoder(); + +public: + int open(Bambu_StreamInfo const &info); + + int decode(Bambu_Sample const &sample); + + int flush(); + + void close(); + + bool toWxImage(wxImage &image, wxSize const &size); + + bool toWxBitmap(wxBitmap &bitmap, wxSize const & size); + +private: + AVCodecContext *codec_ctx_ = nullptr; + AVFrame * frame_ = nullptr; + SwsContext * sws_ctx_ = nullptr; + bool got_frame_ = false; + int width_ { 0 }; // scale result width + std::vector bits_; +}; + +#endif // AVVIDEODECODER_HPP diff --git a/src/slic3r/GUI/BambuPlayer/BambuPlayer.h b/src/slic3r/GUI/BambuPlayer/BambuPlayer.h deleted file mode 100644 index fe5f0d049a..0000000000 --- a/src/slic3r/GUI/BambuPlayer/BambuPlayer.h +++ /dev/null @@ -1,30 +0,0 @@ -// -// BambuPlayer.h -// BambuPlayer -// -// Created by cmguo on 2021/12/6. -// - -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface BambuPlayer : NSObject - -+ (void) initialize; - -- (instancetype) initWithDisplayLayer: (AVSampleBufferDisplayLayer*) layer; -- (instancetype) initWithImageView: (NSView*) view; -- (int) open: (char const *) url; -- (NSSize) videoSize; -- (int) play; -- (void) stop; -- (void) close; - -- (void) setLogger: (void (*)(void const * context, int level, char const * msg)) logger withContext: (void const *) context; - -@end - -NS_ASSUME_NONNULL_END diff --git a/src/slic3r/GUI/CalibrationWizardPresetPage.cpp b/src/slic3r/GUI/CalibrationWizardPresetPage.cpp index db97354ad1..5267715439 100644 --- a/src/slic3r/GUI/CalibrationWizardPresetPage.cpp +++ b/src/slic3r/GUI/CalibrationWizardPresetPage.cpp @@ -1015,7 +1015,7 @@ wxBoxSizer* CalibrationPresetPage::create_ams_items_sizer(MachineObject* obj, wx auto ams_items_sizer = new wxBoxSizer(wxHORIZONTAL); for (auto &info : ams_info) { auto preview_ams_item = new AMSPreview(ams_preview_panel, wxID_ANY, info, info.ams_type); - preview_ams_item->Update(info); + preview_ams_item->UpdateInfo(info); preview_ams_item->Open(); ams_preview_list.push_back(preview_ams_item); std::string ams_id = preview_ams_item->get_ams_id(); diff --git a/src/slic3r/GUI/CalibrationWizardSavePage.hpp b/src/slic3r/GUI/CalibrationWizardSavePage.hpp index 4726cb1230..eb15720e96 100644 --- a/src/slic3r/GUI/CalibrationWizardSavePage.hpp +++ b/src/slic3r/GUI/CalibrationWizardSavePage.hpp @@ -193,7 +193,7 @@ public: void show_panels(CalibrationMethod method, const PrinterSeries printer_ser); - void on_device_connected(MachineObject* obj); + void on_device_connected(MachineObject* obj) override; void update(MachineObject* obj) override; diff --git a/src/slic3r/GUI/CalibrationWizardStartPage.hpp b/src/slic3r/GUI/CalibrationWizardStartPage.hpp index 0e893bce10..026ce187ac 100644 --- a/src/slic3r/GUI/CalibrationWizardStartPage.hpp +++ b/src/slic3r/GUI/CalibrationWizardStartPage.hpp @@ -48,8 +48,8 @@ public: void create_page(wxWindow* parent); - void on_reset_page(); - void on_device_connected(MachineObject* obj); + void on_reset_page() override; + void on_device_connected(MachineObject* obj) override; void msw_rescale() override; }; @@ -63,8 +63,8 @@ public: long style = wxTAB_TRAVERSAL); void create_page(wxWindow* parent); - void on_reset_page(); - void on_device_connected(MachineObject* obj); + void on_reset_page() override; + void on_device_connected(MachineObject* obj) override; void msw_rescale() override; }; diff --git a/src/slic3r/GUI/ColorDecomposeDialog.cpp b/src/slic3r/GUI/ColorDecomposeDialog.cpp new file mode 100644 index 0000000000..2176de110e --- /dev/null +++ b/src/slic3r/GUI/ColorDecomposeDialog.cpp @@ -0,0 +1,951 @@ +#include "ColorDecomposeDialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include "wx/graphics.h" + +#include "I18N.hpp" +#include "GUI.hpp" +#include "GUI_App.hpp" +#include "format.hpp" +#include "Widgets/ComboBox.hpp" +#include "Widgets/DropDown.hpp" +#include "Widgets/Button.hpp" +#include "Widgets/CheckBox.hpp" +#include "Widgets/Label.hpp" +#include "wxExtensions.hpp" +#include "ColorDecomposeSupport.hpp" +#include "libslic3r/ColorDecomposeRecipe.hpp" + +namespace Slic3r { +namespace GUI { + +static const wxColour COLOR_BRAND("#009688"); +static const wxColour COLOR_BORDER_NORMAL("#EEEEEE"); +static const wxColour COLOR_BG_CARD("#F8F8F8"); +static const wxColour COLOR_LABEL_GREY("#ACACAC"); +static const wxColour COLOR_TEXT_DARK("#262E30"); +static const wxColour COLOR_DIVIDER("#EEEEEE"); + +// Standard CMYW base colors +static const wxColour CMYW_CYAN(0, 255, 255); +static const wxColour CMYW_MAGENTA(255, 0, 255); +static const wxColour CMYW_YELLOW(255, 255, 0); +static const wxColour CMYW_WHITE(255, 255, 255); + +// Standard RYBW base colors +static const wxColour RYBW_RED(255, 0, 0); +static const wxColour RYBW_YELLOW(255, 255, 0); +static const wxColour RYBW_BLUE(0, 0, 255); +static const wxColour RYBW_WHITE(255, 255, 255); + +static size_t mode_index(DecomposeMode mode) +{ + return static_cast(mode); +} + +static ColorDecomposeRgb wx_colour_to_recipe_rgb(const wxColour& color) +{ + return { + static_cast(color.Red()), + static_cast(color.Green()), + static_cast(color.Blue()) + }; +} + +static wxColour hex_to_wx_colour(const std::string& hex, const wxColour& fallback) +{ + wxColour color(hex); + return color.IsOk() ? color : fallback; +} + +static bool same_rgb(const wxColour& lhs, const wxColour& rhs) +{ + return lhs.Red() == rhs.Red() && lhs.Green() == rhs.Green() && lhs.Blue() == rhs.Blue(); +} + +static DecomposeBaseColor standard_base_color_from_key(const std::string& key) +{ + if (key == "Cyan") return DecomposeBaseColor::Cyan; + if (key == "Magenta") return DecomposeBaseColor::Magenta; + if (key == "Yellow") return DecomposeBaseColor::Yellow; + if (key == "White") return DecomposeBaseColor::White; + if (key == "Red") return DecomposeBaseColor::Red; + if (key == "Green") return DecomposeBaseColor::Green; + if (key == "Blue") return DecomposeBaseColor::Blue; + return DecomposeBaseColor::None; +} + +static wxColour pure_color_for_base(DecomposeBaseColor base) +{ + switch (base) { + case DecomposeBaseColor::Cyan: return CMYW_CYAN; + case DecomposeBaseColor::Magenta: return CMYW_MAGENTA; + case DecomposeBaseColor::Yellow: return CMYW_YELLOW; + case DecomposeBaseColor::White: return CMYW_WHITE; + case DecomposeBaseColor::Red: return RYBW_RED; + case DecomposeBaseColor::Blue: return RYBW_BLUE; + default: return *wxBLACK; + } +} + +static DecomposeBaseColor standard_base_color_for(DecomposeMode mode, const wxColour& color) +{ + if (mode == DecomposeMode::CMYW) { + if (same_rgb(color, CMYW_CYAN)) return DecomposeBaseColor::Cyan; + if (same_rgb(color, CMYW_MAGENTA)) return DecomposeBaseColor::Magenta; + if (same_rgb(color, CMYW_YELLOW)) return DecomposeBaseColor::Yellow; + if (same_rgb(color, CMYW_WHITE)) return DecomposeBaseColor::White; + } else if (mode == DecomposeMode::RYBW) { + if (same_rgb(color, RYBW_RED)) return DecomposeBaseColor::Red; + if (same_rgb(color, RYBW_YELLOW)) return DecomposeBaseColor::Yellow; + if (same_rgb(color, RYBW_BLUE)) return DecomposeBaseColor::Blue; + if (same_rgb(color, RYBW_WHITE)) return DecomposeBaseColor::White; + } + return DecomposeBaseColor::None; +} + +static ColorDecomposeResult to_dialog_result(const ColorDecomposeRecipeResult& recipe, + const wxColour& fallback) +{ + ColorDecomposeResult result; + result.mode = recipe.mode; + result.matched_color = hex_to_wx_colour(recipe.matched_color_hex, fallback); + for (const auto& comp_recipe : recipe.components) { + DecomposeComponent comp; + comp.colour = hex_to_wx_colour(comp_recipe.color_hex, fallback); + comp.ratio = comp_recipe.ratio; + comp.filament_index = static_cast(comp_recipe.filament_index); + comp.base_color = standard_base_color_from_key(comp_recipe.base_color); + if (comp.base_color == DecomposeBaseColor::None) + comp.base_color = standard_base_color_for(recipe.mode, comp.colour); + result.components.push_back(comp); + } + return result; +} + +static wxPanel* create_h_divider(wxWindow* parent, int fixed_width = -1) +{ + const int h = parent->FromDIP(1); + int w = fixed_width > 0 ? fixed_width : -1; + auto* panel = new wxPanel(parent, wxID_ANY, wxDefaultPosition, wxSize(w, h)); + panel->SetMinSize(wxSize(w, h)); + if (fixed_width > 0) + panel->SetMaxSize(wxSize(fixed_width, h)); + panel->SetBackgroundColour(StateColor::darkModeColorFor(COLOR_DIVIDER)); + return panel; +} + +static wxStaticText* create_mode_group_label(wxWindow* parent, const wxString& text) +{ + auto* label = new wxStaticText(parent, wxID_ANY, text); + label->SetFont(Label::Body_11); + label->SetForegroundColour(StateColor::darkModeColorFor(COLOR_LABEL_GREY)); + return label; +} + +static void match_parent_bg(wxWindow* w, const wxColour& bg) +{ + w->SetBackgroundColour(bg); +} + +static bool material_type_matches(const std::string& a, const std::string& b) +{ + if (a.empty() || b.empty()) + return false; + return a == b || a == b + " Basic" || b == a + " Basic"; +} + + +ColorDecomposeDialog::ColorDecomposeDialog(wxWindow* parent, + int filament_idx, + const wxColour& target_color, + const std::vector& physical_colors, + const std::vector& filament_names, + const std::vector& filament_types, + size_t current_filament_count, + size_t max_filament_count, + std::vector physical_config_indices) + : DPIDialog(parent, wxID_ANY, _L("Decompose Color"), wxDefaultPosition, + wxDefaultSize, wxCAPTION | wxCLOSE_BOX) + , m_filament_idx(filament_idx) + , m_target_color(target_color) + , m_physical_colors(physical_colors) + , m_filament_names(filament_names) + , m_filament_types(filament_types) + , m_current_filament_count(current_filament_count) + , m_max_filament_count(max_filament_count) + , m_physical_config_indices(std::move(physical_config_indices)) +{ + for (const auto& t : m_filament_types) { + if (std::find(m_project_types.begin(), m_project_types.end(), t) == m_project_types.end()) + m_project_types.push_back(t); + } + + if (m_filament_idx >= 0 && static_cast(m_filament_idx) < m_filament_types.size()) + m_preferred_type = m_filament_types[m_filament_idx]; + else if (!m_project_types.empty()) + m_preferred_type = m_project_types.front(); + + build_ui(); + wxGetApp().UpdateDlgDarkUI(this); + // Restore target swatch after dark mode color remapping + if (m_target_swatch) { + m_target_swatch->SetBackgroundColour(m_target_color); + m_target_swatch->Refresh(); + } + + update_card_visibility(); + Fit(); + compute_decomposition(); + update_matched_color_display(); + update_ok_button_state(); +} + +void ColorDecomposeDialog::on_dpi_changed(const wxRect& suggested_rect) +{ + (void)suggested_rect; + Fit(); + Refresh(); +} + +void ColorDecomposeDialog::build_ui() +{ + SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + + auto* main_sizer = new wxBoxSizer(wxVERTICAL); + + const int selector_side_margin = FromDIP(26); + const int selector_top_gap = FromDIP(22); + const int content_side_margin = FromDIP(30); + const int target_section_top_gap = FromDIP(18); + + main_sizer->AddSpacer(selector_top_gap); + main_sizer->Add(create_filament_selector(), 0, wxEXPAND | wxLEFT | wxRIGHT, selector_side_margin); + main_sizer->AddSpacer(target_section_top_gap); + main_sizer->Add(create_target_color_section(), 0, wxEXPAND | wxLEFT | wxRIGHT, content_side_margin); + main_sizer->AddSpacer(FromDIP(16)); + main_sizer->Add(create_h_divider(this), 0, wxEXPAND | wxLEFT | wxRIGHT, content_side_margin); + main_sizer->AddSpacer(FromDIP(16)); + main_sizer->Add(create_mode_selection_section(), 0, wxEXPAND | wxLEFT | wxRIGHT, content_side_margin); + main_sizer->AddSpacer(FromDIP(16)); + main_sizer->Add(create_button_panel(), 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, content_side_margin); + + SetSizer(main_sizer); + SetMinSize(wxSize(FromDIP(477), FromDIP(380))); + Fit(); + CenterOnParent(); +} + +wxBoxSizer* ColorDecomposeDialog::create_filament_selector() +{ + auto* sizer = new wxBoxSizer(wxHORIZONTAL); + + m_type_combo = new ComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, + wxSize(-1, FromDIP(36)), 0, nullptr, wxCB_READONLY); + m_type_combo->SetFont(Label::Body_13); + + m_combo_item_types.clear(); + int default_sel = -1; + + // --- Group 1: Project filament list (deduplicated by type) --- + m_type_combo->Append(_L("Project Filament List"), wxNullBitmap, DD_ITEM_STYLE_SPLIT_ITEM | DD_ITEM_STYLE_DISABLED); + m_combo_item_types.push_back(std::string()); + + std::set seen_types; + for (size_t i = 0; i < m_filament_names.size(); ++i) { + const std::string& type = (i < m_filament_types.size()) ? m_filament_types[i] : "PLA"; + if (!seen_types.insert(type).second) + continue; + int idx = m_type_combo->Append(wxString::FromUTF8(m_filament_names[i])); + m_combo_item_types.push_back(type); + if (type == m_preferred_type && default_sel < 0) + default_sel = idx; + } + + // --- Group 2: Standard mode material recommendations --- + static const char* kStandardTypes[] = { + kDecomposePlaBasicType + }; + + m_type_combo->Append(_L("Standard Mode Recommendations"), wxNullBitmap, DD_ITEM_STYLE_SPLIT_ITEM | DD_ITEM_STYLE_DISABLED); + m_combo_item_types.push_back(std::string()); + + for (size_t s = 0; s < sizeof(kStandardTypes) / sizeof(kStandardTypes[0]); ++s) { + // Always show standard recommendations, even if the same type already + // appears in the project filament list above. + const std::string label = std::string(kDecomposeBambuPresetPrefix) + kStandardTypes[s]; + int idx = m_type_combo->Append(wxString::FromUTF8(label)); + m_combo_item_types.push_back(kStandardTypes[s]); + if (kStandardTypes[s] == m_preferred_type && default_sel < 0) + default_sel = idx; + } + + if (default_sel < 0) { + for (int i = 0; i < static_cast(m_combo_item_types.size()); ++i) { + if (!m_combo_item_types[i].empty()) { + default_sel = i; + break; + } + } + } + + if (default_sel >= 0) { + m_type_combo->SetSelection(default_sel); + if (!m_combo_item_types[default_sel].empty()) + m_preferred_type = m_combo_item_types[default_sel]; + } + + m_type_combo->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent& evt) { + evt.StopPropagation(); + int sel = m_type_combo->GetSelection(); + if (sel >= 0 && static_cast(sel) < m_combo_item_types.size() + && !m_combo_item_types[sel].empty()) { + m_preferred_type = m_combo_item_types[sel]; + } + update_card_visibility(); + compute_decomposition(); + update_matched_color_display(); + update_ok_button_state(); + }); + + sizer->Add(m_type_combo, 1, wxEXPAND); + return sizer; +} + +static wxPanel* create_color_swatch(wxWindow* parent, const wxColour& color, int size) +{ + auto* panel = new wxPanel(parent, wxID_ANY, wxDefaultPosition, wxSize(size, size)); + panel->SetBackgroundColour(color); + panel->SetMinSize(wxSize(size, size)); + panel->SetBackgroundStyle(wxBG_STYLE_PAINT); + panel->Bind(wxEVT_PAINT, [panel](wxPaintEvent&) { + wxAutoBufferedPaintDC dc(panel); + wxSize sz = panel->GetClientSize(); + wxColour c = panel->GetBackgroundColour(); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(c)); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + // Mirror sidebar (FilamentBitmapUtils::create_single_filament_bitmap): + // gray border for near-white in light mode so white swatches stay + // visible on a white background; light border for near-black in dark mode. + const bool light_mode = !wxGetApp().dark_mode(); + if ((light_mode && c.Red() > 224 && c.Green() > 224 && c.Blue() > 224) || + (!light_mode && c.Red() < 45 && c.Green() < 45 && c.Blue() < 45)) { + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.SetPen(wxPen(light_mode ? wxColour(130, 130, 128) : wxColour(207, 207, 207), + 1, wxPENSTYLE_SOLID)); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + } + }); + return panel; +} + +wxBoxSizer* ColorDecomposeDialog::create_target_color_section() +{ + auto* sizer = new wxBoxSizer(wxHORIZONTAL); + + auto* label = new wxStaticText(this, wxID_ANY, _L("Target Color")); + label->SetFont(Label::Head_14); + label->SetForegroundColour(StateColor::darkModeColorFor(COLOR_TEXT_DARK)); + sizer->Add(label, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(19)); + + m_target_swatch = create_color_swatch(this, m_target_color, FromDIP(28)); + sizer->Add(m_target_swatch, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(12)); + + m_target_rgb_text = new wxStaticText(this, wxID_ANY, + wxString::Format("RGB: %d, %d, %d", m_target_color.Red(), m_target_color.Green(), m_target_color.Blue())); + m_target_rgb_text->SetFont(Label::Body_13); + m_target_rgb_text->SetForegroundColour(StateColor::darkModeColorFor(COLOR_TEXT_DARK)); + sizer->Add(m_target_rgb_text, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(12)); + + auto* arrow_text = new wxStaticText(this, wxID_ANY, wxString::FromUTF8("\xe2\x86\x92")); + arrow_text->SetForegroundColour(StateColor::darkModeColorFor(COLOR_TEXT_DARK)); + sizer->Add(arrow_text, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(12)); + + m_matched_swatch = create_color_swatch(this, m_target_color, FromDIP(28)); + sizer->Add(m_matched_swatch, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(12)); + + m_matched_rgb_text = new wxStaticText(this, wxID_ANY, + wxString::Format("RGB: %d, %d, %d", m_target_color.Red(), m_target_color.Green(), m_target_color.Blue())); + m_matched_rgb_text->SetFont(Label::Head_13); + m_matched_rgb_text->SetForegroundColour(StateColor::darkModeColorFor(COLOR_TEXT_DARK)); + sizer->Add(m_matched_rgb_text, 0, wxALIGN_CENTER_VERTICAL); + + return sizer; +} + +wxPanel* ColorDecomposeDialog::create_mode_card(wxWindow* parent, DecomposeMode mode, + const wxString& title) +{ + const int pad = FromDIP(12); + + auto* card = new wxPanel(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE); + card->SetBackgroundStyle(wxBG_STYLE_PAINT); + + auto* card_sizer = new wxBoxSizer(wxVERTICAL); + + auto* title_sizer = new wxBoxSizer(wxHORIZONTAL); + auto* title_label = new wxStaticText(card, wxID_ANY, title); + title_label->SetFont(Label::Body_14); + title_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#6B6A6A"))); + match_parent_bg(title_label, StateColor::darkModeColorFor(COLOR_BG_CARD)); + title_sizer->Add(title_label, 1, wxALIGN_CENTER_VERTICAL); + + auto* chk = new ::CheckBox(card); + chk->SetValue(mode == m_selected_mode); + match_parent_bg(chk, StateColor::darkModeColorFor(COLOR_BG_CARD)); + switch (mode) { + case DecomposeMode::MaterialList: m_chk_material_list = chk; break; + case DecomposeMode::CMYW: m_chk_cmyw = chk; break; + case DecomposeMode::RYBW: m_chk_rybw = chk; break; + } + chk->Bind(wxEVT_TOGGLEBUTTON, [this, mode](wxCommandEvent& e) { + select_mode(mode); + e.Skip(); // let CheckBox::update() re-sync its bitmap to GetValue() + }); + title_sizer->Add(chk, 0, wxALIGN_CENTER_VERTICAL); + + card_sizer->Add(title_sizer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, pad); + + card_sizer->Add(create_h_divider(card), 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(8)); + + auto* colors_sizer = new wxBoxSizer(wxHORIZONTAL); + card_sizer->Add(colors_sizer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, pad); + + auto& controls = m_mode_cards[mode_index(mode)]; + controls.card = card; + controls.components_sizer = colors_sizer; + + card->SetSizer(card_sizer); + card->SetMinSize(wxSize(FromDIP(128), FromDIP(111))); + card->SetMaxSize(wxSize(FromDIP(128), FromDIP(111))); + + card->Bind(wxEVT_PAINT, [this, card, mode](wxPaintEvent&) { + wxBufferedPaintDC dc(card); + wxSize sz = card->GetClientSize(); + dc.SetBackground(wxBrush(StateColor::darkModeColorFor(*wxWHITE))); + dc.Clear(); + + bool selected = (m_selected_mode == mode); + wxColour border_col = selected + ? StateColor::darkModeColorFor(COLOR_BRAND) + : StateColor::darkModeColorFor(COLOR_BORDER_NORMAL); + const int border_width = FromDIP(selected ? 2 : 1); + const double inset = border_width / 2.0; + std::unique_ptr gc(wxGraphicsContext::Create(dc)); + if (gc) { + gc->SetPen(wxPen(border_col, border_width)); + gc->SetBrush(wxBrush(StateColor::darkModeColorFor(COLOR_BG_CARD))); + gc->DrawRoundedRectangle(inset, inset, sz.x - 2 * inset, sz.y - 2 * inset, FromDIP(8)); + } else { + const int fallback_inset = (border_width + 1) / 2; + dc.SetPen(wxPen(border_col, border_width)); + dc.SetBrush(wxBrush(StateColor::darkModeColorFor(COLOR_BG_CARD))); + dc.DrawRoundedRectangle(fallback_inset, fallback_inset, sz.x - 2 * fallback_inset, sz.y - 2 * fallback_inset, FromDIP(8)); + } + }); + + std::function bind_click; + bind_click = [this, mode, chk, &bind_click](wxWindow* w) { + if (w == chk || dynamic_cast<::CheckBox*>(w)) + return; + w->Bind(wxEVT_LEFT_UP, [this, mode](wxMouseEvent&) { + select_mode(mode); + }); + w->SetCursor(wxCursor(wxCURSOR_HAND)); + for (auto* child : w->GetChildren()) + bind_click(child); + }; + bind_click(card); + + return card; +} + +wxBoxSizer* ColorDecomposeDialog::create_mode_selection_section() +{ + auto* sizer = new wxBoxSizer(wxVERTICAL); + + auto* section_label = new wxStaticText(this, wxID_ANY, _L("Select Color Decomposition")); + section_label->SetFont(Label::Head_14); + section_label->SetForegroundColour(StateColor::darkModeColorFor(COLOR_TEXT_DARK)); + sizer->Add(section_label, 0, wxBOTTOM, FromDIP(4)); + + auto* modes_sizer = new wxBoxSizer(wxHORIZONTAL); + + // --- Arbitrary mode column (wrapped in a panel so the whole column hides together) --- + m_arb_column_panel = new wxPanel(this, wxID_ANY); + m_arb_column_panel->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + auto* arb_col = new wxBoxSizer(wxVERTICAL); + { + auto* arb_header_sizer = new wxBoxSizer(wxHORIZONTAL); + arb_header_sizer->Add(create_mode_group_label(m_arb_column_panel, _L("Arbitrary Mode")), + 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(5)); + arb_header_sizer->Add(create_h_divider(m_arb_column_panel, FromDIP(88)), 0, wxALIGN_CENTER_VERTICAL); + arb_col->Add(arb_header_sizer, 0, wxEXPAND | wxBOTTOM, FromDIP(8)); + + m_card_material_list = create_mode_card(m_arb_column_panel, DecomposeMode::MaterialList, + _L("Material List")); + arb_col->Add(m_card_material_list, 0, wxEXPAND); + } + m_arb_column_panel->SetSizer(arb_col); + modes_sizer->Add(m_arb_column_panel, 0, wxEXPAND | wxRIGHT, FromDIP(16)); + + // --- Standard mode column --- + auto* std_col = new wxBoxSizer(wxVERTICAL); + { + auto* std_header_sizer = new wxBoxSizer(wxHORIZONTAL); + std_header_sizer->Add(create_mode_group_label(this, _L("Standard Mode")), + 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(5)); + std_header_sizer->Add(create_h_divider(this), 1, wxALIGN_CENTER_VERTICAL); + std_col->Add(std_header_sizer, 0, wxEXPAND | wxBOTTOM, FromDIP(8)); + + auto* cards_sizer = new wxBoxSizer(wxHORIZONTAL); + + m_card_cmyw = create_mode_card(this, DecomposeMode::CMYW, "CMYW"); + cards_sizer->Add(m_card_cmyw, 0, wxRIGHT, FromDIP(12)); + + m_card_rybw = create_mode_card(this, DecomposeMode::RYBW, "RYBW"); + cards_sizer->Add(m_card_rybw, 0); + + std_col->Add(cards_sizer, 0, wxEXPAND); + } + modes_sizer->Add(std_col, 0, wxEXPAND); + + sizer->Add(modes_sizer, 0, wxEXPAND); + + m_no_card_hint = new wxStaticText(this, wxID_ANY, + _L("At least two filaments of the same material type are required for decomposition")); + m_no_card_hint->SetFont(Label::Body_13); + m_no_card_hint->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#6B6A6A"))); + m_no_card_hint->Wrap(FromDIP(400)); + m_no_card_hint->Hide(); + sizer->Add(m_no_card_hint, 0, wxTOP, FromDIP(8)); + + m_limit_warning_panel = new wxPanel(this, wxID_ANY); + m_limit_warning_panel->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + auto* warning_sizer = new wxBoxSizer(wxHORIZONTAL); + auto* warn_bmp = new wxStaticBitmap(m_limit_warning_panel, wxID_ANY, + create_scaled_bitmap("obj_warning", m_limit_warning_panel, 16), + wxDefaultPosition, wxSize(FromDIP(16), FromDIP(16))); + m_limit_warning_text = new wxStaticText(m_limit_warning_panel, wxID_ANY, wxEmptyString); + m_limit_warning_text->SetFont(Label::Body_13); + m_limit_warning_text->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#D01B1B"))); + m_limit_warning_text->Wrap(FromDIP(400)); + warning_sizer->Add(warn_bmp, 0, wxALIGN_TOP | wxRIGHT, FromDIP(6)); + warning_sizer->Add(m_limit_warning_text, 1, wxEXPAND); + m_limit_warning_panel->SetSizer(warning_sizer); + m_limit_warning_panel->Hide(); + sizer->Add(m_limit_warning_panel, 0, wxEXPAND | wxTOP, FromDIP(8)); + + return sizer; +} + +wxBoxSizer* ColorDecomposeDialog::create_button_panel() +{ + auto* sizer = new wxBoxSizer(wxHORIZONTAL); + sizer->AddStretchSpacer(); + + m_btn_cancel = new Button(this, _L("Cancel")); + m_btn_cancel->SetStyle(ButtonStyle::Regular, ButtonType::Choice); + m_btn_cancel->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_CANCEL); }); + + m_btn_ok = new Button(this, _L("OK")); + m_btn_ok->SetStyle(ButtonStyle::Confirm, ButtonType::Choice); + m_btn_ok->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { + EndModal(wxID_OK); + }); + + sizer->Add(m_btn_cancel, 0, wxRIGHT, FromDIP(12)); + sizer->Add(m_btn_ok, 0); + + return sizer; +} + +void ColorDecomposeDialog::select_mode(DecomposeMode mode) +{ + m_selected_mode = mode; + m_result = m_mode_results[mode_index(mode)]; + update_card_styles(); + update_matched_color_display(); + update_ok_button_state(); +} + +void ColorDecomposeDialog::update_card_styles() +{ + if (m_card_material_list) m_card_material_list->Refresh(); + if (m_card_cmyw) m_card_cmyw->Refresh(); + if (m_card_rybw) m_card_rybw->Refresh(); + + if (m_chk_material_list) + m_chk_material_list->SetValue(m_selected_mode == DecomposeMode::MaterialList); + if (m_chk_cmyw) + m_chk_cmyw->SetValue(m_selected_mode == DecomposeMode::CMYW); + if (m_chk_rybw) + m_chk_rybw->SetValue(m_selected_mode == DecomposeMode::RYBW); +} + +void ColorDecomposeDialog::update_card_visibility() +{ + // Count physical filaments of the same type (excluding the source filament) + int same_type_count = 0; + for (size_t i = 0; i < m_filament_types.size(); ++i) { + if (static_cast(i) == m_filament_idx) + continue; + if (material_type_matches(m_filament_types[i], m_preferred_type)) + ++same_type_count; + } + + bool show_arb = (same_type_count >= 2); + bool show_cmyw = (m_preferred_type == kDecomposePlaBasicType); + bool show_rybw = (m_preferred_type == kDecomposePlaBasicType); + + if (m_arb_column_panel) m_arb_column_panel->Show(show_arb); + if (m_card_material_list) m_card_material_list->Show(show_arb); + if (m_card_cmyw) m_card_cmyw->Show(show_cmyw); + if (m_card_rybw) m_card_rybw->Show(show_rybw); + + bool any_visible = show_arb || show_cmyw || show_rybw; + if (m_no_card_hint) + m_no_card_hint->Show(!any_visible); + + // Auto-select a visible mode when current selection becomes hidden + if (any_visible) { + bool cur_visible = false; + if (m_selected_mode == DecomposeMode::MaterialList && show_arb) cur_visible = true; + if (m_selected_mode == DecomposeMode::CMYW && show_cmyw) cur_visible = true; + if (m_selected_mode == DecomposeMode::RYBW && show_rybw) cur_visible = true; + if (!cur_visible) { + if (show_arb) select_mode(DecomposeMode::MaterialList); + else if (show_cmyw) select_mode(DecomposeMode::CMYW); + else select_mode(DecomposeMode::RYBW); + } + } + + Layout(); + update_ok_button_state(); +} + +void ColorDecomposeDialog::update_filament_limit_warning() +{ + if (!m_limit_warning_panel || !m_limit_warning_text) + return; + + size_t missing_new = 0; + if (m_missing_calculator) { + missing_new = m_missing_calculator(m_result); + } else { + const size_t source_physical_idx = m_filament_idx >= 0 ? static_cast(m_filament_idx) : size_t(-1); + const std::vector* indices = + m_physical_config_indices.empty() ? nullptr : &m_physical_config_indices; + missing_new = count_decompose_new_physical_filaments( + m_result, m_physical_colors, m_filament_types, source_physical_idx, indices); + } + // A result with fewer than 2 components (e.g. target color is already a + // standard base color shown as "100%") creates no mixed filament and no new + // physical filament, so it can never exceed the limit. + const bool creates_mixed = m_result.components.size() >= 2; + // +1 for the mixed filament slot that will be created after decomposition. + const size_t needed = m_current_filament_count + missing_new + 1; + const bool blocked = creates_mixed && needed > m_max_filament_count; + + const bool was_shown = m_limit_warning_panel->IsShown(); + + if (!blocked) { + if (was_shown) { + m_limit_warning_panel->Hide(); + Layout(); + Fit(); + } + return; + } + + wxString mode_name; + switch (m_selected_mode) { + case DecomposeMode::CMYW: mode_name = "CMYW"; break; + case DecomposeMode::RYBW: mode_name = "RYBW"; break; + case DecomposeMode::MaterialList: mode_name = _L("Material List"); break; + } + + const wxString warning_text = format_wxstr( + _L("The material list supports at most %1% colors. After %2% decomposition, the material count would exceed %1%. Please delete unused filaments on the main screen before decomposing."), + m_max_filament_count, mode_name); + + // Show first so the panel is laid out and the text control gets its real + // width, then wrap to that width so the paragraph fills the content area. + m_limit_warning_panel->Show(); + Layout(); + const int avail = m_limit_warning_text->GetClientSize().x; + m_limit_warning_text->SetLabel(warning_text); + if (avail > FromDIP(50)) + m_limit_warning_text->Wrap(avail); + + Layout(); + // Only resize when the warning panel actually toggled from hidden to shown. + // While already visible, switching modes must not re-Fit the dialog, which + // would make it jump on every card switch. Fit keeps the user-moved position. + if (!was_shown) { + Fit(); + } +} + +void ColorDecomposeDialog::set_missing_physical_calculator(std::function fn) +{ + m_missing_calculator = std::move(fn); + update_ok_button_state(); +} + +void ColorDecomposeDialog::update_ok_button_state() +{ + if (!m_btn_ok) return; + update_filament_limit_warning(); + bool any_card_visible = (m_card_material_list && m_card_material_list->IsShown()) + || (m_card_cmyw && m_card_cmyw->IsShown()) + || (m_card_rybw && m_card_rybw->IsShown()); + const bool blocked = m_limit_warning_panel && m_limit_warning_panel->IsShown(); + m_btn_ok->Enable(any_card_visible && !blocked); + Layout(); +} + +void ColorDecomposeDialog::update_mode_card_content(DecomposeMode mode) +{ + auto& controls = m_mode_cards[mode_index(mode)]; + auto* sizer = controls.components_sizer; + auto* card = controls.card; + if (!sizer || !card) + return; + + sizer->Clear(true); + const auto& components = m_mode_results[mode_index(mode)].components; + const size_t count = components.size(); + if (count == 0) { + card->Layout(); + card->Refresh(); + return; + } + + const int swatch_sz = FromDIP(24); + const int plus_gap = FromDIP(24); + const wxFont& ratio_font = Label::Body_13; + auto bind_select = [this, mode](wxWindow* w) { + w->Bind(wxEVT_LEFT_UP, [this, mode](wxMouseEvent&) { + select_mode(mode); + }); + w->SetCursor(wxCursor(wxCURSOR_HAND)); + }; + + for (size_t i = 0; i < count; ++i) { + auto* col = new wxBoxSizer(wxVERTICAL); + auto* swatch = create_color_swatch(card, components[i].colour, swatch_sz); + bind_select(swatch); + col->Add(swatch, 0, wxALIGN_CENTER_HORIZONTAL); + auto* ratio_text = new wxStaticText(card, wxID_ANY, wxString::Format("%d%%", components[i].ratio)); + ratio_text->SetFont(ratio_font); + ratio_text->SetForegroundColour(StateColor::darkModeColorFor(COLOR_TEXT_DARK)); + match_parent_bg(ratio_text, StateColor::darkModeColorFor(COLOR_BG_CARD)); + bind_select(ratio_text); + col->Add(ratio_text, 0, wxALIGN_CENTER_HORIZONTAL | wxTOP, FromDIP(4)); + sizer->Add(col, 0, wxALIGN_TOP); + + if (i + 1 < count) { + sizer->AddStretchSpacer(); + auto* plus_panel = new wxPanel(card, wxID_ANY, wxDefaultPosition, wxSize(plus_gap, swatch_sz)); + plus_panel->SetMinSize(wxSize(plus_gap, swatch_sz)); + plus_panel->SetMaxSize(wxSize(plus_gap, swatch_sz)); + plus_panel->SetBackgroundColour(StateColor::darkModeColorFor(COLOR_BG_CARD)); + auto* plus_sizer = new wxBoxSizer(wxVERTICAL); + auto* plus_label = new wxStaticText(plus_panel, wxID_ANY, "+"); + plus_label->SetFont(Label::Body_13); + plus_label->SetForegroundColour(StateColor::darkModeColorFor(COLOR_TEXT_DARK)); + match_parent_bg(plus_label, StateColor::darkModeColorFor(COLOR_BG_CARD)); + bind_select(plus_panel); + bind_select(plus_label); + plus_sizer->AddStretchSpacer(); + plus_sizer->Add(plus_label, 0, wxALIGN_CENTER_HORIZONTAL); + plus_sizer->AddStretchSpacer(); + plus_panel->SetSizer(plus_sizer); + sizer->Add(plus_panel, 0, wxALIGN_TOP); + sizer->AddStretchSpacer(); + } + } + + const int card_width = FromDIP(128 + (count > 2 ? static_cast(count - 2) * 31 : 0)); + card->SetMinSize(wxSize(card_width, FromDIP(111))); + card->SetMaxSize(wxSize(card_width, FromDIP(111))); + + card->Layout(); + card->Refresh(); +} + +void ColorDecomposeDialog::update_mode_card_contents() +{ + update_mode_card_content(DecomposeMode::MaterialList); + update_mode_card_content(DecomposeMode::CMYW); + update_mode_card_content(DecomposeMode::RYBW); + Layout(); + Fit(); +} + +void ColorDecomposeDialog::update_matched_color_display() +{ + if (!m_result.matched_color.IsOk()) + m_result.matched_color = m_target_color; + + if (m_matched_swatch) { + m_matched_swatch->SetBackgroundColour(m_result.matched_color); + m_matched_swatch->Refresh(); + } + if (m_matched_rgb_text) { + m_matched_rgb_text->SetLabel(wxString::Format("RGB: %d, %d, %d", + m_result.matched_color.Red(), m_result.matched_color.Green(), m_result.matched_color.Blue())); + } +} + +bool ColorDecomposeDialog::try_build_single_base_result(DecomposeMode mode, ColorDecomposeResult& out) const +{ + // Gate by preferred type, matching card visibility: CMYW and RYBW only for PLA Basic. + if (mode == DecomposeMode::CMYW || mode == DecomposeMode::RYBW) { + if (m_preferred_type != kDecomposePlaBasicType) + return false; + } else { + return false; + } + + static const DecomposeBaseColor cmyw_bases[] = { + DecomposeBaseColor::Cyan, DecomposeBaseColor::Magenta, + DecomposeBaseColor::Yellow, DecomposeBaseColor::White + }; + static const DecomposeBaseColor rybw_bases[] = { + DecomposeBaseColor::Red, DecomposeBaseColor::Yellow, + DecomposeBaseColor::Blue, DecomposeBaseColor::White + }; + const DecomposeBaseColor* bases = (mode == DecomposeMode::CMYW) ? cmyw_bases : rybw_bases; + const size_t base_count = (mode == DecomposeMode::CMYW) + ? sizeof(cmyw_bases) / sizeof(cmyw_bases[0]) + : sizeof(rybw_bases) / sizeof(rybw_bases[0]); + + const std::string target_hex = decompose_normalize_color_hex( + m_target_color.GetAsString(wxC2S_HTML_SYNTAX).ToStdString()); + + for (size_t i = 0; i < base_count; ++i) { + const DecomposeBaseColor base = bases[i]; + DecomposeOfficialComponent official = + lookup_decompose_official_component(m_preferred_type, base, pure_color_for_base(base)); + if (decompose_normalize_color_hex(official.color_hex) != target_hex) + continue; + + out = ColorDecomposeResult{}; + out.mode = mode; + out.matched_color = hex_to_wx_colour(official.color_hex, m_target_color); + DecomposeComponent comp; + comp.colour = out.matched_color; + comp.ratio = 100; + comp.filament_index = -1; + comp.base_color = base; + out.components.push_back(comp); + return true; + } + return false; +} + +void ColorDecomposeDialog::compute_decomposition() +{ + auto fallback_result = [this](DecomposeMode mode, const std::vector& components) { + ColorDecomposeResult result; + result.mode = mode; + result.components = components; + int total = 0; + double r = 0.0, g = 0.0, b = 0.0; + for (const auto& comp : result.components) + total += comp.ratio; + if (total <= 0) + total = 100; + for (const auto& comp : result.components) { + const double w = static_cast(comp.ratio) / total; + r += comp.colour.Red() * w; + g += comp.colour.Green() * w; + b += comp.colour.Blue() * w; + } + result.matched_color = result.components.empty() + ? m_target_color + : wxColour(static_cast(std::clamp(r, 0.0, 255.0)), + static_cast(std::clamp(g, 0.0, 255.0)), + static_cast(std::clamp(b, 0.0, 255.0))); + return result; + }; + + std::vector physical_filaments; + physical_filaments.reserve(m_physical_colors.size()); + for (size_t i = 0; i < m_physical_colors.size(); ++i) { + if (m_filament_idx >= 0 && i == static_cast(m_filament_idx)) + continue; + ColorDecomposePhysicalFilament filament; + filament.color_hex = m_physical_colors[i]; + filament.name = i < m_filament_names.size() ? m_filament_names[i] : ""; + filament.type = i < m_filament_types.size() ? m_filament_types[i] : ""; + filament.filament_index = static_cast(i + 1); + physical_filaments.push_back(std::move(filament)); + } + + const ColorDecomposeRgb target_rgb = wx_colour_to_recipe_rgb(m_target_color); + + auto material_recipe = recommend_from_physical_filaments(target_rgb, physical_filaments, m_preferred_type); + if (material_recipe.valid) { + m_mode_results[mode_index(DecomposeMode::MaterialList)] = + to_dialog_result(material_recipe, m_target_color); + } else { + std::vector components; + for (size_t i = 0; i < std::min(2, physical_filaments.size()); ++i) { + DecomposeComponent comp; + comp.colour = wxColour(physical_filaments[i].color_hex); + comp.ratio = 50; + comp.filament_index = static_cast(physical_filaments[i].filament_index); + components.push_back(comp); + } + if (components.empty()) { + components.push_back({m_target_color, 100, -1}); + } else if (components.size() == 1) { + components.front().ratio = 100; + } + m_mode_results[mode_index(DecomposeMode::MaterialList)] = + fallback_result(DecomposeMode::MaterialList, components); + } + + ColorDecomposeResult single_base; + if (try_build_single_base_result(DecomposeMode::CMYW, single_base)) { + m_mode_results[mode_index(DecomposeMode::CMYW)] = single_base; + } else { + auto cmyw_recipe = lookup_standard_recipe(target_rgb, ColorDecomposeRecipeMode::CMYW, m_preferred_type); + m_mode_results[mode_index(DecomposeMode::CMYW)] = cmyw_recipe.valid + ? to_dialog_result(cmyw_recipe, m_target_color) + : fallback_result(DecomposeMode::CMYW, { + {CMYW_YELLOW, 50, -1, DecomposeBaseColor::Yellow}, + {CMYW_CYAN, 50, -1, DecomposeBaseColor::Cyan} + }); + } + + if (try_build_single_base_result(DecomposeMode::RYBW, single_base)) { + m_mode_results[mode_index(DecomposeMode::RYBW)] = single_base; + } else { + auto rybw_recipe = lookup_standard_recipe(target_rgb, ColorDecomposeRecipeMode::RYBW, m_preferred_type); + m_mode_results[mode_index(DecomposeMode::RYBW)] = rybw_recipe.valid + ? to_dialog_result(rybw_recipe, m_target_color) + : fallback_result(DecomposeMode::RYBW, { + {RYBW_YELLOW, 50, -1, DecomposeBaseColor::Yellow}, + {RYBW_BLUE, 50, -1, DecomposeBaseColor::Blue} + }); + } + + m_result = m_mode_results[mode_index(m_selected_mode)]; + update_mode_card_contents(); + update_ok_button_state(); +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/ColorDecomposeDialog.hpp b/src/slic3r/GUI/ColorDecomposeDialog.hpp new file mode 100644 index 0000000000..419dfe8cea --- /dev/null +++ b/src/slic3r/GUI/ColorDecomposeDialog.hpp @@ -0,0 +1,152 @@ +#ifndef slic3r_ColorDecomposeDialog_hpp_ +#define slic3r_ColorDecomposeDialog_hpp_ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "GUI_Utils.hpp" +#include "libslic3r/ColorDecomposeRecipe.hpp" + +class Button; +class CheckBox; +class ComboBox; + +namespace Slic3r { +namespace GUI { + +using DecomposeMode = ColorDecomposeRecipeMode; + +enum class DecomposeBaseColor { + None, + Cyan, + Magenta, + Yellow, + White, + Red, + Green, + Blue +}; + +struct DecomposeComponent { + wxColour colour; + int ratio{50}; // percentage + int filament_index{-1}; // 1-based physical filament index, -1 if standard base color + DecomposeBaseColor base_color{DecomposeBaseColor::None}; +}; + +struct ColorDecomposeResult { + DecomposeMode mode{DecomposeMode::MaterialList}; + wxColour matched_color; + std::vector components; +}; + +class ColorDecomposeDialog : public DPIDialog +{ +public: + ColorDecomposeDialog(wxWindow* parent, + int filament_idx, + const wxColour& target_color, + const std::vector& physical_colors, + const std::vector& filament_names, + const std::vector& filament_types, + size_t current_filament_count = 0, + size_t max_filament_count = 32, + std::vector physical_config_indices = {}); + + ColorDecomposeResult get_result() const { return m_result; } + + // Override the "new physical filaments" count used by the filament-limit + // warning. The Texture import path supplies its own calculator so the + // pre-check shares the exact reuse rule as its write-back (existing + + // virtual physical filaments), instead of the project-config based default + // that cannot see not-yet-committed virtual base colors. + void set_missing_physical_calculator(std::function fn); + +protected: + void on_dpi_changed(const wxRect& suggested_rect) override; + +private: + void build_ui(); + wxBoxSizer* create_filament_selector(); + wxBoxSizer* create_target_color_section(); + wxBoxSizer* create_mode_selection_section(); + wxPanel* create_mode_card(wxWindow* parent, DecomposeMode mode, const wxString& title); + wxBoxSizer* create_button_panel(); + + void select_mode(DecomposeMode mode); + void update_card_styles(); + void update_card_visibility(); + void update_mode_card_content(DecomposeMode mode); + void update_mode_card_contents(); + void update_matched_color_display(); + void update_ok_button_state(); + void update_filament_limit_warning(); + + void compute_decomposition(); + + // When the target color is exactly one of the standard base colors for the + // preferred type, the standard card should show that base at 100% instead of + // a mix. PLA Basic covers CMYW and RYBW. + bool try_build_single_base_result(DecomposeMode mode, ColorDecomposeResult& out) const; + + struct ModeCardControls { + wxPanel* card{nullptr}; + wxBoxSizer* components_sizer{nullptr}; + }; + + ColorDecomposeResult m_result; + std::array m_mode_results; + std::array m_mode_cards; + int m_filament_idx{-1}; + wxColour m_target_color; + std::vector m_physical_colors; + std::vector m_filament_names; + std::vector m_filament_types; + std::vector m_project_types; + std::string m_preferred_type; + // Dropdown selectable item index -> material type string + std::vector m_combo_item_types; + size_t m_current_filament_count{0}; + size_t m_max_filament_count{32}; + std::vector m_physical_config_indices; + std::function m_missing_calculator; + + // UI controls + ComboBox* m_type_combo{nullptr}; + wxPanel* m_target_swatch{nullptr}; + wxStaticText* m_target_rgb_text{nullptr}; + wxPanel* m_matched_swatch{nullptr}; + wxStaticText* m_matched_rgb_text{nullptr}; + + // Mode cards + wxPanel* m_card_material_list{nullptr}; + wxPanel* m_card_cmyw{nullptr}; + wxPanel* m_card_rybw{nullptr}; + wxPanel* m_arb_column_panel{nullptr}; + CheckBox* m_chk_material_list{nullptr}; + CheckBox* m_chk_cmyw{nullptr}; + CheckBox* m_chk_rybw{nullptr}; + DecomposeMode m_selected_mode{DecomposeMode::MaterialList}; + + // Hint shown when no mode card is visible + wxStaticText* m_no_card_hint{nullptr}; + + // Warning shown when decomposition would exceed filament limit + wxPanel* m_limit_warning_panel{nullptr}; + wxStaticText* m_limit_warning_text{nullptr}; + + Button* m_btn_ok{nullptr}; + Button* m_btn_cancel{nullptr}; +}; + +} // namespace GUI +} // namespace Slic3r + +#endif // slic3r_ColorDecomposeDialog_hpp_ diff --git a/src/slic3r/GUI/ColorDecomposeSupport.cpp b/src/slic3r/GUI/ColorDecomposeSupport.cpp new file mode 100644 index 0000000000..e8fb4c9082 --- /dev/null +++ b/src/slic3r/GUI/ColorDecomposeSupport.cpp @@ -0,0 +1,386 @@ +#include "ColorDecomposeSupport.hpp" +#include "MixedFilamentDialog.hpp" +#include "GUI_App.hpp" +#include "MsgDialog.hpp" +#include "I18N.hpp" +#include "libslic3r/Preset.hpp" +#include "libslic3r/PresetBundle.hpp" +#include "libslic3r/Utils.hpp" + +#include "nlohmann/json.hpp" + +#include +#include +#include + +using json = nlohmann::json; + +namespace Slic3r { namespace GUI { + +std::string decompose_normalize_color_hex(std::string color) +{ + if (color.size() >= 7) + color = color.substr(0, 7); + std::transform(color.begin(), color.end(), color.begin(), [](unsigned char c) { + return static_cast(std::toupper(c)); + }); + return color; +} + +const char* decompose_base_color_en(DecomposeBaseColor color) +{ + switch (color) { + case DecomposeBaseColor::Cyan: return "Cyan"; + case DecomposeBaseColor::Magenta: return "Magenta"; + case DecomposeBaseColor::Yellow: return "Yellow"; + case DecomposeBaseColor::White: return "White"; + case DecomposeBaseColor::Red: return "Red"; + case DecomposeBaseColor::Green: return "Green"; + case DecomposeBaseColor::Blue: return "Blue"; + default: return ""; + } +} + +wxString decompose_base_color_display(DecomposeBaseColor color) +{ + switch (color) { + case DecomposeBaseColor::Cyan: return _L("Cyan"); + case DecomposeBaseColor::Magenta: return _L("Magenta"); + case DecomposeBaseColor::Yellow: return _L("Yellow"); + case DecomposeBaseColor::White: return _L("White"); + case DecomposeBaseColor::Red: return _L("Red"); + case DecomposeBaseColor::Green: return _L("Green"); + case DecomposeBaseColor::Blue: return _L("Blue"); + default: return wxString(); + } +} + +std::string decompose_basic_type_from_source(size_t source_config_idx, + size_t source_physical_idx, + const std::vector& physical_types) +{ + auto& project_config = wxGetApp().preset_bundle->project_config; + if (auto* filament_id_opt = project_config.option("filament_id")) { + if (source_config_idx < filament_id_opt->values.size()) { + const std::string& filament_id = filament_id_opt->values[source_config_idx]; + if (filament_id == kDecomposePetgFilamentId) + return kDecomposePetgBasicType; + if (filament_id == kDecomposePlaFilamentId) + return kDecomposePlaBasicType; + } + } + + if (source_physical_idx < physical_types.size()) { + const std::string& type = physical_types[source_physical_idx]; + if (type == kDecomposePetgShortType || type == kDecomposePetgBasicType) + return kDecomposePetgBasicType; + if (type == kDecomposePlaShortType || type == kDecomposePlaBasicType) + return kDecomposePlaBasicType; + } + return kDecomposePlaBasicType; +} + +std::string decompose_basic_filament_id(const std::string& basic_type) +{ + if (basic_type == kDecomposePetgBasicType) + return kDecomposePetgFilamentId; + return kDecomposePlaFilamentId; +} + +void set_created_standard_component_metadata(size_t config_idx, const DecomposeOfficialComponent& component) +{ + auto& project_config = wxGetApp().preset_bundle->project_config; + if (!component.filament_id.empty()) { + if (auto* filament_id_opt = project_config.option("filament_id")) { + while (filament_id_opt->values.size() <= config_idx) + filament_id_opt->values.push_back(""); + filament_id_opt->values[config_idx] = component.filament_id; + } + } + + const std::string type = component.filament_id == kDecomposePetgFilamentId ? kDecomposePetgShortType : + component.filament_id == kDecomposePlaFilamentId ? kDecomposePlaShortType : ""; + if (!type.empty()) { + if (auto* type_opt = project_config.option("filament_type")) { + while (type_opt->values.size() <= config_idx) + type_opt->values.push_back(""); + type_opt->values[config_idx] = type; + } + } +} + +DecomposeOfficialComponent lookup_decompose_official_component( + const std::string& basic_type, + DecomposeBaseColor base_color, + const wxColour& fallback) +{ + DecomposeOfficialComponent result; + result.base_color = base_color; + result.color_hex = decompose_normalize_color_hex(fallback.GetAsString(wxC2S_HTML_SYNTAX).ToStdString()); + result.filament_id = decompose_basic_filament_id(basic_type); + + const char* color_name = decompose_base_color_en(base_color); + if (color_name[0] == '\0') + return result; + + // Some materials name a standard base color differently in the color-code + // table. PETG Basic's RYBW blue base is "Reflex Blue" (deep blue, B00, + // #001489), not "Blue". Match by an ordered list of exact English names so + // "Navy Blue" (B01, #0086D6) is never picked up by mistake. + std::vector candidate_names; + candidate_names.emplace_back(color_name); + if (base_color == DecomposeBaseColor::Blue && basic_type == kDecomposePetgBasicType) + candidate_names.emplace_back("Reflex Blue"); + + std::ifstream ifs(resources_dir() + "/profiles/BBL/filament/filaments_color_codes.json"); + if (!ifs) + return result; + + json root = json::parse(ifs, nullptr, false); + if (root.is_discarded() || !root.contains("data") || !root["data"].is_array()) + return result; + + for (const std::string& candidate : candidate_names) { + for (const auto& item : root["data"]) { + if (!item.is_object() || item.value("fila_type", "") != basic_type) + continue; + if (!item.contains("fila_color_name")) + continue; + const auto& names = item["fila_color_name"]; + if (!names.is_object() || names.value("en", "") != candidate) + continue; + if (item.contains("fila_color") && item["fila_color"].is_array() && !item["fila_color"].empty()) + result.color_hex = decompose_normalize_color_hex(item["fila_color"][0].get()); + result.filament_id = item.value("fila_id", result.filament_id); + return result; + } + } + return result; +} + +std::string find_decompose_standard_preset_name(size_t source_config_idx, const std::string& basic_type) +{ + const PresetBundle& preset_bundle = *wxGetApp().preset_bundle; + if (source_config_idx < preset_bundle.filament_presets.size()) { + const std::string& source_name = preset_bundle.filament_presets[source_config_idx]; + if (source_name.find(std::string(kDecomposeBambuPresetPrefix) + basic_type) != std::string::npos) + return source_name; + } + + const std::string prefix = std::string(kDecomposeBambuPresetPrefix) + basic_type + " @BBL "; + for (const std::string& preset_name : preset_bundle.filament_presets) { + if (preset_name.find(prefix) == 0) + return preset_name; + } + + return {}; +} + +std::string official_basic_type_from_preset_name(const std::string& preset_name) +{ + if (preset_name.find(std::string(kDecomposeBambuPresetPrefix) + kDecomposePlaBasicType) != std::string::npos) + return kDecomposePlaBasicType; + if (preset_name.find(std::string(kDecomposeBambuPresetPrefix) + kDecomposePetgBasicType) != std::string::npos) + return kDecomposePetgBasicType; + return {}; +} + +std::string filament_type_for_color_decompose(Preset* preset) +{ + if (!preset) + return kDecomposePlaShortType; + + std::string display_type; + std::string ft = preset->config.get_filament_type(display_type); + const std::string basic = official_basic_type_from_preset_name(preset->name); + if (!basic.empty()) + ft = basic; + if (ft.empty()) + ft = kDecomposePlaShortType; + return ft; +} + +int find_existing_decompose_component( + const DecomposeOfficialComponent& component, + const std::vector& physical_colors, + const std::vector& physical_config_indices, + size_t source_config_idx) +{ + auto& project_config = wxGetApp().preset_bundle->project_config; + auto* filament_id_opt = project_config.option("filament_id"); + auto* type_opt = project_config.option("filament_type"); + const PresetBundle& preset_bundle = *wxGetApp().preset_bundle; + const size_t num_physical = physical_colors.size(); + const std::string expected_basic_type = component.filament_id == kDecomposePetgFilamentId ? kDecomposePetgBasicType : + component.filament_id == kDecomposePlaFilamentId ? kDecomposePlaBasicType : ""; + const std::string expected_short_type = expected_basic_type == kDecomposePetgBasicType ? kDecomposePetgShortType : + expected_basic_type == kDecomposePlaBasicType ? kDecomposePlaShortType : ""; + const std::string expected_preset_part = expected_basic_type.empty() ? "" : std::string(kDecomposeBambuPresetPrefix) + expected_basic_type; + for (size_t i = 0; i < num_physical && i < physical_config_indices.size(); ++i) { + const size_t config_idx = physical_config_indices[i]; + const std::string slot_color = decompose_normalize_color_hex(physical_colors[i]); + const std::string slot_filament_id = (filament_id_opt && config_idx < filament_id_opt->values.size()) ? filament_id_opt->values[config_idx] : ""; + const std::string slot_type = (type_opt && config_idx < type_opt->values.size()) ? type_opt->values[config_idx] : ""; + const std::string preset_name = config_idx < preset_bundle.filament_presets.size() ? preset_bundle.filament_presets[config_idx] : ""; + if (config_idx == source_config_idx) { + continue; + } + if (slot_color != component.color_hex) { + continue; + } + + if (!component.filament_id.empty() && slot_filament_id == component.filament_id) { + return static_cast(config_idx + 1); + } + + if (!expected_basic_type.empty() && (slot_type == expected_basic_type || slot_type == expected_short_type)) { + return static_cast(config_idx + 1); + } + + if (!expected_preset_part.empty() && preset_name.find(expected_preset_part) != std::string::npos) { + return static_cast(config_idx + 1); + } + + const bool has_material_hint = !slot_filament_id.empty() || !slot_type.empty() || !preset_name.empty(); + if (!expected_basic_type.empty() && has_material_hint) + continue; + + return static_cast(config_idx + 1); + } + return -1; +} + +bool prepare_decompose_mixed_result( + const ColorDecomposeResult& result, + size_t source_config_idx, + size_t source_physical_idx, + const std::vector& physical_colors, + const std::vector& physical_types, + const std::vector& physical_config_indices, + MixedFilamentResult& out_result, + std::vector& missing) +{ + out_result = {}; + missing.clear(); + if (result.components.size() < 2) { + return false; + } + + const bool standard_mode = result.mode == DecomposeMode::CMYW || result.mode == DecomposeMode::RYBW; + std::string basic_type; + std::string preset_name; + if (standard_mode) { + basic_type = decompose_basic_type_from_source(source_config_idx, source_physical_idx, physical_types); + preset_name = find_decompose_standard_preset_name(source_config_idx, basic_type); + } + + for (size_t i = 0; i < result.components.size(); ++i) { + const DecomposeComponent& comp = result.components[i]; + out_result.ratios.push_back(comp.ratio); + if (!standard_mode) { + if (comp.filament_index <= 0) { + return false; + } + const size_t physical_idx = static_cast(comp.filament_index - 1); + if (physical_idx >= physical_config_indices.size()) { + return false; + } + out_result.components.push_back(static_cast(physical_config_indices[physical_idx] + 1)); + continue; + } + + if (comp.base_color == DecomposeBaseColor::None) { + return false; + } + DecomposeOfficialComponent official_component = + lookup_decompose_official_component(basic_type, comp.base_color, comp.colour); + int existing_idx = find_existing_decompose_component(official_component, physical_colors, + physical_config_indices, source_config_idx); + if (existing_idx > 0) { + out_result.components.push_back(static_cast(existing_idx)); + continue; + } + + DecomposeMissingComponent missing_comp; + missing_comp.component_idx = out_result.components.size(); + missing_comp.official_component = official_component; + missing_comp.preset_name = preset_name; + missing_comp.display_name = decompose_base_color_display(comp.base_color) + + wxString::FromUTF8(" ") + wxString::FromUTF8(basic_type); + missing.push_back(std::move(missing_comp)); + out_result.components.push_back(0); + } + + const bool ok = out_result.components.size() == out_result.ratios.size() && out_result.components.size() >= 2; + return ok; +} + +size_t count_decompose_new_physical_filaments( + const ColorDecomposeResult& result, + const std::vector& physical_colors, + const std::vector& physical_types, + size_t source_physical_idx, + const std::vector* physical_config_indices) +{ + if (result.mode != DecomposeMode::CMYW && result.mode != DecomposeMode::RYBW) + return 0; + + std::vector fallback_indices; + const std::vector* indices = physical_config_indices; + if (!indices) { + fallback_indices.resize(physical_colors.size()); + for (size_t i = 0; i < fallback_indices.size(); ++i) + fallback_indices[i] = i; + indices = &fallback_indices; + } + + size_t source_config_idx = size_t(-1); + if (source_physical_idx < indices->size()) + source_config_idx = (*indices)[source_physical_idx]; + + const std::string basic_type = + decompose_basic_type_from_source(source_config_idx, source_physical_idx, physical_types); + + size_t missing_count = 0; + for (const DecomposeComponent& comp : result.components) { + if (comp.base_color == DecomposeBaseColor::None) + continue; + DecomposeOfficialComponent official_component = + lookup_decompose_official_component(basic_type, comp.base_color, comp.colour); + int existing_idx = find_existing_decompose_component(official_component, physical_colors, + *indices, source_config_idx); + if (existing_idx <= 0) + ++missing_count; + } + return missing_count; +} + +bool confirm_create_decompose_missing_components(wxWindow* parent, const std::vector& missing) +{ + if (missing.empty()) + return true; + + static const char* config_key = "not_show_color_decompose_missing_component_tip"; + if (wxGetApp().app_config->get(config_key) == "1") { + return true; + } + + wxString missing_text; + for (size_t i = 0; i < missing.size(); ++i) { + if (i > 0) + missing_text += _L(", "); + missing_text += missing[i].display_name; + } + + wxString message = _L("The current filament list does not contain ") + missing_text + + _L(". A project filament required by the mixed filament will be created automatically after decomposition."); + + MessageDialog dlg(parent, message, _L("Tip"), wxOK | wxCANCEL | wxICON_INFORMATION); + dlg.show_dsa_button(); + int res = dlg.ShowModal(); + if (res == wxID_OK && dlg.get_checkbox_state()) + wxGetApp().app_config->set(config_key, "1"); + return res == wxID_OK; +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/ColorDecomposeSupport.hpp b/src/slic3r/GUI/ColorDecomposeSupport.hpp new file mode 100644 index 0000000000..a982c51303 --- /dev/null +++ b/src/slic3r/GUI/ColorDecomposeSupport.hpp @@ -0,0 +1,104 @@ +#ifndef slic3r_GUI_ColorDecomposeSupport_hpp_ +#define slic3r_GUI_ColorDecomposeSupport_hpp_ + +#include +#include +#include +#include +#include "ColorDecomposeDialog.hpp" + +class wxWindow; + +namespace Slic3r { +class Preset; +namespace GUI { + +// ---- Constants ---- + +inline constexpr const char* kDecomposePlaBasicType = "PLA Basic"; +inline constexpr const char* kDecomposePetgBasicType = "PETG Basic"; +inline constexpr const char* kDecomposePlaShortType = "PLA"; +inline constexpr const char* kDecomposePetgShortType = "PETG"; +inline constexpr const char* kDecomposePlaFilamentId = "GFA00"; +inline constexpr const char* kDecomposePetgFilamentId = "GFG00"; +inline constexpr const char* kDecomposeBambuPresetPrefix = "Bambu "; + +// ---- Types ---- + +struct DecomposeOfficialComponent { + DecomposeBaseColor base_color{DecomposeBaseColor::None}; + std::string color_hex; + std::string filament_id; +}; + +struct DecomposeMissingComponent { + size_t component_idx{0}; + DecomposeOfficialComponent official_component; + std::string preset_name; + wxString display_name; +}; + +struct MixedFilamentResult; + +// ---- Functions ---- + +std::string decompose_normalize_color_hex(std::string color); + +const char* decompose_base_color_en(DecomposeBaseColor color); + +wxString decompose_base_color_display(DecomposeBaseColor color); + +std::string decompose_basic_type_from_source(size_t source_config_idx, + size_t source_physical_idx, + const std::vector& physical_types); + +std::string decompose_basic_filament_id(const std::string& basic_type); + +void set_created_standard_component_metadata(size_t config_idx, const DecomposeOfficialComponent& component); + +DecomposeOfficialComponent lookup_decompose_official_component( + const std::string& basic_type, + DecomposeBaseColor base_color, + const wxColour& fallback); + +std::string find_decompose_standard_preset_name(size_t source_config_idx, const std::string& basic_type); + +// Returns "PLA Basic" / "PETG Basic" when preset_name names an official Bambu +// basic filament, else an empty string. +std::string official_basic_type_from_preset_name(const std::string& preset_name); + +// Resolve display type for color-decompose: official Bambu Basic overrides +// get_filament_type when preset name matches; empty/missing -> "PLA". +std::string filament_type_for_color_decompose(Preset* preset); + +int find_existing_decompose_component( + const DecomposeOfficialComponent& component, + const std::vector& physical_colors, + const std::vector& physical_config_indices, + size_t source_config_idx); + +bool prepare_decompose_mixed_result( + const ColorDecomposeResult& result, + size_t source_config_idx, + size_t source_physical_idx, + const std::vector& physical_colors, + const std::vector& physical_types, + const std::vector& physical_config_indices, + MixedFilamentResult& out_result, + std::vector& missing); + +// For standard modes: how many base colors are not reusable from physical list. +// MaterialList returns 0. When physical_config_indices is null, indices are 0..n-1. +size_t count_decompose_new_physical_filaments( + const ColorDecomposeResult& result, + const std::vector& physical_colors, + const std::vector& physical_types, + size_t source_physical_idx, + const std::vector* physical_config_indices); + +bool confirm_create_decompose_missing_components(wxWindow* parent, + const std::vector& missing); + +}} // namespace Slic3r::GUI + +#endif // slic3r_GUI_ColorDecomposeSupport_hpp_ diff --git a/src/slic3r/GUI/ConfigManipulation.cpp b/src/slic3r/GUI/ConfigManipulation.cpp index de94bb6b4b..d15164ef63 100644 --- a/src/slic3r/GUI/ConfigManipulation.cpp +++ b/src/slic3r/GUI/ConfigManipulation.cpp @@ -577,22 +577,67 @@ void ConfigManipulation::update_print_fff_config(DynamicPrintConfig* config, con } // BBS - static const char* keys[] = { "support_filament", "support_interface_filament"}; - for (int i = 0; i < sizeof(keys) / sizeof(keys[0]); i++) { - std::string key = std::string(keys[i]); + // Reset filament overrides pointing at a slot that no longer exists. Support and the wipe + // tower additionally reject mixed slots: the engine consumes those keys directly, so a virtual + // slot would reach the G-code unresolved, while the per-feature keys are resolved per layer. + static const char* physical_only_keys[] = { "support_filament", "support_interface_filament", "wipe_tower_filament" }; + static const char* feature_keys[] = { "outer_wall_filament_id", "inner_wall_filament_id", + "sparse_infill_filament_id", "internal_solid_filament_id", + "top_surface_filament_id", "bottom_surface_filament_id" }; + auto reset_invalid_filament = [this, config, filament_cnt](const char* key, bool allow_mixed) { auto* opt = dynamic_cast(config->option(key, false)); - if (opt != nullptr) { - if (opt->getInt() > filament_cnt) { - DynamicPrintConfig new_conf = *config; - const DynamicPrintConfig *conf_temp = wxGetApp().plater()->config(); - int new_value = 0; - if (conf_temp != nullptr && conf_temp->has(key)) { - new_value = conf_temp->opt_int(key); + if (opt == nullptr) + return; + const int val = opt->getInt(); + const bool out_of_range = val > filament_cnt; + const bool is_mixed = !allow_mixed && val > 0 && val <= filament_cnt && + wxGetApp().preset_bundle->is_mixed_filament(val - 1); + if (!out_of_range && !is_mixed) + return; + DynamicPrintConfig new_conf = *config; + int new_value = 0; + if (out_of_range) { + const DynamicPrintConfig *conf_temp = wxGetApp().plater()->config(); + if (conf_temp != nullptr && conf_temp->has(key)) + new_value = conf_temp->opt_int(key); + } + new_conf.set_key_value(key, new ConfigOptionInt(new_value)); + apply(config, &new_conf); + }; + for (const char* key : physical_only_keys) + reset_invalid_filament(key, false); + for (const char* key : feature_keys) + reset_invalid_filament(key, true); + + // Sub-layer splitting divides each layer by the mix ratio; an adaptive layer profile makes + // those sub-layer heights vary per layer, which degrades the blend. Warn once per enable. + { + static bool s_mixed_sublayer_warned = false; + bool sublayer_on = config->opt_bool("enable_mixed_color_sublayer"); + if (sublayer_on && !s_mixed_sublayer_warned && + wxGetApp().app_config->get("no_warn_mixed_sublayer_variable_layer") != "1") { + bool has_variable_layer = false; + for (const auto* obj : wxGetApp().model().objects) { + if (obj->layer_height_profile.get().size() > 4) { + has_variable_layer = true; + break; } - new_conf.set_key_value(key, new ConfigOptionInt(new_value)); - apply(config, &new_conf); + } + if (has_variable_layer) { + MessageDialog dialog(m_msg_dlg_parent, + _L("Using variable layer height together with mixed color sublayer may result in poor color mixing quality."), + "", wxICON_WARNING | wxOK); + dialog.show_dsa_button(); + is_msg_dlg_already_exist = true; + dialog.ShowModal(); + is_msg_dlg_already_exist = false; + if (dialog.get_checkbox_state()) + wxGetApp().app_config->set("no_warn_mixed_sublayer_variable_layer", "1"); + s_mixed_sublayer_warned = true; } } + if (!sublayer_on) + s_mixed_sublayer_warned = false; } if (config->opt_enum("seam_slope_type") != SeamScarfType::None && diff --git a/src/slic3r/GUI/DeviceCore/DevFirmware.h b/src/slic3r/GUI/DeviceCore/DevFirmware.h index 9dae603702..5b0ea986a2 100644 --- a/src/slic3r/GUI/DeviceCore/DevFirmware.h +++ b/src/slic3r/GUI/DeviceCore/DevFirmware.h @@ -64,7 +64,7 @@ public: DevFirmware(MachineObject* obj) : m_owner(obj) {} private: - MachineObject* m_owner = nullptr; + [[maybe_unused]] MachineObject* m_owner = nullptr; }; } // namespace Slic3r \ No newline at end of file diff --git a/src/slic3r/GUI/DeviceTab/uiAMSBestPositionPopup.cpp b/src/slic3r/GUI/DeviceTab/uiAMSBestPositionPopup.cpp index 103584602f..a258a2178a 100644 --- a/src/slic3r/GUI/DeviceTab/uiAMSBestPositionPopup.cpp +++ b/src/slic3r/GUI/DeviceTab/uiAMSBestPositionPopup.cpp @@ -2,7 +2,7 @@ /* File: uiAMSBestPositionPopup.hpp * Description: The popup with suggest best ams position * -//**********************************************************/ +************************************************************/ #include "uiAMSBestPositionPopup.hpp" diff --git a/src/slic3r/GUI/DeviceTab/uiAMSBestPositionPopup.hpp b/src/slic3r/GUI/DeviceTab/uiAMSBestPositionPopup.hpp index 427a5c6a73..18dd3c4301 100644 --- a/src/slic3r/GUI/DeviceTab/uiAMSBestPositionPopup.hpp +++ b/src/slic3r/GUI/DeviceTab/uiAMSBestPositionPopup.hpp @@ -2,7 +2,7 @@ /* File: uiAMSBestPositionPopup.hpp * Description: The popup with suggest best ams position * -//**********************************************************/ +************************************************************/ #pragma once #include "slic3r/GUI/Widgets/AMSItem.hpp" diff --git a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRack.cpp b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRack.cpp index a62277a858..1e40a71150 100644 --- a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRack.cpp +++ b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRack.cpp @@ -6,7 +6,7 @@ * \n class wgtDeviceNozzleRackNozzleItem; * \n class wgtDeviceNozzleRackToolHead; * \n class wgtDeviceNozzleRackPos; -//**********************************************************/ +************************************************************/ #include "wgtDeviceNozzleRack.h" #include "wgtDeviceNozzleRackUpdate.h" diff --git a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRack.h b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRack.h index fe12b8bc50..385fa6be48 100644 --- a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRack.h +++ b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRack.h @@ -6,7 +6,7 @@ * \n class wgtDeviceNozzleRackNozzleItem; * \n class wgtDeviceNozzleRackToolHead; * \n class wgtDeviceNozzleRackPos; -//**********************************************************/ +************************************************************/ #pragma once #include "slic3r/GUI/DeviceCore/DevNozzleRack.h" diff --git a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRackUpdate.cpp b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRackUpdate.cpp index 2750ad6323..fac14e31d9 100644 --- a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRackUpdate.cpp +++ b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRackUpdate.cpp @@ -3,7 +3,7 @@ * Description: The panel with rack updating * * \n class wgtDeviceNozzleRackUpdate -//**********************************************************/ +************************************************************/ #include "wgtDeviceNozzleRackUpdate.h" diff --git a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRackUpdate.h b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRackUpdate.h index 0fa07fd63a..8275638831 100644 --- a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRackUpdate.h +++ b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleRackUpdate.h @@ -3,7 +3,7 @@ * Description: The panel for updating hotends * * \n class wgtDeviceNozzleRackUpdate -//**********************************************************/ +************************************************************/ #pragma once #include "slic3r/GUI/DeviceCore/DevNozzleRack.h" diff --git a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.cpp b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.cpp index 0d61cfd144..c383815f8c 100644 --- a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.cpp +++ b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.cpp @@ -3,7 +3,7 @@ * Description: The panel to select nozzle * * \n class wgtDeviceNozzleSelect; -//**********************************************************/ +************************************************************/ #include "wgtDeviceNozzleSelect.h" #include "wgtDeviceNozzleRack.h" diff --git a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.h b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.h index 3ff866f3a1..729d24a03d 100644 --- a/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.h +++ b/src/slic3r/GUI/DeviceTab/wgtDeviceNozzleSelect.h @@ -3,7 +3,7 @@ * Description: The panel to select nozzle * * \n class wgtDeviceNozzleSelect; -//**********************************************************/ +************************************************************/ #pragma once diff --git a/src/slic3r/GUI/Field.hpp b/src/slic3r/GUI/Field.hpp index e57a569561..5d5d549427 100644 --- a/src/slic3r/GUI/Field.hpp +++ b/src/slic3r/GUI/Field.hpp @@ -385,7 +385,7 @@ public: wxWindow* window{ nullptr }; void BUILD() override; /// Propagate value from field to the OptionGroupe and Config after kill_focus/ENTER - void propagate_value() ; + void propagate_value() override; void set_value(const std::string& value, bool change_event = false) { m_disable_change_event = !change_event; @@ -440,7 +440,7 @@ public: wxWindow* window{ nullptr }; void BUILD() override; // Propagate value from field to the OptionGroupe and Config after kill_focus/ENTER - void propagate_value(); + void propagate_value() override; /* Under OSX: wxBitmapComboBox->GetWindowStyle() returns some weard value, * so let use a flag, which has TRUE value for a control without wxCB_READONLY style diff --git a/src/slic3r/GUI/FilamentBitmapUtils.cpp b/src/slic3r/GUI/FilamentBitmapUtils.cpp index 9b43647ac0..1f51fc79b3 100644 --- a/src/slic3r/GUI/FilamentBitmapUtils.cpp +++ b/src/slic3r/GUI/FilamentBitmapUtils.cpp @@ -4,7 +4,10 @@ #include #include "EncodedFilament.hpp" +#include "FilamentBitmapUtils.hpp" #include "GUI_App.hpp" +#include "libslic3r/FilamentMixer.hpp" +#include "libslic3r/PrintConfig.hpp" namespace Slic3r { namespace GUI { @@ -28,6 +31,113 @@ void fill_gradient_rect_east(wxDC& dc, const wxRect& rect, const wxColour& from, } } +static std::string to_hex(const wxColour& c) +{ + return wxString::Format("#%02X%02X%02X", c.Red(), c.Green(), c.Blue()).ToStdString(); +} + +wxColour blend_n_colors(const std::vector& cols, const std::vector& weights) +{ + const size_t n = std::min(cols.size(), weights.size()); + std::vector hex_colors; + std::vector int_weights; + hex_colors.reserve(n); + int_weights.reserve(n); + for (size_t i = 0; i < n; ++i) { + hex_colors.push_back(to_hex(cols[i])); + // Scale double weights (e.g. 0.5) to int (5000) for blend_color_multi; + // only relative magnitude matters. + int_weights.push_back(static_cast(std::lround(weights[i] * 10000.0))); + } + wxColour blended(Slic3r::blend_color_multi(hex_colors, int_weights)); + return blended.IsOk() ? blended : wxColour(128, 128, 128); +} + +std::vector sample_gradient_ramp(const wxColour& first, + const wxColour& second, + const Slic3r::GradientCurve& curve, + int steps) +{ + std::vector ramp; + if (steps <= 0 || curve.points.size() < 2) return ramp; + + ramp.reserve(steps); + for (int i = 0; i < steps; ++i) { + const double t = (steps > 1) ? (i + 0.5) / steps : 0.5; + const double r1 = Slic3r::sample_gradient_curve(curve, t); + ramp.push_back(blend_n_colors({first, second}, {r1, 1.0 - r1})); + } + return ramp; +} + +// Resolve the curve a gradient slot is sampled with, mirroring the slicer's fallback in +// ToolOrdering: a custom curve wins, otherwise a straight line between gradient_range's +// endpoints, otherwise the 0.10 -> 0.90 default. +static Slic3r::GradientCurve mixed_gradient_curve(const Slic3r::DynamicPrintConfig& cfg, size_t slot) +{ + const auto* curve_opt = cfg.option("filament_mixed_gradient_curve"); + if (curve_opt && slot < curve_opt->values.size() && !curve_opt->values[slot].empty()) { + Slic3r::GradientCurve custom = Slic3r::parse_gradient_curve(curve_opt->values[slot]); + if (custom.points.size() >= 2) return custom; + } + + double start = kGradientMinRatio, end = kGradientMaxRatio; + const auto* range_opt = cfg.option("filament_mixed_gradient_range"); + if (range_opt && slot < range_opt->values.size() && !range_opt->values[slot].empty()) { + CNumericLocalesSetter c_locale_setter; + float v0 = 0, v1 = 0; + if (std::sscanf(range_opt->values[slot].c_str(), "%f,%f", &v0, &v1) == 2 && + v0 > 0 && v0 < 1.0 && v1 > 0 && v1 < 1.0) { + start = v0; + end = v1; + } + } + + Slic3r::GradientCurve curve; + curve.points = {{0.0, start, NAN, NAN}, {1.0, end, NAN, NAN}}; + return curve; +} + +std::vector mixed_gradient_ramp(const Slic3r::DynamicPrintConfig& cfg, size_t slot, int steps) +{ + const auto* is_mixed_opt = cfg.option("filament_is_mixed"); + const auto* grad_opt = cfg.option("filament_mixed_gradient"); + const auto* comp_opt = cfg.option("filament_mixed_components"); + const auto* colour_opt = cfg.option("filament_colour"); + if (!is_mixed_opt || !grad_opt || !comp_opt || !colour_opt) return {}; + if (slot >= is_mixed_opt->values.size() || !is_mixed_opt->values[slot]) return {}; + if (slot >= grad_opt->values.size() || !grad_opt->values[slot]) return {}; + if (slot >= comp_opt->values.size()) return {}; + + // Only two-component slots fade; anything else stays on the plain blended swatch. + const auto comp_ids = Slic3r::parse_mixed_components(comp_opt->values[slot]); + if (comp_ids.size() != 2) return {}; + + auto component_colour = [&](unsigned int id) { + wxColour c = (id >= 1 && id <= colour_opt->values.size()) ? wxColour(colour_opt->values[id - 1]) : wxColour(); + return c.IsOk() ? c : wxColour("#D9D9D9"); + }; + + // Both gradient_range and the curve express the *first* component's ratio over Z, so + // the components stay in config order and the curve alone decides which end is which. + return sample_gradient_ramp(component_colour(comp_ids[0]), component_colour(comp_ids[1]), + mixed_gradient_curve(cfg, slot), steps); +} + +void fill_gradient_ramp_rect(wxDC& dc, const wxRect& rect, const std::vector& ramp) +{ + if (rect.width <= 0 || rect.height <= 0 || ramp.empty()) return; + + dc.SetPen(*wxTRANSPARENT_PEN); + for (int y = 0; y < rect.height; ++y) { + // Row 0 is the top of the rect and so takes the ramp's last entry, the model's top. + // Mapping over height - 1 puts both ends of the ramp on screen even in a short swatch. + const double t = (rect.height > 1) ? (double) (rect.height - 1 - y) / (rect.height - 1) : 0.5; + dc.SetBrush(wxBrush(ramp[static_cast(t * (ramp.size() - 1) + 0.5)])); + dc.DrawRectangle(rect.x, rect.y + y, rect.width, 1); + } +} + // Helper struct to hold bitmap and DC struct BitmapDC { wxBitmap bitmap; @@ -47,6 +157,19 @@ static BitmapDC init_bitmap_dc(const wxSize& size) { return BitmapDC(size); } +wxBitmap create_gradient_ramp_bitmap(const std::vector& ramp, const wxSize& size) +{ + if (ramp.empty()) return wxNullBitmap; + + BitmapDC bdc = init_bitmap_dc(size); + if (!bdc.dc.IsOk()) return wxNullBitmap; + + fill_gradient_ramp_rect(bdc.dc, wxRect(0, 0, size.GetWidth(), size.GetHeight()), ramp); + + bdc.dc.SelectObject(wxNullBitmap); + return bdc.bitmap; +} + // Check if a color is transparent (alpha == 0) static bool is_transparent_color(const wxColour& color) { return color.Alpha() == 0; @@ -265,4 +388,65 @@ wxBitmap create_filament_bitmap(const std::vector& colors, const wxSiz } } +void recompute_mixed_slot_colors(std::vector& colors, + const Slic3r::DynamicPrintConfig& cfg) +{ + const auto* is_mixed_opt = cfg.option("filament_is_mixed"); + const auto* comp_opt = cfg.option("filament_mixed_components"); + const auto* ratio_opt = cfg.option("filament_mixed_sublayer_ratios"); + const auto* grad_opt = cfg.option("filament_mixed_gradient"); + if (!is_mixed_opt || !comp_opt) return; + + const size_t n = is_mixed_opt->values.size(); + if (colors.size() < n) colors.resize(n); + + const auto* colour_opt = cfg.option("filament_colour"); + const auto kFallback = wxColour(128, 128, 128, 255); + + for (size_t i = 0; i < n; ++i) { + if (!is_mixed_opt->values[i]) continue; + + if (i >= comp_opt->values.size()) { colors[i] = kFallback; continue; } + auto comp_ids = Slic3r::parse_mixed_components(comp_opt->values[i]); + if (comp_ids.empty()) { colors[i] = kFallback; continue; } + + bool is_gradient = grad_opt && i < grad_opt->values.size() && grad_opt->values[i]; + std::vector use_ids = comp_ids; + std::vector weights; + + if (is_gradient && comp_ids.size() >= 2) { + use_ids = { comp_ids.front(), comp_ids.back() }; + weights = { 5000, 5000 }; + } else { + auto ratios_d = Slic3r::parse_mixed_ratios( + (ratio_opt && i < ratio_opt->values.size()) ? ratio_opt->values[i] : std::string{}, + comp_ids.size()); + weights.reserve(comp_ids.size()); + for (double r : ratios_d) + weights.push_back(static_cast(std::lround(r * 10000.0))); + } + + std::vector hex_colors; + hex_colors.reserve(use_ids.size()); + bool any_invalid = false; + for (unsigned int id : use_ids) { + if (id == 0 || id > colors.size()) { any_invalid = true; break; } + wxColour c = colors[id - 1]; + if (c.IsOk() && (c.Red() > 0 || c.Green() > 0 || c.Blue() > 0)) { + hex_colors.push_back(to_hex(c)); + } else if (colour_opt && (id - 1) < colour_opt->values.size()) { + hex_colors.push_back(colour_opt->values[id - 1]); + } else { + any_invalid = true; break; + } + } + if (any_invalid) { colors[i] = kFallback; continue; } + + std::string hex = Slic3r::blend_color_multi(hex_colors, weights); + wxColour blended(hex); + if (!blended.IsOk()) blended = kFallback; + colors[i] = wxColour(blended.Red(), blended.Green(), blended.Blue(), 255); + } +} + }} // namespace Slic3r::GUI \ No newline at end of file diff --git a/src/slic3r/GUI/FilamentBitmapUtils.hpp b/src/slic3r/GUI/FilamentBitmapUtils.hpp index 87d5b275cc..11696f3401 100644 --- a/src/slic3r/GUI/FilamentBitmapUtils.hpp +++ b/src/slic3r/GUI/FilamentBitmapUtils.hpp @@ -7,6 +7,10 @@ #include #include +// Orca: forward-declare so the header is self-contained outside libslic3r_gui's +// force-included pch (the GUI test suite includes it directly). +namespace Slic3r { class DynamicPrintConfig; struct GradientCurve; } + namespace Slic3r { namespace GUI { // Fills a rect with a west->east linear gradient by drawing solid 1px columns. @@ -28,6 +32,37 @@ wxBitmap create_filament_bitmap(const std::vector& colors, const wxSize& size, bool force_gradient = false); +// Blend colours at the given relative weights through blend_color_multi, so a measured +// real-world mix is used where one exists instead of a plain channel lerp. +wxColour blend_n_colors(const std::vector& cols, const std::vector& weights); + +// Sample a gradient mixed filament the way the slicer builds it: t runs 0..1 over the +// model's height, the curve gives the first component's ratio at t, and the two +// components are blended at that ratio through blend_n_colors. Entry 0 is the bottom +// of the model, the last entry its top. +std::vector sample_gradient_ramp(const wxColour& first, + const wxColour& second, + const Slic3r::GradientCurve& curve, + int steps); + +// Same ramp for a project config slot, resolving components, colours and curve (or the +// linear gradient_range fallback) from cfg. Returns empty for any slot that is not a +// two-component gradient mixed filament. steps is the ramp's resolution; pass the +// destination's height in pixels. +std::vector mixed_gradient_ramp(const Slic3r::DynamicPrintConfig& cfg, size_t slot, int steps); + +// Fill rect with a ramp, ramp.front() along the bottom edge. +void fill_gradient_ramp_rect(wxDC& dc, const wxRect& rect, const std::vector& ramp); + +// Swatch bitmap for a gradient mixed filament, drawn bottom to top from the ramp. +wxBitmap create_gradient_ramp_bitmap(const std::vector& ramp, const wxSize& size); + +// Recompute blended representative colors for mixed (virtual) filament slots. +// Reads mixed-filament config keys from cfg and writes back into colors[i] +// for every slot where filament_is_mixed[i] is true. +void recompute_mixed_slot_colors(std::vector& colors, + const Slic3r::DynamicPrintConfig& cfg); + }} // namespace Slic3r::GUI #endif // slic3r_GUI_FilamentBitmapUtils_hpp_ \ No newline at end of file diff --git a/src/slic3r/GUI/GLCanvas3D.cpp b/src/slic3r/GUI/GLCanvas3D.cpp index fa6902de8d..34279369a1 100644 --- a/src/slic3r/GUI/GLCanvas3D.cpp +++ b/src/slic3r/GUI/GLCanvas3D.cpp @@ -155,6 +155,11 @@ std::string& get_filament_mixture_warning_text(){ return filament_mixture_warning_text; } +std::string& get_single_extruder_mixed_filament_warning_text(){ + static std::string single_extruder_mixed_filament_warning_text; + return single_extruder_mixed_filament_warning_text; +} + static std::string format_number(float value) { @@ -2984,6 +2989,9 @@ void GLCanvas3D::reload_scene(bool refresh_immediately, bool force_full_scene_re bool mix_pla_and_petg = cur_plate->check_mixture_of_pla_and_petg(full_config_temp); _set_warning_notification(EWarning::MixUsePLAAndPETG, !mix_pla_and_petg); + bool single_extruder_mixed_risk = cur_plate->check_single_extruder_mixed_filament_risk(full_config_temp, get_single_extruder_mixed_filament_warning_text()); + _set_warning_notification(EWarning::SingleExtruderMixedFilament, single_extruder_mixed_risk); + bool filament_nozzle_compatible = cur_plate->check_compatible_of_nozzle_and_filament(full_config_temp, wxGetApp().preset_bundle->filament_presets, get_nozzle_filament_incompatible_text()); _set_warning_notification(EWarning::NozzleFilamentIncompatible, !filament_nozzle_compatible); @@ -3010,6 +3018,7 @@ void GLCanvas3D::reload_scene(bool refresh_immediately, bool force_full_scene_re _set_warning_notification(EWarning::TPUPrintableError, false); _set_warning_notification(EWarning::FilamentPrintableError, false); _set_warning_notification(EWarning::MixUsePLAAndPETG, false); + _set_warning_notification(EWarning::SingleExtruderMixedFilament, false); _set_warning_notification(EWarning::PrimeTowerOutside, false); _set_warning_notification(EWarning::MultiExtruderPrintableError,false); _set_warning_notification(EWarning::MultiExtruderHeightOutside,false); @@ -8902,7 +8911,10 @@ void GLCanvas3D::_render_imgui_select_plate_toolbar() m_sel_plate_toolbar.m_items[i]->slice_state = IMToolbarItem::SliceState::SLICE_FAILED; } else { - if ((!is_empty && !can_slice) || (plate_list.get_plate(i)->has_printable_instances() && !plate_list.get_plate(i)->can_slice())) + // A plate using a mixed filament whose components are broken cannot be sliced, + // so surface that on the plate toolbar the same way an unsliceable plate is. + if ((!is_empty && !can_slice) || (plate_list.get_plate(i)->has_printable_instances() && !plate_list.get_plate(i)->can_slice()) + || wxGetApp().plater()->sidebar().has_broken_mixed_filament(plate_list.get_plate(i))) m_sel_plate_toolbar.m_items[i]->slice_state = IMToolbarItem::SliceState::SLICE_FAILED; else { if (plate_list.get_plate(i)->get_slicing_percent() < 0.0f) @@ -9669,6 +9681,13 @@ void GLCanvas3D::_render_paint_toolbar() const } } } + // ORCA: the loop above only labels a slot whose preset was found in the preset collection, + // while the render loop below iterates extruder_num. Pad the label arrays so a slot without a + // matching preset cannot index past them; a garbage std::string crashes ImGui::CalcTextSize. + while (int(filament_text_first_line.size()) < extruder_num) { + filament_text_first_line.emplace_back(); + filament_text_second_line.emplace_back(); + } ImGuiWrapper& imgui = *wxGetApp().imgui(); const float canvas_w = float(get_canvas_size().get_width()); @@ -9698,6 +9717,10 @@ void GLCanvas3D::_render_paint_toolbar() const bool disabled = !wxGetApp().plater()->can_fillcolor(); ColorRGBA rgba; + // Gradient mixed filaments fade over Z, so their swatch is drawn as that fade rather than + // the single blended colour in `colors`. Every other slot's ramp is empty. + const auto& gradient_ramps = wxGetApp().plater()->get_filament_gradient_ramps(); + for (int i = 0; i < extruder_num; i++) { if (i > 0) ImGui::SameLine(); @@ -9711,6 +9734,8 @@ void GLCanvas3D::_render_paint_toolbar() const if (!ImGui::IsMouseHoveringRect(left_arrow_button.Min, left_arrow_button.Max) && !ImGui::IsMouseHoveringRect(right_arrow_button.Min, right_arrow_button.Max)) wxPostEvent(m_canvas, IntEvent(EVT_GLTOOLBAR_FILLCOLOR, i + 1)); } + if (i < (int) gradient_ramps.size() && !gradient_ramps[i].empty()) + ImGuiWrapper::draw_gradient_ramp(draw_list, ImGui::GetItemRectMin(), ImGui::GetItemRectMax(), gradient_ramps[i]); if (ImGui::IsItemHovered() && i < 9) { if (!ImGui::IsMouseHoveringRect(left_arrow_button.Min, left_arrow_button.Max) && !ImGui::IsMouseHoveringRect(right_arrow_button.Min, right_arrow_button.Max)) { ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, { 20.0f * f_scale, 10.0f * f_scale }); @@ -9726,7 +9751,13 @@ void GLCanvas3D::_render_paint_toolbar() const const float text_offset_y = 4.0f * em_unit * f_scale; for (int i = 0; i < extruder_num; i++) { - decode_color(colors[i], rgba); + // A gradient slot's swatch shows its fade instead of the blended colour in `colors`, so the + // labels take their contrast from the colour printed at the middle of the fade they sit on. + if (i < (int) gradient_ramps.size() && !gradient_ramps[i].empty()) { + const wxColour& c = gradient_ramps[i][gradient_ramps[i].size() / 2]; + rgba = ColorRGBA(c.Red(), c.Green(), c.Blue(), c.Alpha()); + } else + decode_color(colors[i], rgba); float gray = 0.299 * rgba.r_uchar() + 0.587 * rgba.g_uchar() + 0.114 * rgba.b_uchar(); ImVec4 text_color = gray < 80 ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) : ImVec4(0, 0, 0, 1.0f); @@ -10570,6 +10601,9 @@ void GLCanvas3D::_set_warning_notification(EWarning warning, bool state) case EWarning::MixUsePLAAndPETG: text = _u8L("PLA and PETG filaments detected in the mixture. Adjust parameters according to the Wiki to ensure print quality."); break; + case EWarning::SingleExtruderMixedFilament: + text = get_single_extruder_mixed_filament_warning_text(); + break; case EWarning::PrimeTowerOutside: text = _u8L("The prime tower extends beyond the plate boundary."); break; @@ -10618,6 +10652,14 @@ void GLCanvas3D::_set_warning_notification(EWarning warning, bool state) notification_manager.close_slicing_customize_error_notification(NotificationType::BBLNozzleFilamentIncompatible, NotificationLevel::WarningNotificationLevel); } } + else if (warning == EWarning::SingleExtruderMixedFilament) { + // Close by type: check_single_extruder_mixed_filament_risk() clears the shared text + // buffer on every call, so a close-by-text would miss once the risk is gone. + if (state) + notification_manager.push_slicing_customize_error_notification(NotificationType::BBLSingleExtruderMixedFilamentRisk, NotificationLevel::WarningNotificationLevel, text); + else + notification_manager.close_slicing_customize_error_notification(NotificationType::BBLSingleExtruderMixedFilamentRisk, NotificationLevel::WarningNotificationLevel); + } else { if (state) notification_manager.push_plater_warning_notification(text); diff --git a/src/slic3r/GUI/GLCanvas3D.hpp b/src/slic3r/GUI/GLCanvas3D.hpp index 17497edf16..84dbd5d652 100644 --- a/src/slic3r/GUI/GLCanvas3D.hpp +++ b/src/slic3r/GUI/GLCanvas3D.hpp @@ -391,6 +391,7 @@ class GLCanvas3D PrimeTowerOutside, NozzleFilamentIncompatible, MixtureFilamentIncompatible, + SingleExtruderMixedFilament, FlushingVolumeZero }; diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 6028ada640..223829f435 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -521,10 +521,10 @@ static const FileWildcards file_wildcards_by_type[FT_SIZE] = { /* FT_GCODE */ { L("G-code files"), { ".gcode"sv} }, #ifdef __APPLE__ /* FT_MODEL */ - {L("Supported files"), {".3mf"sv, ".stl"sv, ".oltp"sv, ".stp"sv, ".step"sv, ".svg"sv, ".amf"sv, ".obj"sv, ".usd"sv, ".usda"sv, ".usdc"sv, ".usdz"sv, ".abc"sv, ".ply"sv, ".drc"sv}}, + {L("Supported files"), {".3mf"sv, ".stl"sv, ".oltp"sv, ".stp"sv, ".step"sv, ".svg"sv, ".amf"sv, ".obj"sv, ".gltf"sv, ".glb"sv, ".fbx"sv, ".usd"sv, ".usda"sv, ".usdc"sv, ".usdz"sv, ".abc"sv, ".ply"sv, ".drc"sv}}, #else /* FT_MODEL */ - {L("Supported files"), {".3mf"sv, ".stl"sv, ".oltp"sv, ".stp"sv, ".step"sv, ".svg"sv, ".amf"sv, ".obj"sv, ".drc"sv}}, + {L("Supported files"), {".3mf"sv, ".stl"sv, ".oltp"sv, ".stp"sv, ".step"sv, ".svg"sv, ".amf"sv, ".obj"sv, ".gltf"sv, ".glb"sv, ".fbx"sv, ".drc"sv}}, #endif /* FT_ZIP */ { L("ZIP files"), { ".zip"sv } }, /* FT_PROJECT */ { L("Project files"), { ".3mf"sv} }, @@ -8905,7 +8905,17 @@ void GUI_App::load_current_presets(bool active_preset_combox/*= false*/, bool ch if (printer_technology == ptFFF && !edited_printer_preset.config.opt_bool("single_extruder_multi_material")) { auto* nozzle_diameter = edited_printer_preset.config.option("nozzle_diameter"); if (nozzle_diameter) { - preset_bundle->set_num_filaments(nozzle_diameter->values.size()); + // Mixed-color slots are virtual filaments kept at the tail of the list, so they have no + // nozzle of their own and the count has to allow for them. Only ever grow: this sizes + // the list so the combo boxes have something to bind to, and set_num_filaments() trims + // at the raw tail, so shrinking here would eat the mixes rather than the surplus + // physical slots. A list longer than the nozzle count is a state the app reaches + // legitimately - raising the extruder count and not saving the printer preset leaves + // exactly that on the next start - and losing the project's mixes to it is worse than + // carrying a filament the printer has no nozzle for until the count is next changed. + const size_t target = nozzle_diameter->values.size() + preset_bundle->num_mixed_filaments(); + if (target > preset_bundle->filament_presets.size()) + preset_bundle->set_num_filaments(target); } } this->plater()->set_printer_technology(printer_technology); diff --git a/src/slic3r/GUI/GUI_Factories.cpp b/src/slic3r/GUI/GUI_Factories.cpp index 5254492e5d..be407a270f 100644 --- a/src/slic3r/GUI/GUI_Factories.cpp +++ b/src/slic3r/GUI/GUI_Factories.cpp @@ -1656,16 +1656,16 @@ void MenuFactory::create_filament_action_menu(bool init, int active_filament_men { wxMenu *menu = &m_filament_action_menu; - if (init) { + // ORCA rebuild menu everytime instead checking existing of every item then deleting + while (menu->GetMenuItemCount() > 0) + menu->Destroy(menu->FindItemByPosition(0)); + + //if (init) { // append_menu_item( menu, wxID_ANY, _L("Edit"), "", [](wxCommandEvent&) { plater()->sidebar().edit_filament(); }, "", nullptr, []() { return true; }, m_parent); - } - - const int item_id = menu->FindItem(_L("Merge with")); - if (item_id != wxNOT_FOUND) - menu->Destroy(item_id); + //} wxMenu* sub_menu = new wxMenu(); std::vector icons = get_extruder_color_icons(true); @@ -1684,11 +1684,15 @@ void MenuFactory::create_filament_action_menu(bool init, int active_filament_men append_submenu(menu, sub_menu, wxID_ANY, _L("Merge with"), "", "", [filaments_cnt]() { return filaments_cnt > 1; }, m_parent); + // Decompose a target colour into a printable mix of the loaded filaments. Placed before the + append_menu_item( + menu, wxID_ANY, _L("Decompose Color"), "", [](wxCommandEvent&) { + plater()->sidebar().decompose_filament_color(kSidebarContextMenuFilamentId); }, "", nullptr, + []() { return plater()->sidebar().combos_filament().size() >= 2; }, m_parent); + + menu->AppendSeparator(); // ORCA use seperator for reducing accidental clicks to delete + // ORCA use delete item on end of menu to prevent accidental clicks. clicking to submenus(merge) already not allowed by OS - const int delete_id = menu->FindItem(_L("Delete")); - if (delete_id != wxNOT_FOUND) - menu->Destroy(delete_id); - append_menu_item( menu, wxID_ANY, _L("Delete"), _L("Delete this filament"), [](wxCommandEvent&) { plater()->sidebar().delete_filament(-2); }, "", nullptr, diff --git a/src/slic3r/GUI/GUI_ObjectList.cpp b/src/slic3r/GUI/GUI_ObjectList.cpp index dc89f5f9bb..b33cc82abc 100644 --- a/src/slic3r/GUI/GUI_ObjectList.cpp +++ b/src/slic3r/GUI/GUI_ObjectList.cpp @@ -3233,6 +3233,24 @@ void ObjectList::merge(bool to_multipart_object) void ObjectList::layers_editing() { + // Height ranges give each range its own layer height, varying the mixed sub-layer heights just + // like an adaptive profile, so this raises the same warning as variable layer height and shares + // its do-not-show-again flag. + const auto& print_config = wxGetApp().preset_bundle->prints.get_edited_preset().config; + if (print_config.opt_bool("enable_mixed_color_sublayer")) { + if (wxGetApp().app_config->get("no_warn_mixed_sublayer_variable_layer") != "1") { + // Orca: parent to the plater like the sibling site in Plater::priv::on_action_layersediting + // (BBS passes nullptr, which MsgDialog remaps to the main frame). + MessageDialog dlg(wxGetApp().plater(), + _L("Using variable layer height together with mixed color sublayer may result in poor color mixing quality."), + _L("Warning"), wxICON_WARNING | wxOK); + dlg.show_dsa_button(); + dlg.ShowModal(); + if (dlg.get_checkbox_state()) + wxGetApp().app_config->set("no_warn_mixed_sublayer_variable_layer", "1"); + } + } + const Selection& selection = scene_selection(); const int obj_idx = selection.get_object_idx(); wxDataViewItem item = obj_idx >= 0 && GetSelectedItemsCount() > 1 && selection.is_single_full_object() ? diff --git a/src/slic3r/GUI/GUI_Utils.hpp b/src/slic3r/GUI/GUI_Utils.hpp index c93c40b066..85790516ee 100644 --- a/src/slic3r/GUI/GUI_Utils.hpp +++ b/src/slic3r/GUI/GUI_Utils.hpp @@ -155,6 +155,9 @@ public: update_dark_config(); on_sys_color_changed(); event.Skip(); +#else + // Not calling Skip() is what stops the event propagating on Windows. + (void) this; #endif // __WINDOWS__ }); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoBrimEars.hpp b/src/slic3r/GUI/Gizmos/GLGizmoBrimEars.hpp index 4e531e6acc..cd3bc53cbd 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoBrimEars.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoBrimEars.hpp @@ -85,7 +85,7 @@ public: void update_model_object(); //ClippingPlane get_sla_clipping_plane() const; - bool is_selection_rectangle_dragging() const { return m_selection_rectangle.is_dragging(); } + bool is_selection_rectangle_dragging() const override { return m_selection_rectangle.is_dragging(); } bool wants_enter_leave_snapshots() const override { return true; } std::string get_gizmo_entering_text() const override { return _u8L("Entering Brim Ears"); } diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMeshBoolean.hpp b/src/slic3r/GUI/Gizmos/GLGizmoMeshBoolean.hpp index 9c36be5cd9..3ba613295d 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMeshBoolean.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMeshBoolean.hpp @@ -75,7 +75,7 @@ protected: virtual void on_render() override; virtual void on_set_state() override; virtual CommonGizmosDataID on_get_requirements() const override; - virtual void on_render_input_window(float x, float y, float bottom_limit); + virtual void on_render_input_window(float x, float y, float bottom_limit) override; void on_load(cereal::BinaryInputArchive &ar) override; void on_save(cereal::BinaryOutputArchive &ar) const override; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp index 761228550f..3d4af75cde 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.cpp @@ -78,6 +78,9 @@ void GLGizmoMmuSegmentation::init_extruders_data() m_extruders_colors = wxGetApp().plater()->get_extruders_colors(); m_selected_extruder_idx = 0; + m_gradient_ramps = wxGetApp().plater()->get_filament_gradient_ramps(); + m_gradient_ramps.resize(m_extruders_colors.size()); + // keep remap table consistent with current extruder count m_extruder_remap.resize(m_extruders_colors.size()); for (size_t i = 0; i < m_extruder_remap.size(); ++i) @@ -305,15 +308,32 @@ void GLGizmoMmuSegmentation::render_tooltip_button(float x, float y) } // ORCA -bool GLGizmoMmuSegmentation::draw_color_button(int idx, std::string id_str, const ColorRGBA& color, ColorRGBA& map_color, bool active, float scale) +bool GLGizmoMmuSegmentation::draw_color_button(int idx, const char* id_str, const ColorRGBA& color, ColorRGBA& map_color, bool active, float scale) { + // Inset of the frame stroked below, which is what trims the swatch down to its visible shape. + const float frame_inset = 1.5f; + ImDrawList* draw_list = ImGui::GetWindowDrawList(); std::string label_id = std::to_string(idx) + id_str + std::to_string(idx); ImVec2 pos = ImGui::GetCursorScreenPos(); ImVec2 size = ImVec2(27.f * scale, 27.f * scale); ImVec4 color_vec = ImGuiWrapper::to_ImVec4(color); ImU32 br_color = ImGui::ColorConvertFloat4ToU32(active ? ImGuiWrapper::COL_ORCA : m_is_dark_mode ? ImVec4(.35f, .35f, .35f, 1) : ImVec4(.85f, .85f, .85f, 1)); - bool dark_tone = (0.299f * color.r() + 0.587f * color.g() + 0.114f * color.b()) < 0.51f; // matching values used by wxWidgets with clr.GetLuminance() < 0.51 + // Every caller labels the button with the 1 based slot number, so idx - 1 picks out the slot's fade. + const std::vector* gradient = gradient_of(idx - 1); + // The centered slot number sits at the swatch's mid height, so take its contrast from the colour + // printed there rather than from the slot's blended color. + bool dark_tone = gradient ? (*gradient)[gradient->size() / 2].GetLuminance() < 0.51 : + (0.299f * color.r() + 0.587f * color.g() + 0.114f * color.b()) < 0.51f; // matching values used by wxWidgets with clr.GetLuminance() < 0.51 + + // Paint a gradient mixed filament's fade before the button and keep the button transparent, so + // the slot number and the frame below stay on top of it. The bands cannot round their corners, + // so the fade is inset to the frame, which masks it into the shape a plain color slot gets. + if (gradient) { + ImGuiWrapper::draw_gradient_ramp(draw_list, {pos.x + frame_inset * scale, pos.y + frame_inset * scale}, + {pos.x + size.x - frame_inset * scale, pos.y + size.y - frame_inset * scale}, *gradient); + color_vec.w = 0.f; // let the fade show through + } ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding , 7.f * scale); @@ -329,7 +349,7 @@ bool GLGizmoMmuSegmentation::draw_color_button(int idx, std::string id_str, cons auto drawBorder = [&](float d, float r, float t, ImU32 col) { draw_list->AddRect({pos.x + d * scale, pos.y + d * scale}, {pos.x + size.x - d * scale , pos.y + size.y - d * scale}, col, r * scale, 0, t * scale); }; - drawBorder(1.5f, 3.f, 4.f, ImGui::ColorConvertFloat4ToU32(ImGui::GetStyleColorVec4(ImGuiCol_WindowBg))); + drawBorder(frame_inset, 3.f, 4.f, ImGui::ColorConvertFloat4ToU32(ImGui::GetStyleColorVec4(ImGuiCol_WindowBg))); if(active) drawBorder(.5f, 4.f , 2.f, br_color); else @@ -433,7 +453,7 @@ void GLGizmoMmuSegmentation::on_render_input_window(float x, float y, float bott m_selected_extruder_idx = extruder_idx; } - if (extruder_idx < 16 && ImGui::IsItemHovered()) m_imgui->tooltip(_L("Shortcut Key ") + std::to_string(extruder_idx + 1), max_tooltip_width); + if (extruder_idx < int(GLGizmoMmuSegmentation::EXTRUDERS_LIMIT) && ImGui::IsItemHovered()) m_imgui->tooltip(_L("Shortcut Key ") + std::to_string(extruder_idx + 1), max_tooltip_width); } // ORCA: Remap filaments section (Border only, Title in border). // Styled as a panel for visual grouping. @@ -731,6 +751,10 @@ void GLGizmoMmuSegmentation::init_model_triangle_selectors() continue; int extruder_idx = (mv->extruder_id() > 0) ? mv->extruder_id() - 1 : 0; + // A volume may be assigned to a mixed-color slot, whose index can sit past the + // physical colour list; fall back to the first colour rather than reading OOB. + if (extruder_idx >= (int)m_extruders_colors.size()) + extruder_idx = 0; std::vector ebt_colors; ebt_colors.push_back(m_extruders_colors[size_t(extruder_idx)]); ebt_colors.insert(ebt_colors.end(), m_extruders_colors.begin(), m_extruders_colors.end()); @@ -753,6 +777,9 @@ void GLGizmoMmuSegmentation::update_triangle_selectors_colors() TriangleSelectorPatch* selector = dynamic_cast(m_triangle_selectors[i].get()); int extruder_idx = m_volumes_extruder_idxs[i]; int extruder_color_idx = std::max(0, extruder_idx - 1); + // A mixed-color slot can index past the physical colour list; fall back to the first colour. + if (extruder_color_idx >= (int)m_extruders_colors.size()) + extruder_color_idx = 0; std::vector ebt_colors; ebt_colors.push_back(m_extruders_colors[extruder_color_idx]); ebt_colors.insert(ebt_colors.end(), m_extruders_colors.begin(), m_extruders_colors.end()); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp index 55308bffa9..70cfde5aed 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMmuSegmentation.hpp @@ -73,11 +73,10 @@ public: void data_changed(bool is_serializing) override; - // TriangleSelector::serialization/deserialization has a limit to store 19 different states. - // EXTRUDER_LIMIT + 1 states are used to storing the painting because also uncolored triangles are stored. - // When increasing EXTRUDER_LIMIT, it needs to ensure that TriangleSelector::serialization/deserialization - // will be also extended to support additional states, requiring at least one state to remain free out of 19 states. - static const constexpr size_t EXTRUDERS_LIMIT = 16; + // The paint material limit follows EnforcerBlockerType::ExtruderMax: TriangleSelector + // serialization covers the extended (17..32) range through an escape nibble. Mixed-color + // filaments occupy ordinary slots, so they draw from the same budget as physical ones. + static const constexpr size_t EXTRUDERS_LIMIT = static_cast(EnforcerBlockerType::ExtruderMax); const float get_cursor_radius_min() const override { return CursorRadiusMin; } @@ -116,6 +115,10 @@ protected: // Filament remap feature std::vector m_extruder_remap; // index → target extruder index + // Colours each gradient mixed filament actually prints, bottom of the model first, mirrored + // from Plater so the extruder swatches draw the same fade the editor previews. Plain + // filament slots keep an empty ramp. + std::vector> m_gradient_ramps; // ORCA: Cache used filaments to filter UI std::set m_used_filaments; // Set of used filament indices (cached) @@ -137,7 +140,13 @@ private: void init_model_triangle_selectors(); // ORCA - bool draw_color_button(int idx, std::string id_str, const ColorRGBA& color, ColorRGBA& map_color, bool active, float scale); + bool draw_color_button(int idx, const char* id_str, const ColorRGBA& color, ColorRGBA& map_color, bool active, float scale); + // Gradient ramp of a filament slot, or nullptr when the slot is a plain single color + // filament. A non-null result is never empty. + const std::vector* gradient_of(int idx) const + { + return idx >= 0 && idx < (int) m_gradient_ramps.size() && !m_gradient_ramps[idx].empty() ? &m_gradient_ramps[idx] : nullptr; + } // BBS void update_triangle_selectors_colors(); diff --git a/src/slic3r/GUI/Gizmos/GLGizmoMove.hpp b/src/slic3r/GUI/Gizmos/GLGizmoMove.hpp index df3abdddc7..fecc8abf1c 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoMove.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoMove.hpp @@ -67,7 +67,7 @@ protected: void on_register_raycasters_for_picking() override; void on_unregister_raycasters_for_picking() override; //BBS: GUI refactor: add object manipulation - virtual void on_render_input_window(float x, float y, float bottom_limit); + virtual void on_render_input_window(float x, float y, float bottom_limit) override; private: double calc_projection(const UpdateData& data) const; diff --git a/src/slic3r/GUI/Gizmos/GLGizmoScale.hpp b/src/slic3r/GUI/Gizmos/GLGizmoScale.hpp index 6b46a596ba..3bfb63ff7a 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmoScale.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmoScale.hpp @@ -89,7 +89,7 @@ protected: virtual void on_register_raycasters_for_picking() override; virtual void on_unregister_raycasters_for_picking() override; //BBS: GUI refactor: add object manipulation - virtual void on_render_input_window(float x, float y, float bottom_limit); + virtual void on_render_input_window(float x, float y, float bottom_limit) override; private: void render_grabbers_connection(unsigned int id_1, unsigned int id_2, const ColorRGBA& color); diff --git a/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp b/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp index 94c76d896b..7882cf2269 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp +++ b/src/slic3r/GUI/Gizmos/GLGizmosManager.cpp @@ -998,16 +998,40 @@ bool GLGizmosManager::on_key(wxKeyEvent& evt) keyCode = keyCode- WXK_NUMPAD0+'0'; } if (keyCode >= '0' && keyCode <= '9') { - if (keyCode == '1' && !m_timer_set_color.IsRunning()) { + // The paint palette reaches EXTRUDERS_LIMIT slots (mixed-color filaments take + // ordinary slots too), so any leading digit that can start a valid two-digit + // number waits briefly for a second one. + const int digit = keyCode - '0'; + const int shortcut_max = int(GLGizmoMmuSegmentation::EXTRUDERS_LIMIT); + auto can_start_two_digit = [shortcut_max](int d) { return d > 0 && d * 10 <= shortcut_max; }; + auto select = [mmu_seg](int number) { return number > 0 && mmu_seg->on_number_key_down(number); }; + + if (m_timer_set_color.IsRunning() && m_pending_color_shortcut_tens > 0) { + const int two_digit = m_pending_color_shortcut_tens * 10 + digit; + const int pending = m_pending_color_shortcut_tens; + m_pending_color_shortcut_tens = 0; + m_timer_set_color.Stop(); + if (two_digit <= shortcut_max) { + processed = select(two_digit); + } else { + // Out of range: commit the pending digit, then treat this one as new input. + processed = select(pending); + if (can_start_two_digit(digit)) { + m_pending_color_shortcut_tens = digit; + m_timer_set_color.StartOnce(500); + processed = true; + } else { + processed = select(digit) || processed; + } + } + } + else if (can_start_two_digit(digit)) { + m_pending_color_shortcut_tens = digit; m_timer_set_color.StartOnce(500); processed = true; } - else if (keyCode < '7' && m_timer_set_color.IsRunning()) { - processed = mmu_seg->on_number_key_down(keyCode - '0'+10); - m_timer_set_color.Stop(); - } else { - processed = mmu_seg->on_number_key_down(keyCode - '0'); + processed = select(digit); } } else if (keyCode == 'F' || keyCode == 'T' || keyCode == 'S' || keyCode == 'C' || keyCode == 'H' || keyCode == 'G') { @@ -1054,11 +1078,15 @@ bool GLGizmosManager::on_key(wxKeyEvent& evt) void GLGizmosManager::on_set_color_timer(wxTimerEvent& evt) { - if (m_current == MmSegmentation) { + // No second digit arrived in time: commit the pending leading digit on its own. + if (m_current == MmSegmentation && m_pending_color_shortcut_tens > 0) { GLGizmoMmuSegmentation* mmu_seg = dynamic_cast(get_current()); - mmu_seg->on_number_key_down(1); - m_parent.set_as_dirty(); + if (mmu_seg != nullptr) { + mmu_seg->on_number_key_down(m_pending_color_shortcut_tens); + m_parent.set_as_dirty(); + } } + m_pending_color_shortcut_tens = 0; } void GLGizmosManager::update_after_undo_redo(const UndoRedo::Snapshot& snapshot) diff --git a/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp b/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp index 01814521aa..157eb43dc7 100644 --- a/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp +++ b/src/slic3r/GUI/Gizmos/GLGizmosManager.hpp @@ -144,6 +144,8 @@ private: //When there are more than 9 colors, shortcut key coloring wxTimer m_timer_set_color; + // Leading digit of a two-digit color shortcut still waiting for its second digit. + int m_pending_color_shortcut_tens = 0; void on_set_color_timer(wxTimerEvent& evt); // key MENU_ICON_NAME, value = ImtextureID diff --git a/src/slic3r/GUI/GradientCurveEditor.cpp b/src/slic3r/GUI/GradientCurveEditor.cpp new file mode 100644 index 0000000000..5b1073d231 --- /dev/null +++ b/src/slic3r/GUI/GradientCurveEditor.cpp @@ -0,0 +1,656 @@ +#include "GradientCurveEditor.hpp" +#include "GUI_App.hpp" +#include "GuiColor.hpp" +#include "I18N.hpp" +#include "Widgets/StateColor.hpp" + +#include +#include +#include + +#include +#include +#include +#include + +namespace Slic3r { +namespace GUI { + +wxDEFINE_EVENT(wxEVT_GRADIENT_CURVE_CHANGED, wxCommandEvent); + +namespace { +// Layout ratios of the plot rect within the widget, taken from a 214 x 180 px reference drawing. +// Plot rect occupies the upper-left region; right + bottom margins host axis arrows / labels. +constexpr double kPlotLeftRatio = 0.0316; +constexpr double kPlotRightRatio = 0.6766; +constexpr double kPlotTopRatio = 0.1529; +constexpr double kPlotBottomRatio = 0.8474; +constexpr int kGridDivisions = 9; // 10 grid lines including the outer borders. + +// Hit / stroke (DIP). +constexpr int kHitRadius = 6; +constexpr int kCurveHitRadius = 5; +constexpr int kPointRadius = 4; // anchor outer radius (DIP) +constexpr int kStrokeUnselected = 2; +constexpr int kStrokeSelected = 4; +constexpr int kStrokeAxis = 2; // axis line width (px, no DPI scaling - matches kGridColor pen and 2DBed convention) +constexpr int kAxisArrowHalf = 5; // half-base of the axis arrow triangle (DIP) +constexpr int kAxisArrowLen = 10; // length of the axis arrow triangle (DIP) + +// Light-mode design tokens. Resolved through StateColor::darkModeColorFor() +// at paint time so the editor follows the app theme (#EEEEEE -> #4C4C55, #6B6B6B -> +// #818183, #262E30 -> #EFEFF0, #ACACAC -> #65656A, *wxWHITE -> #2D2D31). Don't read these +// directly in paint; always go through the resolved locals declared at the top of on_paint(). +const wxColour kGridColor (238, 238, 238); // #EEEEEE grey 300 +const wxColour kAxisColor (107, 107, 107); // #6B6B6B grey 700 +const wxColour kLabelMuted (107, 107, 107); // #6B6B6B grey 700 +const wxColour kLabelStrong ( 38, 46, 48); // #262E30 grey 900 +const wxColour kOutlineColor(172, 172, 172); // #ACACAC dimmed elements + +// LAB (DeltaE76) threshold for "curve color is too close to the background": below it the curve +// gets a subtle outline so it does not visually vanish, otherwise it is drawn plain. Looser than +// the 5.0 of FlushPredict::is_similar_color, so a pastel pink on white still gets an outline. +constexpr float kBgSimilarThreshold = 15.0f; +constexpr int kOutlineExtraDip = 2; +} // namespace + +GradientCurveEditor::GradientCurveEditor(wxWindow* parent, + const wxColour& color_low, + const wxColour& color_high) + : wxPanel(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE) + , m_color_low(color_low) + , m_color_high(color_high) +{ + SetBackgroundStyle(wxBG_STYLE_PAINT); + SetBackgroundColour(wxGetApp().get_window_default_clr()); + // Wide enough so the X-axis "Material Ratio" label fits past the arrow tip without overlap. + SetMinSize(FromDIP(wxSize(260, 200))); + + reset_to_linear(0.10, 0.90); + + Bind(wxEVT_PAINT, &GradientCurveEditor::on_paint, this); + Bind(wxEVT_LEFT_DOWN, &GradientCurveEditor::on_left_down, this); + Bind(wxEVT_LEFT_UP, &GradientCurveEditor::on_left_up, this); + Bind(wxEVT_RIGHT_DOWN, &GradientCurveEditor::on_right_down, this); + Bind(wxEVT_MOTION, &GradientCurveEditor::on_motion, this); + Bind(wxEVT_LEAVE_WINDOW,&GradientCurveEditor::on_leave, this); + Bind(wxEVT_SIZE, &GradientCurveEditor::on_size, this); + Bind(wxEVT_MOUSE_CAPTURE_LOST, [this](wxMouseCaptureLostEvent&) { + m_drag_mode = DragMode::None; + m_drag_idx = -1; + m_dragged_moved = false; + }); +} + +GradientCurveEditor::~GradientCurveEditor() +{ + // See MixedFilamentDialog::~MixedFilamentDialog: a widget destroyed while it + // still holds the capture wedges mouse input for the whole application. + if (HasCapture()) + ReleaseMouse(); +} + +void GradientCurveEditor::set_points(const PointList& pts) +{ + m_points = pts; + normalize_points(); + Refresh(); +} + +void GradientCurveEditor::set_colors(const wxColour& color_low, const wxColour& color_high) +{ + m_color_low = color_low; + m_color_high = color_high; + Refresh(); +} + +void GradientCurveEditor::set_selected_curve(int curve_idx) +{ + const int new_sel = (curve_idx == 0) ? 0 : 1; + if (m_selected_curve == new_sel) return; + m_selected_curve = new_sel; + Refresh(); +} + +void GradientCurveEditor::reset_to_linear(double y0, double y1) +{ + auto clamp_y = [](double v) { + return std::max(kGradientMinRatio, std::min(kGradientMaxRatio, v)); + }; + m_points.clear(); + GradientAnchor a0; a0.x = 0.0; a0.y = clamp_y(y0); + GradientAnchor a1; a1.x = 1.0; a1.y = clamp_y(y1); + m_points.push_back(a0); + m_points.push_back(a1); + m_selected_curve = 0; + Refresh(); + emit_changed(); +} + +void GradientCurveEditor::reverse() +{ + // Mirror y around 0.5. Tangents are slopes dy/dx so they flip sign to keep the + // local shape consistent across the mirror; NaN tangents remain "use PCHIP default". + for (auto& p : m_points) { + p.y = 1.0 - p.y; + if (std::isfinite(p.m_in)) p.m_in = -p.m_in; + if (std::isfinite(p.m_out)) p.m_out = -p.m_out; + } + Refresh(); + emit_changed(); +} + +void GradientCurveEditor::normalize_points() +{ + if (m_points.empty()) { + GradientAnchor a0; a0.x = 0.0; a0.y = kGradientMinRatio; + GradientAnchor a1; a1.x = 1.0; a1.y = kGradientMaxRatio; + m_points.push_back(a0); + m_points.push_back(a1); + return; + } + + for (auto& p : m_points) { + p.x = std::max(0.0, std::min(1.0, p.x)); + p.y = std::max(kGradientMinRatio, std::min(kGradientMaxRatio, p.y)); + } + std::sort(m_points.begin(), m_points.end(), + [](const GradientAnchor& a, const GradientAnchor& b) { + return a.x < b.x; + }); + + if (m_points.size() < 2) { + GradientAnchor tail; tail.x = 1.0; tail.y = m_points.front().y; + m_points.push_back(tail); + } + + m_points.front().x = 0.0; + m_points.back().x = 1.0; +} + +void GradientCurveEditor::emit_changed() +{ + wxCommandEvent evt(wxEVT_GRADIENT_CURVE_CHANGED, GetId()); + evt.SetEventObject(this); + ProcessWindowEvent(evt); +} + +wxRect GradientCurveEditor::plot_rect() const +{ + const wxSize sz = GetClientSize(); + const int x = static_cast(std::lround(sz.x * kPlotLeftRatio)); + const int y = static_cast(std::lround(sz.y * kPlotTopRatio)); + const int x2 = static_cast(std::lround(sz.x * kPlotRightRatio)); + const int y2 = static_cast(std::lround(sz.y * kPlotBottomRatio)); + // Force square 1:1 so X/Y axes share the same scale and grid cells stay square. Anchor at + // the top-left so the "100%" labels on the bottom/right still align with the plot edges. + const int side = std::max(1, std::min(x2 - x, y2 - y)); + return wxRect(x, y, side, side); +} + +wxPoint2DDouble GradientCurveEditor::data_to_px_f(double x, double y) const +{ + const wxRect r = plot_rect(); + // y axis is inverted: y=1 should sit at the top. + return wxPoint2DDouble(r.x + x * r.width, r.y + (1.0 - y) * r.height); +} + +wxPoint GradientCurveEditor::data_to_px(double x, double y) const +{ + const wxPoint2DDouble p = data_to_px_f(x, y); + return wxPoint(static_cast(std::lround(p.m_x)), static_cast(std::lround(p.m_y))); +} + +void GradientCurveEditor::px_to_data(int px, int py, double& x, double& y) const +{ + const wxRect r = plot_rect(); + const double w = std::max(1, r.width); + const double h = std::max(1, r.height); + x = std::max(0.0, std::min(1.0, (px - r.x) / w)); + y = std::max(0.0, std::min(1.0, 1.0 - (py - r.y) / h)); +} + +double GradientCurveEditor::sample_curve_y(double x) const +{ + GradientCurve gc; + gc.points = m_points; + return sample_gradient_curve(gc, x); +} + +int GradientCurveEditor::hit_test(int px, int py) const +{ + const int tol = FromDIP(kHitRadius); + int best_idx = -1; + int best_d2 = tol * tol; + for (size_t i = 0; i < m_points.size(); ++i) { + // Anchor visual y is curve-specific: component 1's anchor sits at (x, 1 - stored_y). + const double vy = to_visual_y(m_selected_curve, m_points[i].y); + const wxPoint p = data_to_px(m_points[i].x, vy); + const int dx = px - p.x; + const int dy = py - p.y; + const int d2 = dx * dx + dy * dy; + if (d2 <= best_d2) { + best_idx = static_cast(i); + best_d2 = d2; + } + } + return best_idx; +} + +int GradientCurveEditor::hit_test_curve(int px, int py, int* seg_out) const +{ + if (seg_out) *seg_out = -1; + if (m_points.size() < 2) return -1; + const int tol = FromDIP(kCurveHitRadius); + const int tol2 = tol * tol; + + auto dist2_to_seg = [&](int ax, int ay, int bx, int by) -> int { + const double dx = bx - ax; + const double dy = by - ay; + const double l2 = dx * dx + dy * dy; + if (l2 == 0.0) { + const double ddx = px - ax; + const double ddy = py - ay; + return static_cast(ddx * ddx + ddy * ddy); + } + double t = ((px - ax) * dx + (py - ay) * dy) / l2; + t = std::max(0.0, std::min(1.0, t)); + const double ex = ax + t * dx; + const double ey = ay + t * dy; + const double ddx = px - ex; + const double ddy = py - ey; + return static_cast(ddx * ddx + ddy * ddy); + }; + + // Hit-test against the same dense Hermite polyline that on_paint draws, so the + // clickable line follows the visual curve exactly (no offset on the bent parts). + // When a hit is found, also report the index of the left anchor of the data-space + // segment that covers cursor x; needed by the segment-bend interaction. + const wxRect rc = plot_rect(); + const int samples = std::max(128, rc.width * 2); + auto seg_for_x = [&](double cursor_x) -> int { + for (size_t i = 1; i < m_points.size(); ++i) { + if (cursor_x <= m_points[i].x) + return static_cast(i - 1); + } + return static_cast(m_points.size() - 2); + }; + + auto curve_hit = [&](int curve_idx) -> bool { + wxPoint prev; + for (int s = 0; s <= samples; ++s) { + const double x = double(s) / samples; + const double y0 = sample_curve_y(x); + const double vy = to_visual_y(curve_idx, y0); + const wxPoint cur = data_to_px(x, vy); + if (s > 0 && dist2_to_seg(prev.x, prev.y, cur.x, cur.y) <= tol2) + return true; + prev = cur; + } + return false; + }; + + // Prefer the selected curve so overlapping segments don't unintentionally steal focus. + if (curve_hit(m_selected_curve)) { + if (seg_out) { + double nx = 0, dummy = 0; + px_to_data(px, py, nx, dummy); + *seg_out = seg_for_x(nx); + } + return m_selected_curve; + } + const int other = 1 - m_selected_curve; + if (curve_hit(other)) { + if (seg_out) { + double nx = 0, dummy = 0; + px_to_data(px, py, nx, dummy); + *seg_out = seg_for_x(nx); + } + return other; + } + return -1; +} + +void GradientCurveEditor::on_paint(wxPaintEvent& /*evt*/) +{ + // Resolve theme colors every paint so dark-mode toggles (no re-construction) take + // effect without an explicit listener. Window bg is read from GUI_App, not + // GetBackgroundColour(), since the latter is snapshotted at construction time. + const wxColour bg = wxGetApp().get_window_default_clr(); + const wxColour grid_color = StateColor::darkModeColorFor(kGridColor); + const wxColour axis_color = StateColor::darkModeColorFor(kAxisColor); + const wxColour label_muted = StateColor::darkModeColorFor(kLabelMuted); + const wxColour label_strong = StateColor::darkModeColorFor(kLabelStrong); + const wxColour point_fill = StateColor::darkModeColorFor(*wxWHITE); + // Softer than axis_color: the curve outline only has to lift the curve off the + // background, it must not compete with the structural axis / grid. + const wxColour outline_color = StateColor::darkModeColorFor(kOutlineColor); + + wxAutoBufferedPaintDC raw_dc(this); + raw_dc.SetBackground(wxBrush(bg)); + raw_dc.Clear(); + + // Render through wxGCDC so curves, arrows and anchor circles get anti-aliased; the buffered + // DC is the actual back buffer that gets blitted to the window. + wxGCDC dc(raw_dc); + // The curve and its anchors are drawn straight on the graphics context so their + // coordinates stay sub-pixel accurate (see data_to_px_f). + wxGraphicsContext* gc = dc.GetGraphicsContext(); + + const wxRect rc = plot_rect(); + if (rc.width <= 0 || rc.height <= 0) + return; + + // 10x10 light grid (10 lines including outer borders, 9 equal divisions). + dc.SetPen(wxPen(grid_color, 1)); + for (int i = 0; i <= kGridDivisions; ++i) { + const int x = rc.x + rc.width * i / kGridDivisions; + const int y = rc.y + rc.height * i / kGridDivisions; + dc.DrawLine(x, rc.y, x, rc.y + rc.height); + dc.DrawLine(rc.x, y, rc.x + rc.width, y); + } + + // Set the label font first so text width measurements drive arrow / label placement. + wxFont label_font = wxSystemSettings::GetFont(wxSYS_DEFAULT_GUI_FONT); + label_font.SetPointSize(std::max(7, label_font.GetPointSize() - 1)); + dc.SetFont(label_font); + + const wxString axis_y_title = _L("Material Ratio"); + const wxString axis_x_title = _L("Model Height"); + const wxString pct_text = wxT("100%"); + const wxSize x_title_sz = dc.GetTextExtent(axis_x_title); + const wxSize y_title_sz = dc.GetTextExtent(axis_y_title); + + wxFont strong_font = label_font; + strong_font.SetWeight(wxFONTWEIGHT_SEMIBOLD); + dc.SetFont(strong_font); + const wxSize pct_text_sz = dc.GetTextExtent(pct_text); + dc.SetFont(label_font); + + // Axes (grey 700) with filled triangle arrows. Y-axis extends above the plot top to the + // canvas top edge; X-axis extends past the plot right toward the canvas right edge. + const int arrow_half = FromDIP(kAxisArrowHalf); + const int arrow_len = FromDIP(kAxisArrowLen); + const wxSize sz = GetClientSize(); + dc.SetPen(wxPen(axis_color, kStrokeAxis)); + dc.SetBrush(wxBrush(axis_color)); + + // Y-axis: vertical line at plot_left, from arrow tip near canvas top down to plot bottom. + const int y_axis_x = rc.x; + const int y_title_pct_gap = FromDIP(1); + const int y_title_bottom_pad = FromDIP(2); + const int y_title_y = std::max(0, rc.y - y_title_sz.y - y_title_pct_gap - pct_text_sz.y - y_title_bottom_pad); + const int y_arrow_tip_y = y_title_y; + const int y_arrow_ty = y_arrow_tip_y + arrow_len; + dc.DrawLine(y_axis_x, y_arrow_ty, y_axis_x, rc.y + rc.height); + { + wxPoint tri[3] = { + wxPoint(y_axis_x, y_arrow_tip_y), + wxPoint(y_axis_x - arrow_half, y_arrow_ty), + wxPoint(y_axis_x + arrow_half, y_arrow_ty), + }; + dc.DrawPolygon(3, tri); + } + + // X-axis arrow tip: stays just past the plot ideally, but is clamped so the trailing + // "Material Ratio" label still fits inside the canvas without overlapping the arrow. + const int x_axis_y = rc.y + rc.height; + const int x_label_gap = FromDIP(4); + const int x_edge_pad = FromDIP(6); + const int x_arrow_ideal = rc.x + rc.width + FromDIP(10); + const int x_arrow_max = sz.x - x_title_sz.x - x_label_gap - x_edge_pad - arrow_len; + const int x_arrow_tx = std::max(rc.x + rc.width + arrow_len, + std::min(x_arrow_ideal, x_arrow_max)); + const int x_arrow_tip_x = x_arrow_tx + arrow_len; + const int x_title_x = x_arrow_tip_x + x_label_gap; + dc.DrawLine(rc.x, x_axis_y, x_arrow_tx, x_axis_y); + { + wxPoint tri[3] = { + wxPoint(x_arrow_tip_x, x_axis_y), + wxPoint(x_arrow_tx, x_axis_y - arrow_half), + wxPoint(x_arrow_tx, x_axis_y + arrow_half), + }; + dc.DrawPolygon(3, tri); + } + + // Labels. + // "Model Height" and "100%" share the same left x; the gap is larger than the + // axis-arrow half-base so the text never visually touches the Y-axis arrow. + const int label_left_x = y_axis_x + FromDIP(10); + dc.SetTextForeground(label_muted); + dc.DrawText(axis_y_title, label_left_x, y_title_y); + + dc.SetFont(strong_font); + dc.SetTextForeground(label_strong); + dc.DrawText(pct_text, label_left_x, y_title_y + y_title_sz.y + y_title_pct_gap); + + // Bottom-right "100%" sits under the right end of the plot; "Material Ratio" follows the + // X-axis arrow tip (placement was already clamped above to leave room). + dc.DrawText(pct_text, rc.x + rc.width - pct_text_sz.x, x_axis_y); + dc.SetFont(label_font); + dc.SetTextForeground(label_muted); + dc.DrawText(axis_x_title, x_title_x, x_axis_y - x_title_sz.y / 2); + + if (m_points.size() < 2 || !gc) + return; + + auto color_for_curve = [&](int curve_idx) -> wxColour { + wxColour c = (curve_idx == 0) ? m_color_low : m_color_high; + // Transparent filaments (alpha == 0, e.g. #FFFFFF00) would be invisible. + // Lift alpha so the curve stays visible while still hinting at transparency. + if (c.Alpha() == 0) + c.Set(c.Red(), c.Green(), c.Blue(), 150); + return c; + }; + + auto build_polyline = [&](int curve_idx) -> std::vector { + const int samples = std::max(128, rc.width * 2); + std::vector poly; + poly.reserve(samples + 1); + for (int s = 0; s <= samples; ++s) { + const double x = double(s) / samples; + const double y0 = sample_curve_y(x); + const double vy = to_visual_y(curve_idx, y0); + poly.push_back(data_to_px_f(x, vy)); + } + return poly; + }; + + // Only the geometry goes through the graphics context: dc.DrawLines() takes integer wxPoint + // and would quantize the curve back to whole pixels. The pen is still set on the dc, which + // forwards it here while keeping its own cached state in sync for later dc drawing. + auto draw_polyline = [&](const std::vector& poly, const wxColour& col, int stroke_dip) { + dc.SetPen(wxPen(col, FromDIP(stroke_dip))); + gc->StrokeLines(poly.size(), poly.data()); + }; + + // Outline only when the curve color is perceptually close to the background; otherwise + // the plain filament color reads fine and the extra stroke would look heavy. + auto needs_outline = [&](const wxColour& c) { + return calc_color_distance(c, bg) < kBgSimilarThreshold; + }; + + auto draw_one = [&](int curve_idx, int stroke_dip) { + const auto poly = build_polyline(curve_idx); + const wxColour col = color_for_curve(curve_idx); + if (needs_outline(col)) + draw_polyline(poly, outline_color, stroke_dip + kOutlineExtraDip); + draw_polyline(poly, col, stroke_dip); + }; + + // Draw unselected first so the selected curve sits on top. + const int other = 1 - m_selected_curve; + draw_one(other, kStrokeUnselected); + draw_one(m_selected_curve, kStrokeSelected); + + // Control points (selected curve only): hollow circle with axis-color border, theme-aware fill. + // Drawn on the graphics context with a sub-pixel center so the ring stays centered on the + // curve instead of drifting up to half a pixel off it; pen and brush go through the dc for + // the same reason as in draw_polyline above. + const double r = FromDIP(kPointRadius); + dc.SetPen(wxPen(axis_color, 1)); + dc.SetBrush(wxBrush(point_fill)); + for (size_t i = 0; i < m_points.size(); ++i) { + const double vy = to_visual_y(m_selected_curve, m_points[i].y); + const wxPoint2DDouble p = data_to_px_f(m_points[i].x, vy); + gc->DrawEllipse(p.m_x - r, p.m_y - r, r * 2, r * 2); + } +} + +void GradientCurveEditor::on_left_down(wxMouseEvent& evt) +{ + const wxPoint pos = evt.GetPosition(); + m_dragged_moved = false; + + // 1) Anchor on the selected curve takes precedence over everything else. + // Dragging an anchor resets its tangent overrides so the surrounding curve + // returns to PCHIP-default shape (matches user expectation that pulling an + // anchor "straightens out" the local mess). + const int idx = hit_test(pos.x, pos.y); + if (idx >= 0) { + m_drag_mode = DragMode::Anchor; + m_drag_idx = idx; + // Only emit a change event when clearing the tangents actually mutates + // the curve. A plain click on an already-default anchor must not trigger + // re-slicing through the changed-event listener. + const bool had_tangent = std::isfinite(m_points[idx].m_in) + || std::isfinite(m_points[idx].m_out); + m_points[idx].m_in = std::numeric_limits::quiet_NaN(); + m_points[idx].m_out = std::numeric_limits::quiet_NaN(); + if (!HasCapture()) + CaptureMouse(); + Refresh(); + if (had_tangent) + emit_changed(); + return; + } + + // 2) Line-body hit. Determine which curve and which segment. + int seg = -1; + const int curve_hit = hit_test_curve(pos.x, pos.y, &seg); + if (curve_hit < 0) { + m_drag_mode = DragMode::None; + evt.Skip(); + return; + } + + // 3) Non-selected curve hit -> switch selection only, no drag arming. + if (curve_hit != m_selected_curve) { + m_selected_curve = curve_hit; + m_drag_mode = DragMode::None; + Refresh(); + evt.Skip(); + return; + } + + // 4) Selected curve line body hit -> insert a new anchor at cursor x (snapped + // to the current smooth curve so the initial click is visually invisible) + // and immediately enter Anchor drag mode. Bending the segment without + // inserting an anchor is not an option: a single cubic between two existing + // anchors cannot put its peak under an off-center cursor. + double nx = 0, dummy = 0; + px_to_data(pos.x, pos.y, nx, dummy); + if (nx <= 0.0 || nx >= 1.0 || seg < 0) { + m_drag_mode = DragMode::None; + evt.Skip(); + return; + } + GradientAnchor a; + a.x = nx; + a.y = sample_curve_y(nx); + const size_t insert_idx = static_cast(seg) + 1; + m_points.insert(m_points.begin() + insert_idx, a); + + m_drag_mode = DragMode::Anchor; + m_drag_idx = static_cast(insert_idx); + if (!HasCapture()) + CaptureMouse(); + Refresh(); + emit_changed(); +} + +void GradientCurveEditor::on_left_up(wxMouseEvent& evt) +{ + if (HasCapture()) + ReleaseMouse(); + + // Anchor mode (either an existing anchor or one freshly inserted by on_left_down) + // already fired emit_changed on mouse_down; only fire again here if the user + // actually dragged so the slicer doesn't re-run on a pure click. + if (m_drag_mode == DragMode::Anchor && m_dragged_moved) + emit_changed(); + + m_drag_mode = DragMode::None; + m_drag_idx = -1; + m_dragged_moved = false; + (void)evt; +} + +void GradientCurveEditor::on_right_down(wxMouseEvent& evt) +{ + const wxPoint pos = evt.GetPosition(); + const int idx = hit_test(pos.x, pos.y); + if (idx > 0 && static_cast(idx) + 1 < m_points.size()) { + // Interior anchor on the selected curve -> delete it. Endpoints stay locked. + m_points.erase(m_points.begin() + idx); + Refresh(); + emit_changed(); + return; + } + // Right-click on the non-selected curve switches selection (never deletes). + const int curve_hit = hit_test_curve(pos.x, pos.y); + if (curve_hit >= 0 && curve_hit != m_selected_curve) { + m_selected_curve = curve_hit; + Refresh(); + return; + } + evt.Skip(); +} + +void GradientCurveEditor::on_motion(wxMouseEvent& evt) +{ + if (!evt.LeftIsDown() || m_drag_mode != DragMode::Anchor) { + evt.Skip(); + return; + } + if (static_cast(m_drag_idx) >= m_points.size()) + return; + + const wxPoint pos = evt.GetPosition(); + double nx = 0, vy = 0; + px_to_data(pos.x, pos.y, nx, vy); + + auto& p = m_points[m_drag_idx]; + const bool is_first = (m_drag_idx == 0); + const bool is_last = (static_cast(m_drag_idx) + 1 == m_points.size()); + + // Endpoints stay locked at x=0 / x=1; interior anchors clamp into + // (left_neighbor.x, right_neighbor.x) so they can't cross or coincide. + if (!is_first && !is_last) { + const double xl = m_points[m_drag_idx - 1].x; + const double xr = m_points[m_drag_idx + 1].x; + const double eps = 1e-4; + nx = std::max(xl + eps, std::min(xr - eps, nx)); + p.x = nx; + } + // y is constrained to the reserved blend band so neither component ever + // reaches 0% / 100%, matching the sampler's clamp. + p.y = std::max(kGradientMinRatio, + std::min(kGradientMaxRatio, to_stored_y(m_selected_curve, vy))); + m_dragged_moved = true; + Refresh(); +} + +void GradientCurveEditor::on_leave(wxMouseEvent& evt) +{ + evt.Skip(); +} + +void GradientCurveEditor::on_size(wxSizeEvent& evt) +{ + Refresh(); + evt.Skip(); +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/GradientCurveEditor.hpp b/src/slic3r/GUI/GradientCurveEditor.hpp new file mode 100644 index 0000000000..f2e082aff5 --- /dev/null +++ b/src/slic3r/GUI/GradientCurveEditor.hpp @@ -0,0 +1,122 @@ +#ifndef slic3r_GradientCurveEditor_hpp_ +#define slic3r_GradientCurveEditor_hpp_ + +#include +#include +#include +#include +#include +#include + +#include "libslic3r/FilamentMixer.hpp" + +namespace Slic3r { +namespace GUI { + +// Photoshop-style curve editor for "Z progress -> first-component ratio" mapping. +// Curve evaluation uses cubic Hermite with PCHIP defaults plus optional per-anchor +// tangent overrides (m_in / m_out, NaN = use PCHIP default). The same evaluator +// (FilamentMixer::sample_gradient_curve) is shared with the slicing backend so what +// the editor renders matches the G-code output 1:1. +// +// Interaction model (PS Curves style): +// - Click or press-and-drag on the line body inserts a new anchor at the cursor x +// (snapped to the current smooth curve, NaN tangents) and starts dragging it. +// A pure click leaves an anchor sitting exactly on the previous curve shape; a +// drag moves the new anchor freely so the bump follows the cursor 1:1. +// - Dragging an existing anchor moves (x, y) and clears its m_in / m_out so the +// local curve returns to the PCHIP default shape around it. +// - Right-click on an interior anchor deletes it; endpoints stay locked. +class GradientCurveEditor : public wxPanel +{ +public: + using PointList = std::vector; + + GradientCurveEditor(wxWindow* parent, + const wxColour& color_low = wxColour(217, 217, 217), + const wxColour& color_high = wxColour(217, 217, 217)); + + ~GradientCurveEditor() override; + + // Replace the entire point list. The widget enforces x in [0,1], y in [0,1], + // sorts by x, and clamps the first / last x to 0 / 1. Tangent overrides are + // preserved as-is (NaN entries continue to use PCHIP defaults). + void set_points(const PointList& pts); + const PointList& get_points() const { return m_points; } + + void set_colors(const wxColour& color_low, const wxColour& color_high); + + // Which curve currently responds to drag / add / delete and is drawn with the thick stroke. + // 0 = first component (color_low), 1 = second component (color_high). Storage layer is + // unaffected: m_points always represents component 0's ratio. + void set_selected_curve(int curve_idx); + int get_selected_curve() const { return m_selected_curve; } + + // Reset to a two-point linear curve from y0 at t=0 to y1 at t=1. + // Clears all tangent overrides. + void reset_to_linear(double y0, double y1); + // Flip the curve top to bottom (all y -> 1 - y; tangents negated to mirror shape). + void reverse(); + +private: + enum class DragMode { + None, // nothing armed + Anchor, // dragging an anchor (either existing or just inserted from a line hit) + }; + + void normalize_points(); + void emit_changed(); + + void on_paint(wxPaintEvent& evt); + void on_left_down(wxMouseEvent& evt); + void on_left_up(wxMouseEvent& evt); + void on_right_down(wxMouseEvent& evt); + void on_motion(wxMouseEvent& evt); + void on_leave(wxMouseEvent& evt); + void on_size(wxSizeEvent& evt); + + // Coordinate mapping between data (x, y in [0,1]) and pixels in plot area. + wxRect plot_rect() const; + // Sub-pixel accurate mapping, used for drawing: rounding the curve vertices to whole + // pixels leaves a staircase that anti-aliasing cannot smooth out, and the step is + // twice as coarse on 2x (Retina) displays. + wxPoint2DDouble data_to_px_f(double x, double y) const; + wxPoint data_to_px(double x, double y) const; + void px_to_data(int px, int py, double& x, double& y) const; + // Anchor hit test for the currently-selected curve (uses translated visual y). + int hit_test(int px, int py) const; // returns point index or -1 + // Line-body hit test across both curves. Returns 0/1 for which curve was hit, -1 if none. + // Prefers the selected curve when both are within threshold. seg_out (when non-null) + // receives the left-anchor index of the segment that was hit on the returned curve; + // on_left_down uses it to know where in m_points to insert a freshly-added anchor. + int hit_test_curve(int px, int py, int* seg_out = nullptr) const; + + // Sample the curve in stored space (component 0) at x. + double sample_curve_y(double x) const; + + // Symmetric translation between visual y (what the user sees / clicks) and stored y + // (component 0's ratio in m_points). + static double to_stored_y(int curve_idx, double visual_y) { + return (curve_idx == 0) ? visual_y : (1.0 - visual_y); + } + static double to_visual_y(int curve_idx, double stored_y) { + return (curve_idx == 0) ? stored_y : (1.0 - stored_y); + } + + PointList m_points; + wxColour m_color_low; + wxColour m_color_high; + + int m_selected_curve = 0; + DragMode m_drag_mode = DragMode::None; + int m_drag_idx = -1; // valid when m_drag_mode == Anchor + bool m_dragged_moved = false; +}; + +// Custom event raised when the curve is edited (drag / add / remove / reset / reverse). +wxDECLARE_EVENT(wxEVT_GRADIENT_CURVE_CHANGED, wxCommandEvent); + +} // namespace GUI +} // namespace Slic3r + +#endif // slic3r_GradientCurveEditor_hpp_ diff --git a/src/slic3r/GUI/ImGuiWrapper.cpp b/src/slic3r/GUI/ImGuiWrapper.cpp index 0d127f524b..eeaadd0cbd 100644 --- a/src/slic3r/GUI/ImGuiWrapper.cpp +++ b/src/slic3r/GUI/ImGuiWrapper.cpp @@ -2404,6 +2404,25 @@ void ImGuiWrapper::draw( } } +void ImGuiWrapper::draw_gradient_ramp(ImDrawList *draw_list, const ImVec2 &top_left, const ImVec2 &bottom_right, const std::vector &ramp) +{ + if (draw_list == nullptr || ramp.empty() || bottom_right.x <= top_left.x || bottom_right.y <= top_left.y) + return; + + const int rows = std::max(1, (int) std::lround(bottom_right.y - top_left.y)); + const float row_h = (bottom_right.y - top_left.y) / rows; + const size_t last = ramp.size() - 1; + for (int r = 0; r < rows; ++r) { + // Row 0 is the top of the rect and so takes the ramp's last entry, the model's top. + const double t = (rows > 1) ? (double) (rows - 1 - r) / (rows - 1) : 0.5; + const wxColour &c = ramp[(size_t) (t * last + 0.5)]; + // The bottom row snaps to the rect's edge so rounding never leaves a sliver uncovered. + const float y0 = top_left.y + r * row_h; + const float y1 = (r + 1 == rows) ? bottom_right.y : top_left.y + (r + 1) * row_h; + draw_list->AddRectFilled({top_left.x, y0}, {bottom_right.x, y1}, IM_COL32(c.Red(), c.Green(), c.Blue(), c.Alpha())); + } +} + void ImGuiWrapper::draw_cross_hair(const ImVec2 &position, float radius, ImU32 color, int num_segments, float thickness) { auto draw_list = ImGui::GetOverlayDrawList(); draw_list->AddCircle(position, radius, color, num_segments, thickness); @@ -2463,6 +2482,19 @@ static const ImWchar ranges_keyboard_shortcuts[] = }; #endif // __APPLE__ +// Names drawn through the atlas come from file names and CAD data, not from the UI language. +// GetGlyphRangesDefault() already gives every language the CJK ideographs, which is why a +// Chinese file name renders under an English UI; these are the alphabetic scripts it omits. +// Codepoints the font lacks are skipped at build time, so only existing glyphs cost anything. +static const ImWchar ranges_language_independent[] = +{ + 0x0100, 0x024F, // Latin Extended-A and Extended-B + 0x0370, 0x03FF, // Greek and Coptic + 0x0400, 0x04FF, // Cyrillic + 0x1E00, 0x1EFF, // Latin Extended Additional (Vietnamese) + 0, +}; + std::vector ImGuiWrapper::load_svg(const std::string& bitmap_name, unsigned target_width, unsigned target_height, unsigned *outwidth, unsigned *outheight) { @@ -2773,6 +2805,7 @@ void ImGuiWrapper::init_font(bool compress) ImFontAtlas::GlyphRangesBuilder builder; builder.AddRanges(m_glyph_ranges); builder.AddRanges(ImGui::GetIO().Fonts->GetGlyphRangesDefault()); + builder.AddRanges(ranges_language_independent); #ifdef __APPLE__ if (m_font_cjk) // Apple keyboard shortcuts are only contained in the CJK fonts. @@ -2794,12 +2827,17 @@ void ImGuiWrapper::init_font(bool compress) // Orca: temp fix for Korean font auto font_name_regular = "HarmonyOS_Sans_SC_Regular.ttf"; auto font_name_bold = "HarmonyOS_Sans_SC_Bold.ttf"; + // The Korean and Thai fonts cover their own script and little else, so they need the + // default font merged in behind them to reach the full range. + bool needs_glyph_fallback = false; if(m_glyph_ranges == ImGui::GetIO().Fonts->GetGlyphRangesKorean()) { font_name_regular = "NanumGothic-Regular.ttf"; font_name_bold = "NanumGothic-Bold.ttf"; + needs_glyph_fallback = true; } else if (m_glyph_ranges == ImGui::GetIO().Fonts->GetGlyphRangesThai()) { font_name_regular = "Sarabun-Medium.ttf"; font_name_bold = "Sarabun-SemiBold.ttf"; + needs_glyph_fallback = true; } default_font = io.Fonts->AddFontFromFileTTF((Slic3r::resources_dir() + "/fonts/" + font_name_regular).c_str(), m_font_size, &cfg, ranges.Data); if (default_font == nullptr) { @@ -2809,11 +2847,12 @@ void ImGuiWrapper::init_font(bool compress) } } - if (m_glyph_ranges == ImGui::GetIO().Fonts->GetGlyphRangesThai()) { + // A merged font only supplies glyphs the font ahead of it lacks, so this fills the gaps + // without restyling anything the script font already covers. + if (needs_glyph_fallback) { ImFontConfig fallback_cfg = cfg; fallback_cfg.MergeMode = true; - static constexpr ImWchar celsius_range[] = { 0x2103, 0x2103, 0 }; - io.Fonts->AddFontFromFileTTF((Slic3r::resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf").c_str(), m_font_size, &fallback_cfg, celsius_range); + io.Fonts->AddFontFromFileTTF((Slic3r::resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf").c_str(), m_font_size, &fallback_cfg, ranges.Data); } bold_font = io.Fonts->AddFontFromFileTTF((Slic3r::resources_dir() + "/fonts/" + font_name_bold).c_str(), m_font_size, &cfg, ranges.Data); @@ -2822,11 +2861,10 @@ void ImGuiWrapper::init_font(bool compress) if (bold_font == nullptr) { throw Slic3r::RuntimeError("ImGui: Could not load deafult font"); } } - if (m_glyph_ranges == ImGui::GetIO().Fonts->GetGlyphRangesThai()) { + if (needs_glyph_fallback) { ImFontConfig fallback_cfg = cfg; fallback_cfg.MergeMode = true; - static constexpr ImWchar celsius_range[] = { 0x2103, 0x2103, 0 }; - io.Fonts->AddFontFromFileTTF((Slic3r::resources_dir() + "/fonts/HarmonyOS_Sans_SC_Bold.ttf").c_str(), m_font_size, &fallback_cfg, celsius_range); + io.Fonts->AddFontFromFileTTF((Slic3r::resources_dir() + "/fonts/HarmonyOS_Sans_SC_Bold.ttf").c_str(), m_font_size, &fallback_cfg, ranges.Data); } if (m_glyph_ranges == ImGui::GetIO().Fonts->GetGlyphRangesThai()) { @@ -2878,13 +2916,18 @@ void ImGuiWrapper::init_font(bool compress) glsafe(::glGetIntegerv(GL_MAX_TEXTURE_SIZE, &gl_max_tex_size)); constexpr int max_retries = 6; for (int attempt = 0; attempt < max_retries && io.Fonts->TexHeight > gl_max_tex_size; ++attempt) { - io.Fonts->TexDesiredWidth = (io.Fonts->TexDesiredWidth > 0 ? io.Fonts->TexDesiredWidth : io.Fonts->TexWidth) * 2; + const int width = io.Fonts->TexDesiredWidth > 0 ? io.Fonts->TexDesiredWidth : io.Fonts->TexWidth; + // Both dimensions share the same limit, so widening past it would only trade an + // illegal height for an illegal width. + if (width * 2 > gl_max_tex_size) + break; + io.Fonts->TexDesiredWidth = width * 2; io.Fonts->Build(); } if (io.Fonts->TexHeight > gl_max_tex_size) { - // Shouldn't really happen - BOOST_LOG_TRIVIAL(error) << "Font atlas height " << io.Fonts->TexHeight - << " still exceeds GL_MAX_TEXTURE_SIZE (" << gl_max_tex_size << ")" + // Needs both a very large glyph set and a small GL_MAX_TEXTURE_SIZE. + BOOST_LOG_TRIVIAL(error) << "Font atlas " << io.Fonts->TexWidth << "x" << io.Fonts->TexHeight + << " does not fit GL_MAX_TEXTURE_SIZE (" << gl_max_tex_size << ")" << " after " << max_retries << " attempts; rendering may be incomplete"; } diff --git a/src/slic3r/GUI/ImGuiWrapper.hpp b/src/slic3r/GUI/ImGuiWrapper.hpp index b586094ab3..db94b3edcb 100644 --- a/src/slic3r/GUI/ImGuiWrapper.hpp +++ b/src/slic3r/GUI/ImGuiWrapper.hpp @@ -3,10 +3,12 @@ #include #include +#include #include #include +#include #include #include "libslic3r/Point.hpp" @@ -299,6 +301,20 @@ public: int num_segments = 0, float thickness = 4.f); + /// + /// Fill a rect with a filament gradient ramp, one band per pixel row, ramp.front() along + /// the bottom edge. Bands rather than one interpolated rect, because the ramp follows the + /// slot's gradient curve and ImGui's corner interpolation could only draw a straight fade. + /// + /// Define where to draw it + /// Upper left corner of the rect + /// Lower right corner of the rect + /// Colours printed, bottom of the model first + static void draw_gradient_ramp(ImDrawList * draw_list, + const ImVec2 & top_left, + const ImVec2 & bottom_right, + const std::vector &ramp); + /// /// Check that font ranges contain all chars in string /// (rendered Unicodes are stored in GlyphRanges) diff --git a/src/slic3r/GUI/ImageDPIFrame.cpp b/src/slic3r/GUI/ImageDPIFrame.cpp index 2133f18784..8dad9c44da 100644 --- a/src/slic3r/GUI/ImageDPIFrame.cpp +++ b/src/slic3r/GUI/ImageDPIFrame.cpp @@ -74,7 +74,7 @@ bool ImageDPIFrame::Show(bool show) } void ImageDPIFrame::set_bitmap(const wxBitmap &bit_map) { - if (&bit_map && bit_map.IsOk()) { + if (bit_map.IsOk()) { m_bitmap->SetBitmap(bit_map); } } diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index 3b1cf1dffd..5f7d69244e 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -2330,6 +2330,11 @@ bool MainFrame::get_enable_slice_status() } } + // A mixed filament whose components were deleted, or whose components disagree in type, + // cannot be resolved at slicing time. Block the slice until the user fixes it. + if (enable && m_plater->sidebar().has_broken_mixed_filament()) + enable = false; + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": m_slice_select %1%, enable= %2% ")%m_slice_select %enable; return enable; } diff --git a/src/slic3r/GUI/MediaPlayCtrl.cpp b/src/slic3r/GUI/MediaPlayCtrl.cpp index 825f7071d7..76416d152f 100644 --- a/src/slic3r/GUI/MediaPlayCtrl.cpp +++ b/src/slic3r/GUI/MediaPlayCtrl.cpp @@ -39,13 +39,14 @@ static std::map error_messages = { namespace Slic3r { namespace GUI { -MediaPlayCtrl::MediaPlayCtrl(wxWindow *parent, wxMediaCtrl2 *media_ctrl, const wxPoint &pos, const wxSize &size) +MediaPlayCtrl::MediaPlayCtrl(wxWindow *parent, wxMediaCtrl3 *media_ctrl, const wxPoint &pos, const wxSize &size) : wxPanel(parent, wxID_ANY, pos, size) , m_media_ctrl(media_ctrl) { SetLabel("MediaPlayCtrl"); SetBackgroundColour(*wxWHITE); m_media_ctrl->Bind(wxEVT_MEDIA_STATECHANGED, &MediaPlayCtrl::onStateChanged, this); + m_media_ctrl->SetIdleImage(from_u8(resources_dir() + "/images/live_stream_default.png")); m_button_play = new Button(this, "", "media_play", wxBORDER_NONE); m_button_play->SetCanFocus(false); @@ -217,13 +218,6 @@ void MediaPlayCtrl::SetMachineObject(MachineObject* obj) if (machine == m_machine) { if (m_last_state == MEDIASTATE_IDLE && IsEnabled()) Play(); - else if (m_last_state == MEDIASTATE_LOADING && m_tutk_state == "disable" - && m_last_user_play + wxTimeSpan::Seconds(3) < wxDateTime::Now()) { - // resend ttcode to printer - if (auto agent = wxGetApp().getAgent()) - agent->get_camera_url(machine, [](auto) {}, wxGetApp().get_printer_cloud_provider()); - m_last_user_play = wxDateTime::Now(); - } return; } m_machine = machine; @@ -389,7 +383,7 @@ void MediaPlayCtrl::Play() // !m_lan_mode && !m_remote_proto && m_lan_proto == LVL_None (x) if (m_lan_proto <= MachineObject::LVL_Disable && (m_lan_mode || !m_remote_proto)) { - Stop(m_lan_proto == MachineObject::LVL_None + Stop(m_lan_proto == MachineObject::LVL_None ? _L("A problem occurred. Please update the printer firmware and try again.") : _L("LAN Only Liveview is off. Please turn on the liveview on printer screen.")); return; @@ -427,7 +421,7 @@ void MediaPlayCtrl::Play() url += "&cli_id=" + wxGetApp().app_config->get("slicer_uuid"); url += "&cli_ver=" + std::string(SLIC3R_VERSION); } - BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl: " << hide_passwd(url, + BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl: " << hide_passwd(url, {"?uid=", "authkey=", "passwd=", "license=", "token="}); CallAfter([this, m, url] { if (m != m_machine) { @@ -532,7 +526,7 @@ void MediaPlayCtrl::Stop(wxString const &msg, wxString const &msg2) auto tunnel = m_url.empty() ? "" : into_u8(wxURI(m_url).GetPath()).substr(1); if (auto n = tunnel.find_first_of("/_"); n != std::string::npos) tunnel = tunnel.substr(0, n); - if (last_state != wxMEDIASTATE_PLAYING && m_failed_code != 0 + if (last_state != wxMEDIASTATE_PLAYING && m_failed_code != 0 && m_last_failed_codes.find(m_failed_code) == m_last_failed_codes.end() && (m_user_triggered || m_failed_retry > 3)) { m_last_failed_codes.insert(m_failed_code); @@ -668,7 +662,7 @@ void MediaPlayCtrl::ToggleStream() url += "&cli_id=" + wxGetApp().app_config->get("slicer_uuid"); url += "&cli_ver=" + std::string(SLIC3R_VERSION); } - BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl::ToggleStream: " << hide_passwd(url, + BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl::ToggleStream: " << hide_passwd(url, {"?uid=", "authkey=", "passwd=", "license=", "token="}); CallAfter([this, m, url] { if (m != m_machine) return; @@ -688,8 +682,8 @@ void MediaPlayCtrl::ToggleStream() }, wxGetApp().get_printer_cloud_provider()); } -void MediaPlayCtrl::msw_rescale() { - m_button_play->Rescale(); +void MediaPlayCtrl::msw_rescale() { + m_button_play->Rescale(); } void MediaPlayCtrl::jump_to_play() @@ -823,7 +817,9 @@ void MediaPlayCtrl::media_proc() break; } else if (url == "") { + BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl: start play"; m_media_ctrl->Play(); + BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl: end play"; } else { BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl: start load"; @@ -879,15 +875,15 @@ bool MediaPlayCtrl::start_stream_service(bool *need_install) if (!boost::filesystem::exists(file_dll) || boost::filesystem::last_write_time(file_dll) != boost::filesystem::last_write_time(file_dll2)) boost::filesystem::copy_file(file_dll2, file_dll, boost::filesystem::copy_options::overwrite_existing); } - boost::process::child process_source(file_source, file_url2.ToStdWstring(), boost::process::start_dir(tools_dir), - boost::process::windows::create_no_window, + boost::process::child process_source(file_source, file_url2.ToStdWstring(), boost::process::start_dir(tools_dir), + boost::process::windows::create_no_window, boost::process::std_out > intermediate, boost::process::limit_handles); - boost::process::child process_ffmpeg(file_ffmpeg, configss, boost::process::windows::create_no_window, + boost::process::child process_ffmpeg(file_ffmpeg, configss, boost::process::windows::create_no_window, boost::process::std_in < intermediate, boost::process::limit_handles); #else boost::filesystem::permissions(file_source, boost::filesystem::owner_exe | boost::filesystem::add_perms); boost::filesystem::permissions(file_ffmpeg, boost::filesystem::owner_exe | boost::filesystem::add_perms); - boost::process::child process_source(file_source, file_url2.data().AsInternal(), boost::process::start_dir(start_dir), + boost::process::child process_source(file_source, file_url2.data().AsInternal(), boost::process::start_dir(start_dir), boost::process::std_out > intermediate, boost::process::limit_handles); boost::process::child process_ffmpeg(file_ffmpeg, configss, boost::process::std_in < intermediate, boost::process::limit_handles); #endif @@ -938,27 +934,16 @@ bool MediaPlayCtrl::get_stream_url(std::string *url) }} -void wxMediaCtrl2::DoSetSize(int x, int y, int width, int height, int sizeFlags) +void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, int height) { -#ifdef __WXMAC__ - wxWindow::DoSetSize(x, y, width, height, sizeFlags); -#else - wxMediaCtrl::DoSetSize(x, y, width, height, sizeFlags); -#endif -#if defined(__LINUX__) && defined(__WXGTK__) - if (m_gtk_video_window) { - const wxSize client_size = GetClientSize(); - m_gtk_video_window->SetSize(0, 0, client_size.GetWidth(), client_size.GetHeight()); - } -#endif - if (sizeFlags & wxSIZE_USE_EXISTING) return; - wxSize size = m_video_size; + wxSize size = videoSize; + if (!size.IsFullySpecified()) size = {16, 9}; int maxHeight = (width * size.GetHeight() + size.GetHeight() - 1) / size.GetWidth(); - if (maxHeight != GetMaxHeight()) { - // BOOST_LOG_TRIVIAL(info) << "wxMediaCtrl2::DoSetSize: width: " << width << ", height: " << height << ", maxHeight: " << maxHeight; - SetMaxSize({-1, maxHeight}); - CallAfter([this] { - if (auto p = GetParent()) { + if (maxHeight != ctrl->GetMaxHeight()) { + // BOOST_LOG_TRIVIAL(info) << "wxMediaCtrl_OnSize: width: " << width << ", height: " << height << ", maxHeight: " << maxHeight; + ctrl->SetMaxSize({-1, maxHeight}); + ctrl->CallAfter([ctrl] { + if (auto p = ctrl->GetParent()) { p->Layout(); p->Refresh(); } diff --git a/src/slic3r/GUI/MediaPlayCtrl.h b/src/slic3r/GUI/MediaPlayCtrl.h index 66bf6487c6..e8ad0ee82c 100644 --- a/src/slic3r/GUI/MediaPlayCtrl.h +++ b/src/slic3r/GUI/MediaPlayCtrl.h @@ -8,7 +8,7 @@ #ifndef MediaPlayCtrl_h #define MediaPlayCtrl_h -#include "wxMediaCtrl2.h" +#include "wxMediaCtrl3.h" #include "IMediaController.hpp" #include "slic3r/Utils/IPrinterAgent.hpp" @@ -32,7 +32,7 @@ namespace GUI { class MediaPlayCtrl : public wxPanel { public: - MediaPlayCtrl(wxWindow *parent, wxMediaCtrl2 *media_ctrl, const wxPoint &pos = wxDefaultPosition, const wxSize &size = wxDefaultSize); + MediaPlayCtrl(wxWindow *parent, wxMediaCtrl3 *media_ctrl, const wxPoint &pos = wxDefaultPosition, const wxSize &size = wxDefaultSize); ~MediaPlayCtrl(); @@ -83,7 +83,7 @@ private: // token std::shared_ptr m_token = std::make_shared(0); - wxMediaCtrl2 * m_media_ctrl; + wxMediaCtrl3 * m_media_ctrl; IMediaController * m_web_ctrl = nullptr; std::string m_agent_camera_url; bool m_web_user_stopped = false; @@ -101,7 +101,7 @@ private: bool m_device_busy = false; bool m_disable_lan = false; wxString m_url; - + std::deque m_tasks; boost::mutex m_mutex; boost::condition_variable m_cond; diff --git a/src/slic3r/GUI/MixedFilamentDialog.cpp b/src/slic3r/GUI/MixedFilamentDialog.cpp new file mode 100644 index 0000000000..902c12d27b --- /dev/null +++ b/src/slic3r/GUI/MixedFilamentDialog.cpp @@ -0,0 +1,1948 @@ +#include "MixedFilamentDialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "libslic3r/Utils.hpp" +#include "libslic3r/FilamentMixer.hpp" +#include "I18N.hpp" +#include "GUI.hpp" +#include "GUI_App.hpp" +#include "GradientCurveEditor.hpp" +#include "FilamentBitmapUtils.hpp" +#include "wxExtensions.hpp" +#include "Widgets/Button.hpp" +#include "Widgets/CheckBox.hpp" +#include "Widgets/ComboBox.hpp" +#include "Widgets/DropDown.hpp" +#include "Widgets/Label.hpp" + +namespace Slic3r { +namespace GUI { + +static constexpr int MAX_COMPONENTS = 3; +static constexpr int MIN_COMPONENT_RATIO = 10; + +// Section headings and the placeholder text share one muted tone; light key, resolved at each use. +static const wxColour COLOR_LABEL_MUTED("#6B6A6A"); + +// Lightweight self-painting label used for both dual-color and triple-color +// ratio percentage display. Hover shows a rounded-rect background; click +// fires wxEVT_LEFT_DOWN which the owning dialog binds to start_ratio_editor. +class RatioLabelPanel : public wxPanel +{ +public: + RatioLabelPanel(wxWindow* parent) + : wxPanel(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE) + { + SetBackgroundStyle(wxBG_STYLE_PAINT); + SetCursor(wxCursor(wxCURSOR_HAND)); + SetToolTip(_L("Click to edit ratio")); + SetFont(::Label::Body_10); + + Bind(wxEVT_ENTER_WINDOW, [this](wxMouseEvent& e) { m_hovered = true; Refresh(); e.Skip(); }); + Bind(wxEVT_LEAVE_WINDOW, [this](wxMouseEvent& e) { m_hovered = false; Refresh(); e.Skip(); }); + Bind(wxEVT_PAINT, &RatioLabelPanel::on_paint, this); + } + + void SetLabel(const wxString& text) override + { + if (m_text == text) return; + m_text = text; + update_best_size(); + Refresh(); + } + wxString GetLabel() const override { return m_text; } + +private: + void update_best_size() + { + wxClientDC dc(this); + dc.SetFont(GetFont()); + wxSize ts = dc.GetTextExtent(m_text); + int pad_x = FromDIP(4), pad_y = FromDIP(3); + SetMinSize(wxSize(ts.GetWidth() + pad_x * 2, ts.GetHeight() + pad_y * 2)); + InvalidateBestSize(); + } + + void on_paint(wxPaintEvent&) + { + wxBufferedPaintDC dc(this); + wxSize sz = GetClientSize(); + + wxColour parent_bg = GetParent() ? GetParent()->GetBackgroundColour() + : StateColor::darkModeColorFor(*wxWHITE); + dc.SetBrush(wxBrush(parent_bg)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + + if (m_hovered) { + dc.SetBrush(wxBrush(StateColor::darkModeColorFor(wxColour("#F8F8F8")))); + dc.SetPen(wxPen(StateColor::darkModeColorFor(wxColour("#CECECE")), 1)); + dc.DrawRoundedRectangle(0, 0, sz.GetWidth(), sz.GetHeight(), FromDIP(3)); + } + + dc.SetFont(GetFont()); + dc.SetTextForeground(m_hovered ? StateColor::darkModeColorFor(wxColour("#009688")) + : StateColor::darkModeColorFor(wxColour("#262E30"))); + wxSize ts = dc.GetTextExtent(m_text); + int x = (sz.GetWidth() - ts.GetWidth()) / 2; + int y = (sz.GetHeight() - ts.GetHeight()) / 2; + dc.DrawText(m_text, x, y); + } + + wxString m_text; + bool m_hovered{false}; +}; + +static wxColour blend_colors(const wxColour& a, const wxColour& b, double ratio_a) +{ + unsigned char r, g, bl; + Slic3r::filament_mixer_lerp(a.Red(), a.Green(), a.Blue(), + b.Red(), b.Green(), b.Blue(), + static_cast(1.0 - ratio_a), + &r, &g, &bl); + return wxColour(r, g, bl); +} + + +// ---- Constructors ---- + +MixedFilamentDialog::MixedFilamentDialog(wxWindow* parent, + const std::vector& physical_colors, + const std::vector& physical_names, + const std::vector& physical_types) + : DPIDialog(parent, wxID_ANY, _L("Add Mixed Filament"), wxDefaultPosition, + wxDefaultSize, wxCAPTION | wxCLOSE_BOX) + , m_edit_mode(false) + , m_physical_colors(physical_colors) + , m_physical_names(physical_names) + , m_physical_types(physical_types) +{ + m_result.components = {1, (physical_colors.size() >= 2) ? 2u : 1u}; + m_result.ratios = {50, 50}; + build_ui(); + wxGetApp().UpdateDlgDarkUI(this); +} + +MixedFilamentDialog::~MixedFilamentDialog() +{ + // Backstop: a child must never be destroyed while it still holds the mouse + // capture. wxWidgets only asserts about this (compiled out in release), and + // the macOS port never unwinds its capture stack, so the stale entry would + // make wxNSWindow::sendEvent swallow every mouse event in the application. + if (m_ratio_bar && m_ratio_bar->HasCapture()) + m_ratio_bar->ReleaseMouse(); + if (m_triangle_panel && m_triangle_panel->HasCapture()) + m_triangle_panel->ReleaseMouse(); +} + +MixedFilamentDialog::MixedFilamentDialog(wxWindow* parent, + const MixedFilamentResult& existing, + const std::vector& physical_colors, + const std::vector& physical_names, + const std::vector& physical_types) + : DPIDialog(parent, wxID_ANY, _L("Edit Mixed Filament"), wxDefaultPosition, + wxDefaultSize, wxCAPTION | wxCLOSE_BOX) + , m_result(existing) + , m_edit_mode(true) + , m_physical_colors(physical_colors) + , m_physical_names(physical_names) + , m_physical_types(physical_types) +{ + if (m_result.components.size() < 2) { + m_result.components = {1, (physical_colors.size() >= 2) ? 2u : 1u}; + m_result.ratios = {50, 50}; + } + if (m_result.ratios.size() >= 3) { + int sum = 0; + for (int r : m_result.ratios) sum += r; + if (sum > 0) { + m_tri_wx = (double)m_result.ratios[0] / sum; + m_tri_wy = (double)m_result.ratios[1] / sum; + m_tri_wz = (double)m_result.ratios[2] / sum; + } + } + build_ui(); + wxGetApp().UpdateDlgDarkUI(this); +} + +void MixedFilamentDialog::on_dpi_changed(const wxRect&) +{ + int h = (num_components() >= 3) ? FromDIP(680) : FromDIP(580); + SetSize(FromDIP(439), h); + Refresh(); +} + +wxColour MixedFilamentDialog::comp_colour(size_t i) const +{ + unsigned int c = comp(i); + if (c >= 1 && c <= m_physical_colors.size()) + return wxColour(m_physical_colors[c - 1]); + return wxColour("#D9D9D9"); +} + +static wxBitmap make_alpha_bitmap(int w, int h, + const std::function& draw_fn) +{ + wxBitmap bmp(w, h); + wxMemoryDC memdc; +#ifdef __WXOSX__ + bmp.UseAlpha(); + memdc.SelectObject(bmp); +#else + { + wxImage img(w, h); + img.InitAlpha(); + memset(img.GetAlpha(), 0, w * h); + bmp = wxBitmap(std::move(img)); + } + memdc.SelectObject(bmp); +#endif + { +#ifdef __WXMSW__ + wxGCDC dc(memdc); +#else + wxDC& dc = memdc; +#endif + draw_fn(dc); + } + memdc.SelectObject(wxNullBitmap); + return bmp; +} + +wxBitmap MixedFilamentDialog::make_swatch_bitmap(size_t idx) +{ + int swatch_sz = FromDIP(20); + int pad_left = FromDIP(2); + int pad_right = FromDIP(6); + int bmp_w = pad_left + swatch_sz + pad_right; + int bmp_h = swatch_sz; + + // Reuse the sidebar clr_picker swatch (get_extruder_color_icon) so the + // checkerboard (transparent.svg tiling), border and label style match the + // sidebar exactly, instead of a self-drawn rounded rect / programmatic grid. + std::string color_hex = "#D9D9D9"; + if (idx < m_physical_colors.size()) + color_hex = m_physical_colors[idx]; + std::string label = std::to_string(idx + 1); + + wxBitmap* icon = get_extruder_color_icon(color_hex, label, swatch_sz, swatch_sz); + + return make_alpha_bitmap(bmp_w, bmp_h, [&](wxDC& dc) { + if (icon && icon->IsOk()) + dc.DrawBitmap(*icon, pad_left, 0); + }); +} + +void MixedFilamentDialog::apply_uniform_label_width(wxStaticText* lbl) +{ + // A material row places the combo right after the label, so the combo x follows the label + // width and the rows drift apart with fonts that render digits at different advances (which + // is what macOS does). Reserve the width of the widest row label on every row instead. + // The label itself is used as the measuring device on purpose: SetMinSize overrides the + // control's own best size rather than being merged with it, and on macOS the native cell is + // wider than the plain text extent, so a wxDC-measured width would clip the text. + const wxString text = lbl->GetLabel(); + int w = 0; + for (int i = 1; i <= MAX_COMPONENTS; ++i) { + lbl->SetLabel(wxString::Format(_L("Filament %d"), i)); + lbl->InvalidateBestSize(); + w = std::max(w, lbl->GetBestSize().x); + } + lbl->SetLabel(text); + lbl->InvalidateBestSize(); + lbl->SetMinSize(wxSize(w, -1)); +} + +void MixedFilamentDialog::append_material_row() +{ + auto* row = new wxBoxSizer(wxHORIZONTAL); + auto* lbl = new wxStaticText(this, wxID_ANY, + wxString::Format(_L("Filament %d"), (int)(m_combo_filaments.size() + 1))); + lbl->SetFont(::Label::Body_12); + apply_uniform_label_width(lbl); + row->Add(lbl, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(8)); + + auto* combo = new ComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, + wxSize(FromDIP(166), FromDIP(24)), 0, nullptr, wxCB_READONLY); + combo->SetKeepDropArrow(true); + combo->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { on_filament_changed(); }); + row->Add(combo, 1, wxALIGN_CENTER_VERTICAL); + + m_combo_filaments.push_back(combo); + m_combo_to_physical.push_back({}); + m_material_rows_sizer->Add(row, 0, wxEXPAND | wxTOP, FromDIP(9)); +} + +void MixedFilamentDialog::reset_manual_ratio_state() +{ + m_ratio_manual_order.clear(); + if (m_ratio_editor_panel) + m_ratio_editor_panel->Hide(); + // Restore any label hidden by an in-flight editor so it can never be left + // permanently invisible if the editor is dismissed without a commit. + if (m_ratio_editor_anchor) { + m_ratio_editor_anchor->Show(); + m_ratio_editor_anchor = nullptr; + } +} + +void MixedFilamentDialog::refresh_ratio_labels() +{ + if (m_label_ratio_a) + m_label_ratio_a->SetLabel(wxString::Format(wxT("%d%%"), ratio(0))); + if (m_label_ratio_b) + m_label_ratio_b->SetLabel(wxString::Format(wxT("%d%%"), ratio(1))); + if (m_ratio_sizer) + m_ratio_sizer->Layout(); + if (m_triangle_panel) + m_triangle_panel->Refresh(); +} + +void MixedFilamentDialog::sync_triangle_weights_from_ratios() +{ + if (m_result.ratios.size() < 3) + return; + + int sum = 0; + for (int r : m_result.ratios) + sum += r; + if (sum <= 0) + return; + + m_tri_wx = (double)m_result.ratios[0] / sum; + m_tri_wy = (double)m_result.ratios[1] / sum; + m_tri_wz = (double)m_result.ratios[2] / sum; +} + +void MixedFilamentDialog::apply_manual_ratio(size_t idx, int value) +{ + const size_t n = num_components(); + if (idx >= n) + return; + if (m_result.ratios.size() != n) + m_result.ratios.assign(n, n > 0 ? 100 / (int)n : 0); + bool manual_stale = false; + for (size_t o : m_ratio_manual_order) { + if (o >= n) { manual_stale = true; break; } + } + if (manual_stale) + reset_manual_ratio_state(); + + int max_value = (int)(100 - (n - 1) * MIN_COMPONENT_RATIO); + value = std::clamp(value, MIN_COMPONENT_RATIO, std::max(MIN_COMPONENT_RATIO, max_value)); + + if (n == 2) { + if (idx == 0) { + m_result.ratios[0] = value; + m_result.ratios[1] = 100 - value; + } else { + m_result.ratios[1] = value; + m_result.ratios[0] = 100 - value; + } + m_result.ratios[0] = std::clamp(m_result.ratios[0], MIN_COMPONENT_RATIO, 100 - MIN_COMPONENT_RATIO); + m_result.ratios[1] = 100 - m_result.ratios[0]; + } else if (n >= 3) { + m_result.ratios[idx] = value; + int remaining = 100 - value; + + std::vector others; + int others_sum = 0; + for (size_t i = 0; i < n; ++i) { + if (i == idx) continue; + others.push_back(i); + others_sum += m_result.ratios[i]; + } + + if (!others.empty()) { + if (others_sum > 0) { + int assigned = 0; + for (size_t k = 0; k < others.size(); ++k) { + int nv = (int)((double)remaining * m_result.ratios[others[k]] / others_sum + 0.5); + nv = std::max(nv, MIN_COMPONENT_RATIO); + m_result.ratios[others[k]] = nv; + assigned += nv; + } + while (assigned != remaining) { + if (assigned > remaining) { + int pick = -1; + for (size_t k = 0; k < others.size(); ++k) + if (m_result.ratios[others[k]] > MIN_COMPONENT_RATIO + && (pick < 0 || m_result.ratios[others[k]] > m_result.ratios[others[pick]])) + pick = (int)k; + if (pick < 0) break; + --m_result.ratios[others[pick]]; --assigned; + } else { + int pick = 0; + for (size_t k = 1; k < others.size(); ++k) + if (m_result.ratios[others[k]] > m_result.ratios[others[pick]]) + pick = (int)k; + ++m_result.ratios[others[pick]]; ++assigned; + } + } + } else { + int base = remaining / (int)others.size(); + for (size_t k = 0; k < others.size(); ++k) + m_result.ratios[others[k]] = base; + m_result.ratios[others.back()] += remaining - base * (int)others.size(); + } + } + } + + refresh_ratio_labels(); + sync_triangle_weights_from_ratios(); + update_preview(); +} + +void MixedFilamentDialog::apply_dragged_triangle_ratio(int r0, int r1, int r2) +{ + if (m_result.ratios.size() < 3) + return; + + int ratios[3] = { + std::clamp(r0, MIN_COMPONENT_RATIO, 100), + std::clamp(r1, MIN_COMPONENT_RATIO, 100), + std::clamp(r2, MIN_COMPONENT_RATIO, 100) + }; + + int sum = ratios[0] + ratios[1] + ratios[2]; + while (sum > 100) { + int idx = 0; + for (int i = 1; i < 3; ++i) { + if (ratios[i] > ratios[idx]) + idx = i; + } + if (ratios[idx] <= MIN_COMPONENT_RATIO) + break; + --ratios[idx]; + --sum; + } + while (sum < 100) { + int idx = 0; + for (int i = 1; i < 3; ++i) { + if (ratios[i] < ratios[idx]) + idx = i; + } + ++ratios[idx]; + ++sum; + } + + m_result.ratios[0] = ratios[0]; + m_result.ratios[1] = ratios[1]; + m_result.ratios[2] = ratios[2]; + sync_triangle_weights_from_ratios(); + reset_manual_ratio_state(); + update_preview(); +} + +void MixedFilamentDialog::start_ratio_editor(size_t idx, wxWindow* anchor, const wxRect& anchor_rect) +{ + if (!anchor || idx >= m_result.ratios.size()) + return; + if (m_ratio_editor_panel && m_ratio_editor_panel->IsShown()) + commit_ratio_editor(true); + + if (!m_ratio_editor_panel) { + wxColour bg = StateColor::darkModeColorFor(wxColour("#F8F8F8")); + wxColour fg = StateColor::darkModeColorFor(wxColour("#262E30")); + + m_ratio_editor_panel = new wxPanel(this, wxID_ANY, wxDefaultPosition, + wxDefaultSize, wxBORDER_SIMPLE); + m_ratio_editor_panel->SetBackgroundColour(bg); + + auto* hsizer = new wxBoxSizer(wxHORIZONTAL); + + m_ratio_editor = new wxTextCtrl(m_ratio_editor_panel, wxID_ANY, wxEmptyString, + wxDefaultPosition, wxDefaultSize, + wxTE_PROCESS_ENTER | wxTE_RIGHT | wxBORDER_NONE); + m_ratio_editor->SetFont(::Label::Body_10); + m_ratio_editor->SetMaxLength(3); + m_ratio_editor->SetBackgroundColour(bg); + m_ratio_editor->SetForegroundColour(fg); + // Default wxTextCtrl best width (~140px) is too wide for the sizer to + // shrink, which would push the "%" suffix out of the panel. Size the + // editor for the *widest* three digits rather than the largest accepted + // value: SetMaxLength above lets anything up to "888" be typed, and the + // macOS system font renders digits at different advances, so "100" is + // narrower than what the user can actually enter. GetSizeFromTextSize() + // then adds the platform's own text field margins; on macOS those margins + // are what clipped the digits. + { + wxClientDC mdc(m_ratio_editor); + mdc.SetFont(::Label::Body_10); + int digits_w = mdc.GetTextExtent(wxT("888")).GetWidth(); + m_ratio_editor->SetMinSize(m_ratio_editor->GetSizeFromTextSize(digits_w)); + } + + auto* pct_label = new wxStaticText(m_ratio_editor_panel, wxID_ANY, wxT("%")); + pct_label->SetFont(::Label::Body_10); + pct_label->SetForegroundColour(fg); + pct_label->SetBackgroundColour(bg); + pct_label->SetMinSize(pct_label->GetBestSize()); + + hsizer->Add(m_ratio_editor, 1, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(2)); + hsizer->Add(pct_label, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(2)); + m_ratio_editor_panel->SetSizer(hsizer); + m_ratio_editor_panel->Hide(); + + m_ratio_editor->Bind(wxEVT_TEXT_ENTER, [this](wxCommandEvent&) { commit_ratio_editor(true); }); + m_ratio_editor->Bind(wxEVT_KILL_FOCUS, [this](wxFocusEvent& e) { + commit_ratio_editor(true); + e.Skip(); + }); + m_ratio_editor->Bind(wxEVT_CHAR_HOOK, [this](wxKeyEvent& e) { + if (e.GetKeyCode() == WXK_ESCAPE) + commit_ratio_editor(false); + else + e.Skip(); + }); + } + + m_ratio_editor_idx = idx; + + // Keep the editor in the same window hierarchy as the clicked label so the + // z-order is reliable and the editor fully covers the anchor (dual-color + // labels live on the dialog, triple-color labels live on the triangle + // panel). + wxWindow* target_parent = anchor->GetParent(); + if (target_parent && m_ratio_editor_panel->GetParent() != target_parent) + m_ratio_editor_panel->Reparent(target_parent); + + // Hide the label being edited to avoid its (hover-state) text leaking out + // next to the editor; restored on commit. + m_ratio_editor_anchor = anchor; + anchor->Hide(); + + wxPoint pos = anchor->GetPosition() + anchor_rect.GetTopLeft(); + // Match the editor to the label (hover box) size so the inline editor and + // the hover state look identical, but never go below what the digits and + // the "%" suffix need: the sizer takes any missing width out of the + // stretchable editor, which would clip the value. + wxSize needed = m_ratio_editor_panel->ClientToWindowSize( + m_ratio_editor_panel->GetSizer()->CalcMin()); + wxSize size = anchor->GetSize(); + size.SetWidth(std::max(size.GetWidth(), needed.GetWidth())); + size.SetHeight(std::max(size.GetHeight(), needed.GetHeight())); + // An editor wider than the label must still stay inside its parent, or the + // corner labels of the triangle picker would have it clipped at the edge. + if (wxWindow* editor_parent = m_ratio_editor_panel->GetParent()) { + wxSize avail = editor_parent->GetClientSize(); + pos.x = std::clamp(pos.x, 0, std::max(0, avail.GetWidth() - size.GetWidth())); + pos.y = std::clamp(pos.y, 0, std::max(0, avail.GetHeight() - size.GetHeight())); + } + m_ratio_editor_panel->SetSize(wxRect(pos, size)); + m_ratio_editor_panel->Layout(); + m_ratio_editor->SetValue(wxString::Format(wxT("%d"), ratio(idx))); + m_ratio_editor_panel->Show(); + m_ratio_editor_panel->Raise(); + m_ratio_editor->SetFocus(); + m_ratio_editor->SelectAll(); + m_ratio_editor_panel->Refresh(); + Update(); +} + +void MixedFilamentDialog::commit_ratio_editor(bool apply) +{ + if (!m_ratio_editor_panel || !m_ratio_editor_panel->IsShown() || m_ratio_editor_committing) + return; + + m_ratio_editor_committing = true; + + // Restore the hidden anchor before applying the ratio, so any sizer layout + // triggered by refresh_ratio_labels() accounts for the visible label. + m_ratio_editor_panel->Hide(); + if (m_ratio_editor_anchor) { + m_ratio_editor_anchor->Show(); + m_ratio_editor_anchor = nullptr; + } + + if (apply) { + wxString value = m_ratio_editor->GetValue(); + value.Trim(true); + value.Trim(false); + if (value.EndsWith(wxT("%"))) + value.RemoveLast(); + + long parsed = 0; + if (value.ToLong(&parsed)) + apply_manual_ratio(m_ratio_editor_idx, (int)parsed); + else + refresh_ratio_labels(); + } + + m_ratio_editor_committing = false; +} + +void MixedFilamentDialog::commit_ratio_editor_from_background(wxMouseEvent& e) +{ + if (m_ratio_editor_panel && m_ratio_editor_panel->IsShown()) { + wxPoint mouse_in_panel = m_ratio_editor_panel->ScreenToClient(wxGetMousePosition()); + if (!m_ratio_editor_panel->GetClientRect().Contains(mouse_in_panel)) + commit_ratio_editor(true); + } + e.Skip(); +} + +// ---- UI Construction ---- + +void MixedFilamentDialog::build_ui() +{ + SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + Bind(wxEVT_LEFT_DOWN, &MixedFilamentDialog::commit_ratio_editor_from_background, this); + SetSize(FromDIP(439), FromDIP(580)); + + auto* main_sizer = new wxBoxSizer(wxVERTICAL); + + auto* top_sizer = new wxBoxSizer(wxHORIZONTAL); + top_sizer->Add(create_preview_panel(), 0, wxALL, FromDIP(20)); + + m_right_sizer = new wxBoxSizer(wxVERTICAL); + m_right_sizer->Add(create_material_selection(), 0, wxEXPAND); + m_right_sizer->Add(create_gradient_section(), 0, wxEXPAND | wxTOP, FromDIP(7)); + + m_ratio_sizer = create_ratio_slider(); + m_right_sizer->Add(m_ratio_sizer, 0, wxEXPAND | wxTOP, FromDIP(7)); + + m_triangle_sizer = create_triangle_picker(); + m_right_sizer->Add(m_triangle_sizer, 0, wxALIGN_CENTER_HORIZONTAL | wxTOP, FromDIP(7)); + + top_sizer->Add(m_right_sizer, 1, wxTOP | wxRIGHT | wxBOTTOM, FromDIP(20)); + main_sizer->Add(top_sizer, 0, wxEXPAND); + + main_sizer->Add(create_recommendation_grid(), 1, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(25)); + + // Warning panel: red bordered box with exclamation icon + text + m_warning_sizer = new wxBoxSizer(wxVERTICAL); + m_warning_panel = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(-1, FromDIP(48))); + m_warning_panel->SetMinSize(wxSize(-1, FromDIP(48))); + m_warning_panel->SetBackgroundStyle(wxBG_STYLE_PAINT); + m_warning_panel->Bind(wxEVT_PAINT, &MixedFilamentDialog::paint_warning_panel, this); + m_warning_sizer->Add(m_warning_panel, 0, wxEXPAND); + main_sizer->Add(m_warning_sizer, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, FromDIP(25)); + m_warning_panel->Hide(); + + main_sizer->Add(create_button_panel(), 0, wxALIGN_RIGHT | wxALL, FromDIP(20)); + + SetSizer(main_sizer); + + rebuild_all_combos(); + update_component_count_ui(); + update_preview(); + update_ok_button_state(); + + Layout(); + CentreOnParent(); +} + +wxBoxSizer* MixedFilamentDialog::create_preview_panel() +{ + auto* sizer = new wxBoxSizer(wxVERTICAL); + + m_preview_canvas = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(FromDIP(129), FromDIP(129))); + m_preview_canvas->SetBackgroundStyle(wxBG_STYLE_PAINT); + + m_preview_canvas->Bind(wxEVT_PAINT, [this](wxPaintEvent&) { + wxBufferedPaintDC dc(m_preview_canvas); + wxSize sz = m_preview_canvas->GetClientSize(); + size_t n = num_components(); + + dc.SetBrush(wxBrush(StateColor::darkModeColorFor(*wxWHITE))); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + + if (n == 0) return; + + int swatch_sz = FromDIP(80); + int x0 = (sz.GetWidth() - swatch_sz) / 2; + int y0 = (sz.GetHeight() - swatch_sz) / 2; + double radius = FromDIP(6); + + if (m_result.gradient_enabled && n == 2) { + Slic3r::GradientCurve curve; + if (!m_result.gradient_curve.empty()) { + curve.points = m_result.gradient_curve; + } else { + double yStart = (m_result.gradient_direction == 0) ? kGradientMaxRatio : kGradientMinRatio; + double yEnd = (m_result.gradient_direction == 0) ? kGradientMinRatio : kGradientMaxRatio; + curve.points = {{0.0, yStart, NAN, NAN}, {1.0, yEnd, NAN, NAN}}; + } + + // Same sampler the sidebar, extruder icons and paint gizmo swatches use, so this + // preview and every swatch drawn for the filament agree on what it looks like. + auto ramp = sample_gradient_ramp(comp_colour(0), comp_colour(1), curve, std::max(80, swatch_sz)); + fill_gradient_ramp_rect(dc, wxRect(x0, y0, swatch_sz, swatch_sz), ramp); + + // Mask corners: overdraw a thick background-colored rounded rect frame + // so the inner edge forms the desired rounded corners. + // Known limitation: this assumes the panel background equals + // darkModeColorFor(white). wxGraphicsContext::Clip(path) is not + // available in our wxWidgets build (only Clip(wxRegion) exists). + int r = static_cast(radius); + wxColour bg = StateColor::darkModeColorFor(*wxWHITE); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.SetPen(wxPen(bg, r * 2)); + dc.DrawRoundedRectangle(x0 - r, y0 - r, swatch_sz + r * 2, swatch_sz + r * 2, radius * 2); + } else { + std::vector cols; + std::vector weights; + for (size_t i = 0; i < n; ++i) { + cols.push_back(comp_colour(i)); + weights.push_back(ratio(i) / 100.0); + } + wxColour mixed = blend_n_colors(cols, weights); + dc.SetBrush(wxBrush(mixed)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRoundedRectangle(x0, y0, swatch_sz, swatch_sz, radius); + } + }); + + sizer->Add(m_preview_canvas, 0, wxALIGN_CENTER); + + auto* label = new wxStaticText(this, wxID_ANY, _L("Effect Preview")); + label->SetForegroundColour(StateColor::darkModeColorFor(COLOR_LABEL_MUTED)); + label->SetFont(::Label::Body_13); + sizer->Add(label, 0, wxALIGN_CENTER | wxTOP, FromDIP(4)); + + return sizer; +} + +wxBoxSizer* MixedFilamentDialog::create_material_selection() +{ + auto* sizer = new wxBoxSizer(wxVERTICAL); + + // Summary panel — draws N components dynamically + m_summary_panel = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(FromDIP(234), FromDIP(40))); + m_summary_panel->SetBackgroundStyle(wxBG_STYLE_PAINT); + m_summary_panel->Bind(wxEVT_PAINT, [this](wxPaintEvent&) { + wxBufferedPaintDC dc(m_summary_panel); + wxSize sz = m_summary_panel->GetClientSize(); + + wxColour sum_bg = StateColor::darkModeColorFor(wxColour("#F8F8F8")); + wxColour sum_text = StateColor::darkModeColorFor(wxColour("#262E30")); + dc.SetBrush(wxBrush(sum_bg)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + + int swatch_sz = FromDIP(20); + int y_center = (sz.GetHeight() - swatch_sz) / 2; + int x = FromDIP(13); + + dc.SetFont(::Label::Body_13); + + auto draw_summary_swatch = [&](size_t comp_idx) { + unsigned int c = comp(comp_idx); + std::string color_hex = "#D9D9D9"; + if (c >= 1 && c <= m_physical_colors.size()) + color_hex = m_physical_colors[c - 1]; + std::string label = std::to_string(c); + wxBitmap* icon = get_extruder_color_icon(color_hex, label, swatch_sz, swatch_sz); + if (icon && icon->IsOk()) + dc.DrawBitmap(*icon, x, y_center); + x += swatch_sz + FromDIP(4); + }; + + if (m_result.gradient_enabled && num_components() == 2) { + size_t idx_a = (m_result.gradient_direction == 0) ? 0 : 1; + size_t idx_b = 1 - idx_a; + draw_summary_swatch(idx_a); + + dc.SetTextForeground(sum_text); + wxString arrow = wxT("\u2192"); + wxSize arrow_sz = dc.GetTextExtent(arrow); + dc.DrawText(arrow, x, y_center + (swatch_sz - arrow_sz.GetHeight()) / 2); + x += arrow_sz.GetWidth() + FromDIP(4); + + draw_summary_swatch(idx_b); + } else { + for (size_t i = 0; i < num_components(); ++i) { + if (i > 0) { + dc.SetTextForeground(sum_text); + wxString plus = wxT("+"); + wxSize plus_sz = dc.GetTextExtent(plus); + dc.DrawText(plus, x, y_center + (swatch_sz - plus_sz.GetHeight()) / 2); + x += plus_sz.GetWidth() + FromDIP(4); + } + draw_summary_swatch(i); + + dc.SetTextForeground(sum_text); + wxString pct = wxString::Format(wxT("%d%%"), ratio(i)); + wxSize pct_sz = dc.GetTextExtent(pct); + dc.DrawText(pct, x, y_center + (swatch_sz - pct_sz.GetHeight()) / 2); + x += pct_sz.GetWidth() + FromDIP(4); + } + } + }); + sizer->Add(m_summary_panel, 0, wxEXPAND); + + auto* sel_label = new wxStaticText(this, wxID_ANY, _L("Select Mixed Materials")); + sel_label->SetForegroundColour(StateColor::darkModeColorFor(COLOR_LABEL_MUTED)); + sel_label->SetFont(::Label::Body_12); + sizer->Add(sel_label, 0, wxTOP, FromDIP(6)); + + m_material_rows_sizer = new wxBoxSizer(wxVERTICAL); + + m_combo_filaments.clear(); + m_combo_to_physical.clear(); + for (size_t i = 0; i < m_result.components.size(); ++i) + append_material_row(); + + sizer->Add(m_material_rows_sizer, 0, wxEXPAND); + + auto* btn_sizer = new wxBoxSizer(wxHORIZONTAL); + + m_btn_add_material = new Button(this, _L("+ Add Material")); + m_btn_add_material->SetBackgroundColor(wxColour("#F8F8F8")); + m_btn_add_material->SetBorderColor(wxColour("#EEEEEE")); + // The disabled tone rides on the StateColor so Enable() alone repaints it, the way m_btn_ok does. + m_btn_add_material->SetTextColor(StateColor( + std::make_pair(wxColour("#ACACAC"), (int) StateColor::Disabled), + std::make_pair(wxColour("#262E30"), (int) StateColor::Normal))); + m_btn_add_material->SetMinSize(wxSize(-1, FromDIP(24))); + m_btn_add_material->SetCursor(wxCursor(wxCURSOR_HAND)); + m_btn_add_material->EnableTooltipEvenDisabled(); + m_btn_add_material->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_add_material(); }); + btn_sizer->Add(m_btn_add_material, 1, wxRIGHT, FromDIP(6)); + + m_btn_remove_material = new Button(this, _L("- Delete Material")); + m_btn_remove_material->SetBackgroundColor(wxColour("#F8F8F8")); + m_btn_remove_material->SetBorderColor(wxColour("#EEEEEE")); + m_btn_remove_material->SetTextColor(wxColour("#262E30")); + m_btn_remove_material->SetMinSize(wxSize(-1, FromDIP(24))); + m_btn_remove_material->SetCursor(wxCursor(wxCURSOR_HAND)); + m_btn_remove_material->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { on_remove_material(); }); + m_btn_remove_material->Hide(); + btn_sizer->Add(m_btn_remove_material, 1, 0, 0); + + sizer->Add(btn_sizer, 0, wxEXPAND | wxTOP, FromDIP(9)); + + return sizer; +} + +wxBoxSizer* MixedFilamentDialog::create_ratio_slider() +{ + auto* sizer = new wxBoxSizer(wxVERTICAL); + + auto* ratio_label = new wxStaticText(this, wxID_ANY, _L("Ratio")); + ratio_label->SetForegroundColour(StateColor::darkModeColorFor(COLOR_LABEL_MUTED)); + ratio_label->SetFont(::Label::Body_12); + sizer->Add(ratio_label, 0, wxBOTTOM, FromDIP(4)); + + m_ratio_bar = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(-1, FromDIP(27))); + m_ratio_bar->SetMinSize(wxSize(-1, FromDIP(27))); + m_ratio_bar->SetBackgroundStyle(wxBG_STYLE_PAINT); + + m_ratio_bar->Bind(wxEVT_PAINT, [this](wxPaintEvent&) { + wxBufferedPaintDC dc(m_ratio_bar); + wxSize sz = m_ratio_bar->GetClientSize(); + + wxColour col_a = comp_colour(0), col_b = comp_colour(1); + + for (int x = 0; x < sz.GetWidth(); ++x) { + double t = (double)x / sz.GetWidth(); + wxColour c = blend_colors(col_a, col_b, 1.0 - t); + dc.SetPen(wxPen(c)); + dc.DrawLine(x, 0, x, sz.GetHeight()); + } + + int div_x = (int)(ratio(1) / 100.0 * sz.GetWidth()); + // Fixed in both themes, like the triangle picker's drag handle: the divider is drawn over + // blended filament colour, so it has to keep its contrast against data rather than chrome. + dc.SetPen(wxPen(wxColour(80, 80, 80), FromDIP(4))); + dc.DrawLine(div_x, 0, div_x, sz.GetHeight()); + dc.SetPen(wxPen(*wxWHITE, FromDIP(2))); + dc.DrawLine(div_x, 0, div_x, sz.GetHeight()); + }); + + m_ratio_bar->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& e) { + if (m_ratio_editor_panel && m_ratio_editor_panel->IsShown()) + commit_ratio_editor(true); + m_ratio_dragging = true; + if (!m_ratio_bar->HasCapture()) + m_ratio_bar->CaptureMouse(); + int new_ratio = 100 - (int)(e.GetX() * 100.0 / m_ratio_bar->GetClientSize().GetWidth() + 0.5); + on_ratio_changed(std::max(MIN_COMPONENT_RATIO, std::min(100 - MIN_COMPONENT_RATIO, new_ratio))); + }); + + m_ratio_bar->Bind(wxEVT_MOTION, [this](wxMouseEvent& e) { + if (!m_ratio_dragging) return; + int new_ratio = 100 - (int)(e.GetX() * 100.0 / m_ratio_bar->GetClientSize().GetWidth() + 0.5); + on_ratio_changed(std::max(MIN_COMPONENT_RATIO, std::min(100 - MIN_COMPONENT_RATIO, new_ratio))); + }); + + // Key the release off the capture itself, not off the drag flag: the two can fall out of + // sync (a lost capture clears the flag on its own), and a capture that outlives the widget + // wedges mouse input for the whole application. + m_ratio_bar->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent&) { + m_ratio_dragging = false; + if (m_ratio_bar->HasCapture()) + m_ratio_bar->ReleaseMouse(); + }); + + m_ratio_bar->Bind(wxEVT_MOUSE_CAPTURE_LOST, [this](wxMouseCaptureLostEvent&) { + m_ratio_dragging = false; + }); + + sizer->Add(m_ratio_bar, 0, wxEXPAND); + + auto* pct_sizer = new wxBoxSizer(wxHORIZONTAL); + m_label_ratio_a = new RatioLabelPanel(this); + m_label_ratio_a->SetLabel(wxString::Format(wxT("%d%%"), ratio(0))); + m_label_ratio_b = new RatioLabelPanel(this); + m_label_ratio_b->SetLabel(wxString::Format(wxT("%d%%"), ratio(1))); + auto bind_ratio_click = [this](RatioLabelPanel* label, size_t idx) { + label->Bind(wxEVT_LEFT_DOWN, [this, label, idx](wxMouseEvent&) { + wxRect rect(wxPoint(0, 0), label->GetClientSize()); + start_ratio_editor(idx, label, rect); + }); + }; + bind_ratio_click(m_label_ratio_a, 0); + bind_ratio_click(m_label_ratio_b, 1); + pct_sizer->Add(m_label_ratio_a, 0); + pct_sizer->AddStretchSpacer(1); + pct_sizer->Add(m_label_ratio_b, 0); + sizer->Add(pct_sizer, 0, wxEXPAND | wxTOP, FromDIP(2)); + + return sizer; +} + +// ---- Triangle (ternary) ratio picker ---- + +// Barycentric coordinate utilities +struct TriPoint { double x, y; }; + +static double tri_signed_area2(TriPoint a, TriPoint b, TriPoint c) +{ + return (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y); +} + +static bool tri_contains(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2) +{ + double total = tri_signed_area2(v0, v1, v2); + if (std::abs(total) < 1e-9) return false; + double s0 = tri_signed_area2(p, v1, v2) / total; + double s1 = tri_signed_area2(v0, p, v2) / total; + double s2 = 1.0 - s0 - s1; + return s0 >= -0.001 && s1 >= -0.001 && s2 >= -0.001; +} + +static void tri_barycentric(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2, + double& w0, double& w1, double& w2) +{ + double total = std::abs(tri_signed_area2(v0, v1, v2)); + if (total < 1e-9) { w0 = w1 = w2 = 1.0 / 3.0; return; } + w0 = std::abs(tri_signed_area2(p, v1, v2)) / total; + w1 = std::abs(tri_signed_area2(v0, p, v2)) / total; + w2 = 1.0 - w0 - w1; + w0 = std::clamp(w0, 0.0, 1.0); + w1 = std::clamp(w1, 0.0, 1.0); + w2 = std::clamp(w2, 0.0, 1.0); + double s = w0 + w1 + w2; + if (s > 0) { w0 /= s; w1 /= s; w2 /= s; } +} + +static TriPoint tri_clamp(TriPoint p, TriPoint v0, TriPoint v1, TriPoint v2) +{ + double w0, w1, w2; + tri_barycentric(p, v0, v1, v2, w0, w1, w2); + w0 = std::clamp(w0, 0.0, 1.0); + w1 = std::clamp(w1, 0.0, 1.0); + w2 = std::clamp(w2, 0.0, 1.0); + double s = w0 + w1 + w2; + if (s > 0) { w0 /= s; w1 /= s; w2 /= s; } + return {w0 * v0.x + w1 * v1.x + w2 * v2.x, + w0 * v0.y + w1 * v1.y + w2 * v2.y}; +} + +wxBoxSizer* MixedFilamentDialog::create_triangle_picker() +{ + auto* sizer = new wxBoxSizer(wxVERTICAL); + + int panel_w = FromDIP(160); + int panel_h = FromDIP(160); + m_triangle_panel = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(panel_w, panel_h)); + m_triangle_panel->SetMinSize(wxSize(panel_w, panel_h)); + m_triangle_panel->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + m_triangle_panel->SetBackgroundStyle(wxBG_STYLE_PAINT); + + auto get_vertices = [this]() -> std::tuple { + wxSize sz = m_triangle_panel->GetClientSize(); + double pw = sz.GetWidth(), ph = sz.GetHeight(); + double margin = FromDIP(20); + double avail = std::min(pw, ph) - 2 * margin; + double side = avail; + double tri_h = side * std::sqrt(3.0) / 2.0; + double cx = pw / 2.0; + double top_y = (ph - tri_h) / 2.0; + double bot_y = top_y + tri_h; + TriPoint v0 = {cx, top_y}; // top + TriPoint v1 = {cx - side / 2.0, bot_y}; // bottom-left + TriPoint v2 = {cx + side / 2.0, bot_y}; // bottom-right + return {v0, v1, v2}; + }; + + m_triangle_panel->Bind(wxEVT_PAINT, [this, get_vertices](wxPaintEvent&) { + wxBufferedPaintDC dc(m_triangle_panel); + wxSize sz = m_triangle_panel->GetClientSize(); + auto [v0, v1, v2] = get_vertices(); + + wxColour tri_bg = StateColor::darkModeColorFor(*wxWHITE); + dc.SetBrush(wxBrush(tri_bg)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + + wxColour c0 = comp_colour(0), c1 = comp_colour(1), c2 = comp_colour(2); + + const bool cache_valid = m_tri_cache_bmp.IsOk() && + m_tri_cache_size == sz && + m_tri_cache_c0 == c0 && m_tri_cache_c1 == c1 && m_tri_cache_c2 == c2; + + if (!cache_valid) { + int min_y = (int)std::min({v0.y, v1.y, v2.y}); + int max_y = (int)std::max({v0.y, v1.y, v2.y}); + int min_x = (int)std::min({v0.x, v1.x, v2.x}); + int max_x = (int)std::max({v0.x, v1.x, v2.x}); + + m_tri_cache_bmp = wxBitmap(sz.GetWidth(), sz.GetHeight(), 24); + wxMemoryDC mdc(m_tri_cache_bmp); + mdc.SetBrush(wxBrush(tri_bg)); + mdc.SetPen(*wxTRANSPARENT_PEN); + mdc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + + for (int py = min_y; py <= max_y; ++py) { + for (int px = min_x; px <= max_x; ++px) { + TriPoint p = {(double)px, (double)py}; + if (!tri_contains(p, v0, v1, v2)) continue; + double w0, w1, w2; + tri_barycentric(p, v0, v1, v2, w0, w1, w2); + unsigned char mr, mg, mb; + if (w0 + w1 > 1e-6) { + float t01 = static_cast(w1 / (w0 + w1)); + Slic3r::filament_mixer_lerp(c0.Red(), c0.Green(), c0.Blue(), + c1.Red(), c1.Green(), c1.Blue(), + t01, &mr, &mg, &mb); + float t2 = static_cast(w2); + Slic3r::filament_mixer_lerp(mr, mg, mb, + c2.Red(), c2.Green(), c2.Blue(), + t2, &mr, &mg, &mb); + } else { + mr = c2.Red(); mg = c2.Green(); mb = c2.Blue(); + } + mdc.SetPen(wxPen(wxColour(mr, mg, mb))); + mdc.DrawPoint(px, py); + } + } + + mdc.SetPen(wxPen(StateColor::darkModeColorFor(wxColour("#CECECE")), 1)); + mdc.SetBrush(*wxTRANSPARENT_BRUSH); + wxPoint pts[3] = {{(int)v0.x, (int)v0.y}, {(int)v1.x, (int)v1.y}, {(int)v2.x, (int)v2.y}}; + mdc.DrawPolygon(3, pts); + + mdc.SelectObject(wxNullBitmap); + m_tri_cache_c0 = c0; m_tri_cache_c1 = c1; m_tri_cache_c2 = c2; + m_tri_cache_size = sz; + } + + dc.DrawBitmap(m_tri_cache_bmp, 0, 0); + + // Drag handle (always redrawn on top of cached bitmap) + double hx = m_tri_wx * v0.x + m_tri_wy * v1.x + m_tri_wz * v2.x; + double hy = m_tri_wx * v0.y + m_tri_wy * v1.y + m_tri_wz * v2.y; + int handle_r = FromDIP(5); + dc.SetBrush(*wxWHITE_BRUSH); + dc.SetPen(wxPen(wxColour("#262E30"), FromDIP(2))); + dc.DrawCircle((int)hx, (int)hy, handle_r); + + if (m_result.ratios.size() >= 3) { + dc.SetFont(::Label::Body_10); + wxSize ts0 = dc.GetTextExtent(wxString::Format(wxT("%d%%"), m_result.ratios[0])); + int top_label_y = std::max(0, (int)(v0.y - ts0.GetHeight() - FromDIP(4))); + + dc.SetFont(::Label::Body_12); + dc.SetTextForeground(StateColor::darkModeColorFor(COLOR_LABEL_MUTED)); + dc.DrawText(_L("Ratio"), FromDIP(2), top_label_y); + + // Position the real RatioLabelPanel children + for (int i = 0; i < 3 && i < (int)m_triangle_ratio_labels.size(); ++i) { + if (!m_triangle_ratio_labels[i]) continue; + m_triangle_ratio_labels[i]->SetLabel( + wxString::Format(wxT("%d%%"), m_result.ratios[i])); + wxSize lsz = m_triangle_ratio_labels[i]->GetMinSize(); + int lx = 0, ly = 0; + if (i == 0) { + lx = (int)(v0.x - lsz.GetWidth() / 2); + ly = top_label_y; + } else if (i == 1) { + lx = (int)(v1.x - lsz.GetWidth() / 2); + ly = (int)(v1.y + FromDIP(3)); + } else { + lx = (int)(v2.x - lsz.GetWidth() / 2); + ly = (int)(v2.y + FromDIP(3)); + } + m_triangle_ratio_labels[i]->SetSize(lx, ly, lsz.GetWidth(), lsz.GetHeight()); + } + } + }); + + auto handle_mouse = [this, get_vertices](wxMouseEvent& e, bool is_down) { + auto [v0, v1, v2] = get_vertices(); + TriPoint p = {(double)e.GetX(), (double)e.GetY()}; + + if (is_down) { + // Only start dragging when the press lands inside the triangle; + // clicks outside the triangle must not change the mix ratio. + if (!tri_contains(p, v0, v1, v2)) + return; + m_tri_dragging = true; + if (!m_triangle_panel->HasCapture()) + m_triangle_panel->CaptureMouse(); + } + + if (!m_tri_dragging) return; + + TriPoint clamped = tri_clamp(p, v0, v1, v2); + tri_barycentric(clamped, v0, v1, v2, m_tri_wx, m_tri_wy, m_tri_wz); + + int r0 = (int)(m_tri_wx * 100 + 0.5); + int r1 = (int)(m_tri_wy * 100 + 0.5); + int r2 = 100 - r0 - r1; + r0 = std::clamp(r0, 0, 100); + r1 = std::clamp(r1, 0, 100); + r2 = std::clamp(r2, 0, 100); + + apply_dragged_triangle_ratio(r0, r1, r2); + }; + + // Create 3 RatioLabelPanel children on the triangle panel + m_triangle_ratio_labels.fill(nullptr); + for (int i = 0; i < 3; ++i) { + auto* lbl = new RatioLabelPanel(m_triangle_panel); + lbl->SetLabel(wxString::Format(wxT("%d%%"), + (i < (int)m_result.ratios.size()) ? m_result.ratios[i] : 33)); + size_t idx = (size_t)i; + lbl->Bind(wxEVT_LEFT_DOWN, [this, lbl, idx](wxMouseEvent&) { + wxRect rect(wxPoint(0, 0), lbl->GetClientSize()); + start_ratio_editor(idx, lbl, rect); + }); + m_triangle_ratio_labels[i] = lbl; + } + + m_triangle_panel->Bind(wxEVT_LEFT_DOWN, [this, handle_mouse](wxMouseEvent& e) { + if (m_ratio_editor_panel && m_ratio_editor_panel->IsShown()) + commit_ratio_editor(true); + handle_mouse(e, true); + }); + m_triangle_panel->Bind(wxEVT_MOTION, [this, handle_mouse](wxMouseEvent& e) { + if (m_tri_dragging) + handle_mouse(e, false); + }); + m_triangle_panel->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent&) { + m_tri_dragging = false; + if (m_triangle_panel->HasCapture()) + m_triangle_panel->ReleaseMouse(); + }); + m_triangle_panel->Bind(wxEVT_MOUSE_CAPTURE_LOST, [this](wxMouseCaptureLostEvent&) { + m_tri_dragging = false; + }); + + sizer->Add(m_triangle_panel, 0); + + return sizer; +} + +wxBoxSizer* MixedFilamentDialog::create_gradient_section() +{ + m_gradient_sizer = new wxBoxSizer(wxHORIZONTAL); + + m_chk_gradient = new ::CheckBox(this); + m_chk_gradient->SetValue(m_result.gradient_enabled); + m_chk_gradient->Bind(wxEVT_TOGGLEBUTTON, [this](wxCommandEvent& e) { e.Skip(); on_gradient_toggled(); }); + m_gradient_sizer->Add(m_chk_gradient, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, FromDIP(4)); + + m_label_gradient = new wxStaticText(this, wxID_ANY, _L("Gradient Effect")); + m_label_gradient->SetFont(::Label::Body_13); + m_gradient_sizer->Add(m_label_gradient, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); + + m_combo_gradient_dir = new ComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, + wxSize(FromDIP(152), FromDIP(24)), 0, nullptr, wxCB_READONLY); + m_combo_gradient_dir->SetKeepDropArrow(true); + update_gradient_direction_items(); + m_combo_gradient_dir->SetSelection(m_result.gradient_direction); + m_combo_gradient_dir->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { on_gradient_direction_changed(); }); + m_combo_gradient_dir->Show(m_result.gradient_enabled); + + m_gradient_sizer->Add(m_combo_gradient_dir, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); + + auto* outer = new wxBoxSizer(wxVERTICAL); + outer->Add(m_gradient_sizer, 0, wxEXPAND); + + // Custom curve editor: visible only when gradient is on and exactly 2 components are mixed. + m_curve_sizer = new wxBoxSizer(wxVERTICAL); + m_curve_editor = new GradientCurveEditor(this, comp_colour(0), comp_colour(1)); + if (!m_result.gradient_curve.empty()) + m_curve_editor->set_points(m_result.gradient_curve); + else + m_curve_editor->reset_to_linear((m_result.gradient_direction == 0) ? 0.9 : 0.1, + (m_result.gradient_direction == 0) ? 0.1 : 0.9); + m_curve_editor->Bind(wxEVT_GRADIENT_CURVE_CHANGED, + [this](wxCommandEvent&) { on_gradient_curve_changed(); }); + m_curve_sizer->Add(m_curve_editor, 0, wxEXPAND | wxTOP, FromDIP(4)); + + outer->Add(m_curve_sizer, 0, wxEXPAND | wxTOP, FromDIP(6)); + const bool curve_visible = m_result.gradient_enabled && num_components() == 2; + m_curve_sizer->ShowItems(curve_visible); + + // Per-part gradient toggle sits BELOW the curve editor. + m_per_part_gradient_sizer = new wxBoxSizer(wxHORIZONTAL); + + m_chk_per_part_gradient = new ::CheckBox(this); + m_chk_per_part_gradient->SetValue(m_result.per_part_gradient); + m_chk_per_part_gradient->Bind(wxEVT_TOGGLEBUTTON, + [this](wxCommandEvent& e) { e.Skip(); on_per_part_gradient_toggled(); }); + m_per_part_gradient_sizer->Add(m_chk_per_part_gradient, 0, + wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, FromDIP(4)); + + m_label_per_part_gradient = new wxStaticText(this, wxID_ANY, _L("Enable per-part gradient effect")); + m_label_per_part_gradient->SetFont(::Label::Body_13); + m_per_part_gradient_sizer->Add(m_label_per_part_gradient, 0, + wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(8)); + + outer->Add(m_per_part_gradient_sizer, 0, wxEXPAND | wxTOP, FromDIP(2)); + m_per_part_gradient_sizer->ShowItems(m_result.gradient_enabled); + + return outer; +} + +wxBoxSizer* MixedFilamentDialog::create_recommendation_grid() +{ + auto* outer = new wxBoxSizer(wxVERTICAL); + + auto* title_sizer = new wxBoxSizer(wxHORIZONTAL); + auto* rec_label = new wxStaticText(this, wxID_ANY, _L("Mixing Recommendations")); + rec_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#ACACAC"))); + rec_label->SetFont(::Label::Body_10); + title_sizer->Add(rec_label, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); + + auto* rec_line = new wxPanel(this, wxID_ANY); + rec_line->SetMinSize(wxSize(-1, 1)); + rec_line->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#EEEEEE"))); + title_sizer->Add(rec_line, 1, wxALIGN_CENTER_VERTICAL); + + outer->Add(title_sizer, 0, wxEXPAND | wxBOTTOM, FromDIP(4)); + + m_recommendation_scroll = new wxScrolledWindow(this, wxID_ANY, wxDefaultPosition, wxSize(-1, FromDIP(116))); + m_recommendation_scroll->SetScrollRate(0, 5); + m_recommendation_scroll->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#F8F8F8"))); + + m_recommendation_grid = new wxWrapSizer(wxHORIZONTAL, wxREMOVE_LEADING_SPACES); + auto* scroll_inner_sizer = new wxBoxSizer(wxVERTICAL); + scroll_inner_sizer->Add(m_recommendation_grid, 1, wxEXPAND | wxLEFT | wxTOP, FromDIP(8)); + m_recommendation_scroll->SetSizer(scroll_inner_sizer); + + rebuild_recommendation_items(); + + outer->Add(m_recommendation_scroll, 1, wxEXPAND | wxTOP, FromDIP(4)); + return outer; +} + +void MixedFilamentDialog::rebuild_recommendation_items() +{ + if (!m_recommendation_scroll || !m_recommendation_grid) + return; + + static constexpr int MAX_RECOMMENDATIONS = 100; + + m_recommendation_scroll->Freeze(); + m_recommendation_grid->Clear(true); + + size_t n = m_physical_colors.size(); + int count = 0; + + // Group physical filaments by type (only same-type combos are recommended) + std::map> type_groups; + for (size_t i = 0; i < n; ++i) { + std::string t = (i < m_physical_types.size()) ? m_physical_types[i] : "PLA"; + // Skip support filaments (type ends with "-S") + if (t.size() >= 2 && t.compare(t.size() - 2, 2, "-S") == 0) + continue; + type_groups[t].push_back(i); + } + + if (num_components() >= 3) { + // Three-color: C(g,3) x 3 variants per same-type group + for (auto& [type, indices] : type_groups) { + if (count >= MAX_RECOMMENDATIONS) break; + size_t g = indices.size(); + for (size_t ai = 0; ai < g && count < MAX_RECOMMENDATIONS; ++ai) { + for (size_t bi = ai + 1; bi < g && count < MAX_RECOMMENDATIONS; ++bi) { + for (size_t ci = bi + 1; ci < g && count < MAX_RECOMMENDATIONS; ++ci) { + size_t idx[3] = {indices[ai], indices[bi], indices[ci]}; + // 3 variants: each filament takes the 50% role in turn + for (int dominant = 0; dominant < 3 && count < MAX_RECOMMENDATIONS; ++dominant) { + size_t i0 = idx[(dominant + 1) % 3]; // 25% + size_t i1 = idx[(dominant + 2) % 3]; // 25% + size_t i2 = idx[dominant]; // 50% + + wxColour ca(m_physical_colors[i0]); + wxColour cb(m_physical_colors[i1]); + wxColour cc(m_physical_colors[i2]); + wxColour mixed = blend_n_colors({ca, cb, cc}, {0.25, 0.25, 0.50}); + + auto* item = new wxPanel(m_recommendation_scroll, wxID_ANY, + wxDefaultPosition, wxSize(FromDIP(20), FromDIP(20))); + item->SetBackgroundColour(mixed); + item->SetCursor(wxCursor(wxCURSOR_HAND)); + + unsigned int ca_1 = (unsigned int)(i0 + 1); + unsigned int cb_1 = (unsigned int)(i1 + 1); + unsigned int cc_1 = (unsigned int)(i2 + 1); + item->Bind(wxEVT_LEFT_UP, [this, ca_1, cb_1, cc_1](wxMouseEvent&) { + on_recommendation_clicked_triple(ca_1, cb_1, cc_1); + }); + item->SetToolTip(wxString::Format(wxT("%s + %s + %s"), + wxString::FromUTF8(m_physical_names[i0]), + wxString::FromUTF8(m_physical_names[i1]), + wxString::FromUTF8(m_physical_names[i2]))); + + m_recommendation_grid->Add(item, 0, wxRIGHT | wxBOTTOM, FromDIP(6)); + ++count; + } + } + } + } + } + } else { + // Two-color: C(g,2) per same-type group + for (auto& [type, indices] : type_groups) { + if (count >= MAX_RECOMMENDATIONS) break; + size_t g = indices.size(); + for (size_t ai = 0; ai < g && count < MAX_RECOMMENDATIONS; ++ai) { + for (size_t bi = ai + 1; bi < g && count < MAX_RECOMMENDATIONS; ++bi) { + size_t i = indices[ai]; + size_t j = indices[bi]; + + wxColour ca(m_physical_colors[i]); + wxColour cb(m_physical_colors[j]); + wxColour mixed = blend_colors(ca, cb, 0.5); + + auto* item = new wxPanel(m_recommendation_scroll, wxID_ANY, + wxDefaultPosition, wxSize(FromDIP(20), FromDIP(20))); + item->SetBackgroundColour(mixed); + item->SetCursor(wxCursor(wxCURSOR_HAND)); + + unsigned int comp_a = (unsigned int)(i + 1); + unsigned int comp_b = (unsigned int)(j + 1); + item->Bind(wxEVT_LEFT_UP, [this, comp_a, comp_b](wxMouseEvent&) { + on_recommendation_clicked(comp_a, comp_b); + }); + item->SetToolTip(wxString::Format(wxT("%s + %s"), + wxString::FromUTF8(m_physical_names[i]), + wxString::FromUTF8(m_physical_names[j]))); + + m_recommendation_grid->Add(item, 0, wxRIGHT | wxBOTTOM, FromDIP(6)); + ++count; + } + } + } + } + + m_recommendation_scroll->SetScrollbars(0, FromDIP(20), 0, 1); + m_recommendation_scroll->FitInside(); + m_recommendation_scroll->Layout(); + m_recommendation_scroll->Thaw(); +} + +wxBoxSizer* MixedFilamentDialog::create_button_panel() +{ + auto* sizer = new wxBoxSizer(wxHORIZONTAL); + + m_btn_cancel = new Button(this, _L("Cancel")); + m_btn_cancel->SetStyle(ButtonStyle::Regular, ButtonType::Choice); + m_btn_cancel->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_CANCEL); }); + + m_btn_ok = new Button(this, _L("OK")); + m_btn_ok->SetStyle(ButtonStyle::Confirm, ButtonType::Choice); + m_btn_ok->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { EndModal(wxID_OK); }); + + sizer->Add(m_btn_cancel, 0, wxRIGHT, FromDIP(12)); + sizer->Add(m_btn_ok, 0); + + return sizer; +} + +void MixedFilamentDialog::rebuild_all_combos() +{ + m_combo_to_physical.resize(m_combo_filaments.size()); + + for (size_t i = 0; i < m_combo_filaments.size(); ++i) { + std::set others_selected; + std::set others_types; + for (size_t k = 0; k < m_result.components.size(); ++k) { + if (k == i) continue; + unsigned int phys = m_result.components[k]; + others_selected.insert(phys); + if (phys >= 1 && phys <= m_physical_types.size()) + others_types.insert(m_physical_types[phys - 1]); + } + + auto* combo = m_combo_filaments[i]; + combo->Clear(); + m_combo_to_physical[i].clear(); + + int restore_sel = -1; + unsigned int cur_phys = (i < m_result.components.size()) ? m_result.components[i] : 0; + + if (cur_phys == 0) { + combo->Append(_L("-- Select --")); + m_combo_to_physical[i].push_back(0); + restore_sel = 0; + } + + for (size_t j = 0; j < m_physical_names.size(); ++j) { + unsigned int phys_1based = (unsigned int)(j + 1); + + if (others_selected.count(phys_1based)) + continue; + + int style = 0; + if (!others_types.empty() && !m_physical_types.empty()) { + std::string this_type = (j < m_physical_types.size()) ? m_physical_types[j] : "PLA"; + if (others_types.find(this_type) == others_types.end()) + style = DD_ITEM_STYLE_DIMMED; + } + + int idx = combo->Append(wxString::FromUTF8(m_physical_names[j]), make_swatch_bitmap(j), style); + m_combo_to_physical[i].push_back(phys_1based); + + if (phys_1based == cur_phys) + restore_sel = idx; + } + + if (restore_sel >= 0) + combo->SetSelection(restore_sel); + else if (combo->GetCount() > 0) + combo->SetSelection(0); + } +} + +void MixedFilamentDialog::refresh_curve_editor_colors() +{ + if (m_curve_editor) + m_curve_editor->set_colors(comp_colour(0), comp_colour(1)); +} + +// ---- Event Handlers ---- + +void MixedFilamentDialog::on_filament_changed() +{ + for (size_t i = 0; i < m_combo_filaments.size() && i < m_result.components.size(); ++i) { + int sel = m_combo_filaments[i]->GetSelection(); + if (sel >= 0 && i < m_combo_to_physical.size() && sel < (int)m_combo_to_physical[i].size()) + m_result.components[i] = m_combo_to_physical[i][sel]; + } + + refresh_curve_editor_colors(); + rebuild_all_combos(); + update_gradient_direction_items(); + update_preview(); + update_ok_button_state(); +} + +void MixedFilamentDialog::on_ratio_changed(int new_ratio_a) +{ + if (m_result.ratios.size() < 2) return; + m_result.ratios[0] = new_ratio_a; + m_result.ratios[1] = 100 - new_ratio_a; + + reset_manual_ratio_state(); + refresh_ratio_labels(); + + update_preview(); +} + +void MixedFilamentDialog::on_gradient_toggled() +{ + + m_result.gradient_enabled = m_chk_gradient->GetValue(); + + if (m_ratio_sizer) + m_ratio_sizer->ShowItems(!m_result.gradient_enabled && num_components() == 2); + if (m_combo_gradient_dir) + m_combo_gradient_dir->Show(m_result.gradient_enabled); + if (m_per_part_gradient_sizer) + m_per_part_gradient_sizer->ShowItems(m_result.gradient_enabled); + if (m_curve_sizer) + m_curve_sizer->ShowItems(m_result.gradient_enabled && num_components() == 2); + if (!m_result.gradient_enabled) { + m_result.per_part_gradient = false; + if (m_chk_per_part_gradient) m_chk_per_part_gradient->SetValue(false); + } + + // Toggling the curve editor changes the right column height (and width when + // turning gradient on), so the dialog must follow or the recommendation list + // gets squeezed off-screen. Same trick as 2-color -> 3-color switching. + const wxSize new_size = compute_dialog_size(); + if (GetSize() != new_size) { + const wxRect old_rect = GetRect(); + const wxPoint center(old_rect.x + old_rect.width / 2, + old_rect.y + old_rect.height / 2); + SetSize(new_size); + SetPosition(wxPoint(center.x - new_size.x / 2, + center.y - new_size.y / 2)); + } + + Layout(); + Refresh(); +} + +void MixedFilamentDialog::on_gradient_direction_changed() +{ + if (!m_combo_gradient_dir) return; + m_result.gradient_direction = m_combo_gradient_dir->GetSelection(); + + // Mirror the user's custom curve around y=0.5 instead of resetting it, so + // shape work (added anchors, bent segments) survives a direction toggle. + // reverse() flips y and tangent signs consistently; default two-point + // linear curves end up matching the new direction exactly (0.9->0.1 <-> 0.1->0.9). + if (m_curve_editor) { + m_curve_editor->reverse(); + m_result.gradient_curve = m_curve_editor->get_points(); + } + update_preview(); +} + +void MixedFilamentDialog::on_gradient_curve_changed() +{ + if (m_curve_editor) + m_result.gradient_curve = m_curve_editor->get_points(); + update_preview(); +} + +void MixedFilamentDialog::on_per_part_gradient_toggled() +{ + if (m_chk_per_part_gradient) + m_result.per_part_gradient = m_chk_per_part_gradient->GetValue(); +} + +void MixedFilamentDialog::on_add_material() +{ + size_t n = num_components(); + if (n >= (size_t)MAX_COMPONENTS) return; + + unsigned int new_comp = 0; + for (size_t j = 0; j < m_physical_names.size(); ++j) { + unsigned int candidate = (unsigned int)(j + 1); + bool used = false; + for (auto c : m_result.components) + if (c == candidate) { used = true; break; } + if (!used) { new_comp = candidate; break; } + } + if (new_comp == 0) return; + m_result.components.push_back(new_comp); + + int each = 100 / (int)(n + 1); + m_result.ratios.clear(); + int assigned = 0; + for (size_t i = 0; i < n; ++i) { + m_result.ratios.push_back(each); + assigned += each; + } + m_result.ratios.push_back(100 - assigned); + + if (m_result.ratios.size() >= 3) { + int sum = 0; + for (int r : m_result.ratios) sum += r; + if (sum > 0) { + m_tri_wx = (double)m_result.ratios[0] / sum; + m_tri_wy = (double)m_result.ratios[1] / sum; + m_tri_wz = (double)m_result.ratios[2] / sum; + } + } + reset_manual_ratio_state(); + + append_material_row(); + + rebuild_all_combos(); + refresh_curve_editor_colors(); + update_component_count_ui(); + update_preview(); + update_ok_button_state(); + rebuild_recommendation_items(); + + Layout(); + Refresh(); +} + +void MixedFilamentDialog::on_remove_material() +{ + if (num_components() <= 2) + return; + + m_result.components.resize(2); + m_result.ratios = {50, 50}; + m_tri_wx = 0.5; + m_tri_wy = 0.5; + m_tri_wz = 0.0; + + reset_manual_ratio_state(); + refresh_ratio_labels(); + + if (m_material_rows_sizer && m_material_rows_sizer->GetItemCount() > 2) { + auto* sizer_item = m_material_rows_sizer->GetItem(m_material_rows_sizer->GetItemCount() - 1); + if (sizer_item && sizer_item->GetSizer()) + sizer_item->GetSizer()->Clear(true); + m_material_rows_sizer->Remove(m_material_rows_sizer->GetItemCount() - 1); + } + + if (m_combo_filaments.size() > 2) + m_combo_filaments.pop_back(); + if (m_combo_to_physical.size() > 2) + m_combo_to_physical.pop_back(); + + rebuild_all_combos(); + refresh_curve_editor_colors(); + update_component_count_ui(); + update_preview(); + update_ok_button_state(); + rebuild_recommendation_items(); + Layout(); + Refresh(); +} + +void MixedFilamentDialog::on_recommendation_clicked(unsigned int comp_a, unsigned int comp_b) +{ + while (m_material_rows_sizer->GetItemCount() > 2) { + auto* sizer_item = m_material_rows_sizer->GetItem(m_material_rows_sizer->GetItemCount() - 1); + if (sizer_item && sizer_item->GetSizer()) + sizer_item->GetSizer()->Clear(true); + m_material_rows_sizer->Remove(m_material_rows_sizer->GetItemCount() - 1); + } + + while (m_combo_filaments.size() > 2) + m_combo_filaments.pop_back(); + while (m_combo_to_physical.size() > 2) + m_combo_to_physical.pop_back(); + + m_result.components = {comp_a, comp_b}; + m_result.ratios = {50, 50}; + + reset_manual_ratio_state(); + refresh_ratio_labels(); + + rebuild_all_combos(); + refresh_curve_editor_colors(); + update_gradient_direction_items(); + update_component_count_ui(); + update_preview(); + update_ok_button_state(); + Layout(); + Refresh(); +} + +void MixedFilamentDialog::on_recommendation_clicked_triple(unsigned int a, unsigned int b, unsigned int c) +{ + // Ensure we have exactly 3 combo rows + if (num_components() < 3) { + // Need to add a 3rd combo row + while (m_combo_filaments.size() < 3) + append_material_row(); + } else if (num_components() > 3) { + while (m_material_rows_sizer->GetItemCount() > 3) { + auto* sizer_item = m_material_rows_sizer->GetItem(m_material_rows_sizer->GetItemCount() - 1); + if (sizer_item && sizer_item->GetSizer()) + sizer_item->GetSizer()->Clear(true); + m_material_rows_sizer->Remove(m_material_rows_sizer->GetItemCount() - 1); + } + while (m_combo_filaments.size() > 3) + m_combo_filaments.pop_back(); + while (m_combo_to_physical.size() > 3) + m_combo_to_physical.pop_back(); + } + + m_result.components = {a, b, c}; + m_result.ratios = {25, 25, 50}; + m_tri_wx = 0.25; + m_tri_wy = 0.25; + m_tri_wz = 0.50; + reset_manual_ratio_state(); + + rebuild_all_combos(); + refresh_curve_editor_colors(); + update_gradient_direction_items(); + update_component_count_ui(); + update_preview(); + update_ok_button_state(); + Layout(); + Refresh(); +} + +void MixedFilamentDialog::update_preview() +{ + if (m_preview_canvas) m_preview_canvas->Refresh(); + if (m_summary_panel) m_summary_panel->Refresh(); + if (m_ratio_bar) m_ratio_bar->Refresh(); + if (m_triangle_panel) m_triangle_panel->Refresh(); +} + +void MixedFilamentDialog::paint_warning_panel(wxPaintEvent&) +{ + wxBufferedPaintDC dc(m_warning_panel); + wxSize sz = m_warning_panel->GetClientSize(); + + dc.SetBrush(wxBrush(StateColor::darkModeColorFor(*wxWHITE))); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + + dc.SetBrush(wxBrush(StateColor::darkModeColorFor(wxColour("#F8F8F8")))); + dc.SetPen(wxPen(StateColor::darkModeColorFor(wxColour("#D01B1B")), 1)); + dc.DrawRoundedRectangle(0, 0, sz.GetWidth(), sz.GetHeight(), FromDIP(4)); + + int x = FromDIP(10); + int cy = sz.GetHeight() / 2; + + int icon_r = FromDIP(7); + dc.SetBrush(wxBrush(StateColor::darkModeColorFor(wxColour("#D01B1B")))); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawCircle(x + icon_r, cy, icon_r); + dc.SetFont(::Label::Body_10); + dc.SetTextForeground(*wxWHITE); + wxSize ex = dc.GetTextExtent(wxT("!")); + dc.DrawText(wxT("!"), x + icon_r - ex.GetWidth() / 2, cy - ex.GetHeight() / 2); + x += icon_r * 2 + FromDIP(6); + + if (m_type_mismatch_msg.empty()) return; + + dc.SetFont(::Label::Body_12); + dc.SetTextForeground(StateColor::darkModeColorFor(wxColour("#D01B1B"))); + wxString msg = m_type_mismatch_msg; + int avail_w = sz.GetWidth() - x - FromDIP(10); + wxSize ts = dc.GetTextExtent(msg); + if (ts.GetWidth() <= avail_w) { + dc.DrawText(msg, x, cy - ts.GetHeight() / 2); + } else { + wxArrayString lines; + wxString cur_line; + wxArrayString words; + wxStringTokenizer tkz(msg, wxT(" "), wxTOKEN_RET_EMPTY_ALL); + while (tkz.HasMoreTokens()) words.Add(tkz.GetNextToken()); + if (words.empty()) words.Add(msg); + for (size_t w = 0; w < words.size(); ++w) { + wxString test = cur_line.empty() ? words[w] : cur_line + wxT(" ") + words[w]; + if (dc.GetTextExtent(test).GetWidth() > avail_w && !cur_line.empty()) { + lines.Add(cur_line); + cur_line = words[w]; + } else { + cur_line = test; + } + } + if (!cur_line.empty()) lines.Add(cur_line); + if (lines.empty()) lines.Add(msg); + int line_h = dc.GetTextExtent(wxT("Mg")).GetHeight(); + int total_h = (int)lines.size() * line_h; + int y0 = (sz.GetHeight() - total_h) / 2; + for (size_t l = 0; l < lines.size(); ++l) + dc.DrawText(lines[l], x, y0 + (int)l * line_h); + } +} + +void MixedFilamentDialog::update_ok_button_state() +{ + if (!m_btn_ok) return; + + bool has_type_mismatch = false; + if (!m_physical_types.empty() && m_result.components.size() >= 2) { + std::map> type_groups; + for (size_t i = 0; i < m_result.components.size(); ++i) { + unsigned int phys = m_result.components[i]; + if (phys < 1 || phys > m_physical_types.size()) continue; + type_groups[m_physical_types[phys - 1]].push_back(phys); + } + has_type_mismatch = type_groups.size() > 1; + if (has_type_mismatch) { + wxString parts; + for (auto it = type_groups.begin(); it != type_groups.end(); ++it) { + if (!parts.empty()) + parts += _L(" and "); + wxString slots; + for (size_t j = 0; j < it->second.size(); ++j) { + if (!slots.empty()) slots += ", "; + slots += std::to_string(it->second[j]); + } + parts += wxString::Format(_L("Slot %s (%s)"), slots, wxString::FromUTF8(it->first)); + } + m_type_mismatch_msg = parts + " " + _L("cannot be mixed. Please select the same filament type."); + } else { + m_type_mismatch_msg.clear(); + } + } else { + m_type_mismatch_msg.clear(); + } + + bool has_unselected = false; + for (unsigned int c : m_result.components) { + if (c == 0) { has_unselected = true; break; } + } + + bool can_confirm = !has_type_mismatch && !has_unselected; + // Enable() alone repaints the button: its StateColor carries the disabled grey. + m_btn_ok->Enable(can_confirm); + if (has_unselected) + m_btn_ok->SetToolTip(_L("Please select a filament for all components")); + else if (has_type_mismatch) + m_btn_ok->SetToolTip(_L("Cannot mix different filament types")); + else + m_btn_ok->SetToolTip(wxEmptyString); + + if (m_warning_panel) { + m_warning_panel->Show(has_type_mismatch); + // Force a repaint: when the panel is already visible and only the + // mismatch text changes (e.g. PETG -> ABS), Show()/Layout() do not + // generate a paint event, so paint_warning_panel keeps the stale text. + m_warning_panel->Refresh(); + Layout(); + } +} + +void MixedFilamentDialog::update_gradient_direction_items() +{ + if (!m_combo_gradient_dir) return; + + int prev_sel = m_combo_gradient_dir->GetSelection(); + m_combo_gradient_dir->Clear(); + + if (num_components() < 2) return; + + auto make_direction_bitmap = [this](size_t idx_from, size_t idx_to) -> wxBitmap { + int swatch_sz = FromDIP(20); + int arrow_w = FromDIP(16); + int gap = FromDIP(4); + int bmp_w = swatch_sz + gap + arrow_w + gap + swatch_sz; + int bmp_h = swatch_sz; + + wxColour dir_text = StateColor::darkModeColorFor(wxColour("#262E30")); + + return make_alpha_bitmap(bmp_w, bmp_h, [&](wxDC& dc) { + dc.SetFont(::Label::Body_13); + + auto draw_swatch = [&](int x, size_t idx) { + std::string color_hex = "#D9D9D9"; + if (idx < m_physical_colors.size()) + color_hex = m_physical_colors[idx]; + std::string label = std::to_string(idx + 1); + wxBitmap* icon = get_extruder_color_icon(color_hex, label, swatch_sz, swatch_sz); + if (icon && icon->IsOk()) + dc.DrawBitmap(*icon, x, 0); + }; + + int x = 0; + draw_swatch(x, idx_from); + x += swatch_sz + gap; + + dc.SetTextForeground(dir_text); + wxString arrow = wxT("\u2192"); + wxSize arrow_sz = dc.GetTextExtent(arrow); + dc.DrawText(arrow, x + (arrow_w - arrow_sz.GetWidth()) / 2, + (bmp_h - arrow_sz.GetHeight()) / 2); + x += arrow_w + gap; + + draw_swatch(x, idx_to); + }); + }; + + size_t idx_a = (comp(0) >= 1) ? comp(0) - 1 : 0; + size_t idx_b = (comp(1) >= 1) ? comp(1) - 1 : 1; + + m_combo_gradient_dir->Append(wxT(" "), make_direction_bitmap(idx_a, idx_b)); + m_combo_gradient_dir->Append(wxT(" "), make_direction_bitmap(idx_b, idx_a)); + + if (prev_sel >= 0 && prev_sel < (int)m_combo_gradient_dir->GetCount()) + m_combo_gradient_dir->SetSelection(prev_sel); + else + m_combo_gradient_dir->SetSelection(0); +} + +wxSize MixedFilamentDialog::compute_dialog_size() const +{ + const bool is_three = (num_components() >= 3); + const bool curve_visible = !is_three && m_result.gradient_enabled; + + int w = FromDIP(439); + int h = FromDIP(580); + if (is_three) { + h = FromDIP(680); + } else if (curve_visible) { + // Wider so the gradient editor can show "Material Ratio" intact; + // +40 over the 3-color height to fit the curve editor while keeping the + // recommendation list visible (it can still scroll if needed). + w = FromDIP(470); + h = FromDIP(720); + } + return wxSize(w, h); +} + +void MixedFilamentDialog::update_component_count_ui() +{ + bool is_two = (num_components() == 2); + bool is_three = (num_components() >= 3); + + // Toggle ratio slider vs triangle picker + if (m_ratio_sizer) + m_ratio_sizer->ShowItems(is_two && !m_result.gradient_enabled); + if (m_triangle_sizer) + m_triangle_sizer->ShowItems(is_three); + + // 3-color: hide gradient entirely, force off + if (m_gradient_sizer) { + bool show_gradient = is_two; + m_chk_gradient->Show(show_gradient); + if (m_label_gradient) m_label_gradient->Show(show_gradient); + m_combo_gradient_dir->Show(show_gradient && m_result.gradient_enabled); + if (m_per_part_gradient_sizer) + m_per_part_gradient_sizer->ShowItems(show_gradient && m_result.gradient_enabled); + if (m_curve_sizer) + m_curve_sizer->ShowItems(show_gradient && m_result.gradient_enabled); + } + if (is_three) { + m_result.gradient_enabled = false; + if (m_chk_gradient) m_chk_gradient->SetValue(false); + m_result.per_part_gradient = false; + if (m_chk_per_part_gradient) m_chk_per_part_gradient->SetValue(false); + } + + if (m_btn_add_material) { + bool can_add = (num_components() < (size_t)MAX_COMPONENTS && m_physical_colors.size() > num_components()); + m_btn_add_material->Enable(can_add); + m_btn_add_material->SetToolTip(can_add ? wxString() + : (is_three ? _L("Maximum 3 materials for mixing") : _L("Maximum number of components reached"))); + } + + if (m_btn_remove_material) { + m_btn_remove_material->Show(is_three); + m_btn_remove_material->Enable(is_three); + m_btn_remove_material->SetToolTip(is_three ? _L("Remove the third material") : wxString()); + } + + const wxSize new_size = compute_dialog_size(); + const wxRect old_rect = GetRect(); + const wxPoint center(old_rect.x + old_rect.width / 2, + old_rect.y + old_rect.height / 2); + SetSize(new_size); + SetPosition(wxPoint(center.x - new_size.x / 2, + center.y - new_size.y / 2)); + Layout(); +} + +} // namespace GUI +} // namespace Slic3r diff --git a/src/slic3r/GUI/MixedFilamentDialog.hpp b/src/slic3r/GUI/MixedFilamentDialog.hpp new file mode 100644 index 0000000000..ea8ac5ad16 --- /dev/null +++ b/src/slic3r/GUI/MixedFilamentDialog.hpp @@ -0,0 +1,183 @@ +#ifndef slic3r_MixedFilamentDialog_hpp_ +#define slic3r_MixedFilamentDialog_hpp_ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "GUI_Utils.hpp" +#include "libslic3r/FilamentMixer.hpp" + +class Button; +class CheckBox; +class ComboBox; +class wxMouseEvent; +class wxScrolledWindow; +class wxTextCtrl; +class wxWrapSizer; + +namespace Slic3r { +namespace GUI { + +class GradientCurveEditor; +class RatioLabelPanel; + +struct MixedFilamentResult { + std::vector components; // 1-based physical filament indices + std::vector ratios; // percentages, sum = 100 + bool gradient_enabled = false; + int gradient_direction = 0; // 0 = A→B, 1 = B→A (only for 2-color) + bool per_part_gradient = false; // valid only when gradient_enabled == true + // Optional Photoshop-style custom curve overriding the linear A→B gradient. + // Empty -> use linear (gradient_direction). Non-empty -> cubic Hermite over [0,1]^2 + // with optional per-anchor tangent overrides (see GradientAnchor). + std::vector gradient_curve; +}; + +class MixedFilamentDialog : public DPIDialog +{ +public: + MixedFilamentDialog(wxWindow* parent, + const std::vector& physical_colors, + const std::vector& physical_names, + const std::vector& physical_types = {}); + + MixedFilamentDialog(wxWindow* parent, + const MixedFilamentResult& existing, + const std::vector& physical_colors, + const std::vector& physical_names, + const std::vector& physical_types = {}); + + ~MixedFilamentDialog(); + + MixedFilamentResult get_result() const { return m_result; } + +protected: + void on_dpi_changed(const wxRect& suggested_rect) override; + +private: + void build_ui(); + wxBoxSizer* create_preview_panel(); + wxBoxSizer* create_material_selection(); + wxBoxSizer* create_ratio_slider(); + wxBoxSizer* create_triangle_picker(); + wxBoxSizer* create_gradient_section(); + wxBoxSizer* create_recommendation_grid(); + wxBoxSizer* create_button_panel(); + + void on_filament_changed(); + void on_ratio_changed(int new_ratio_a); + void on_gradient_toggled(); + void on_gradient_direction_changed(); + void on_gradient_curve_changed(); + void on_per_part_gradient_toggled(); + void on_add_material(); + void on_remove_material(); + void on_recommendation_clicked(unsigned int comp_a, unsigned int comp_b); + void on_recommendation_clicked_triple(unsigned int a, unsigned int b, unsigned int c); + void apply_manual_ratio(size_t idx, int value); + void apply_dragged_triangle_ratio(int r0, int r1, int r2); + void reset_manual_ratio_state(); + void refresh_ratio_labels(); + void sync_triangle_weights_from_ratios(); + void start_ratio_editor(size_t idx, wxWindow* anchor, const wxRect& anchor_rect); + void commit_ratio_editor(bool apply); + void commit_ratio_editor_from_background(wxMouseEvent& e); + void update_preview(); + void update_ok_button_state(); + void update_gradient_direction_items(); + void update_component_count_ui(); + // Picks dialog (width, height) based on current state so the gradient curve + // editor and the recommendation list stay visible at the same time. + wxSize compute_dialog_size() const; + void rebuild_all_combos(); + void rebuild_recommendation_items(); + void refresh_curve_editor_colors(); + void paint_warning_panel(wxPaintEvent& evt); + + wxBitmap make_swatch_bitmap(size_t idx); + + // Reserves the same width on every material row label so the combo boxes line up. + static void apply_uniform_label_width(wxStaticText* lbl); + // Appends one "Filament N" label + combo row to m_material_rows_sizer. N follows the + // number of rows already there, so callers must not renumber anything themselves. + void append_material_row(); + + // Helpers for component/ratio access + size_t num_components() const { return m_result.components.size(); } + unsigned int comp(size_t i) const { return (i < m_result.components.size()) ? m_result.components[i] : 1; } + int ratio(size_t i) const { return (i < m_result.ratios.size()) ? m_result.ratios[i] : 0; } + wxColour comp_colour(size_t i) const; + + MixedFilamentResult m_result; + bool m_edit_mode{false}; + std::vector m_physical_colors; + std::vector m_physical_names; + std::vector m_physical_types; + wxString m_type_mismatch_msg; + + // Combo item index -> 1-based physical filament index (per combo) + std::vector> m_combo_to_physical; + + // UI controls + wxPanel* m_preview_canvas{nullptr}; + wxPanel* m_summary_panel{nullptr}; + std::vector m_combo_filaments; + wxBoxSizer* m_material_rows_sizer{nullptr}; + wxPanel* m_ratio_bar{nullptr}; + wxPanel* m_triangle_panel{nullptr}; + RatioLabelPanel* m_label_ratio_a{nullptr}; + RatioLabelPanel* m_label_ratio_b{nullptr}; + wxPanel* m_ratio_editor_panel{nullptr}; + wxTextCtrl* m_ratio_editor{nullptr}; + CheckBox* m_chk_gradient{nullptr}; + wxStaticText* m_label_gradient{nullptr}; + ComboBox* m_combo_gradient_dir{nullptr}; + wxBoxSizer* m_gradient_sizer{nullptr}; + GradientCurveEditor* m_curve_editor{nullptr}; + wxBoxSizer* m_curve_sizer{nullptr}; + CheckBox* m_chk_per_part_gradient{nullptr}; + wxStaticText* m_label_per_part_gradient{nullptr}; + wxBoxSizer* m_per_part_gradient_sizer{nullptr}; + Button* m_btn_add_material{nullptr}; + Button* m_btn_remove_material{nullptr}; + Button* m_btn_ok{nullptr}; + Button* m_btn_cancel{nullptr}; + wxBoxSizer* m_warning_sizer{nullptr}; + wxPanel* m_warning_panel{nullptr}; + + wxBoxSizer* m_ratio_sizer{nullptr}; + wxBoxSizer* m_triangle_sizer{nullptr}; + wxBoxSizer* m_right_sizer{nullptr}; + + wxScrolledWindow* m_recommendation_scroll{nullptr}; + wxWrapSizer* m_recommendation_grid{nullptr}; + + // Drag state. The ratio bar and the triangle picker capture the mouse + // independently, so they must not share a flag: a mouse-up on one would + // otherwise clear the other's flag and skip its ReleaseMouse(). + bool m_ratio_dragging{false}; + bool m_tri_dragging{false}; + std::vector m_ratio_manual_order; + size_t m_ratio_editor_idx{0}; + bool m_ratio_editor_committing{false}; + wxWindow* m_ratio_editor_anchor{nullptr}; + // Triangle picker drag point (barycentric weights) + double m_tri_wx{0.333}, m_tri_wy{0.333}, m_tri_wz{0.334}; + + // Cached triangle color bitmap (invalidated when colors or size change) + wxBitmap m_tri_cache_bmp; + wxColour m_tri_cache_c0, m_tri_cache_c1, m_tri_cache_c2; + wxSize m_tri_cache_size; + std::array m_triangle_ratio_labels{nullptr, nullptr, nullptr}; +}; + +} // namespace GUI +} // namespace Slic3r + +#endif // slic3r_MixedFilamentDialog_hpp_ diff --git a/src/slic3r/GUI/MonitorBasePanel.h b/src/slic3r/GUI/MonitorBasePanel.h index c5c76acec3..42424a7187 100644 --- a/src/slic3r/GUI/MonitorBasePanel.h +++ b/src/slic3r/GUI/MonitorBasePanel.h @@ -34,7 +34,6 @@ #include "Widgets/AxisCtrlButton.hpp" #include "Widgets/TextInput.hpp" #include "Widgets/StaticLine.hpp" -#include "wxMediaCtrl2.h" #include "MediaPlayCtrl.h" /////////////////////////////////////////////////////////////////////////// diff --git a/src/slic3r/GUI/NotificationManager.hpp b/src/slic3r/GUI/NotificationManager.hpp index 22720bf33e..bf0a5cfe1a 100644 --- a/src/slic3r/GUI/NotificationManager.hpp +++ b/src/slic3r/GUI/NotificationManager.hpp @@ -162,6 +162,8 @@ enum class NotificationType //BBL: plugin install hint BBLPluginInstallHint, BBLFlushingVolumeZero, + // A mixed-color filament references a deleted component, or its components disagree in type. + BBLMixedFilamentBroken, BBLPluginUpdateAvailable, BBLPreviewOnlyMode, BBLPrinterConfigUpdateAvailable, @@ -172,6 +174,8 @@ enum class NotificationType BBLBedFilamentIncompatible, BBLMixUsePLAAndPETG, BBLNozzleFilamentIncompatible, + // A mixed-color filament is printed on a single-nozzle printer (frequent changes and purging). + BBLSingleExtruderMixedFilamentRisk, OrcaSharedProfilesAvailable, OrcaCloudAPIError, OrcaSyncConflict, diff --git a/src/slic3r/GUI/PartPlate.cpp b/src/slic3r/GUI/PartPlate.cpp index 5e8ee31363..7bb3bec12b 100644 --- a/src/slic3r/GUI/PartPlate.cpp +++ b/src/slic3r/GUI/PartPlate.cpp @@ -6,6 +6,7 @@ #include #include #include "libslic3r/MultiNozzleUtils.hpp" +#include "libslic3r/FilamentMixer.hpp" #include #include #include @@ -1675,6 +1676,25 @@ std::vector PartPlate::get_extruders(bool conside_custom_gcode) const std::sort(plate_extruders.begin(), plate_extruders.end()); auto it_end = std::unique(plate_extruders.begin(), plate_extruders.end()); plate_extruders.resize(std::distance(plate_extruders.begin(), it_end)); + + // Expand mixed filament slots to their physical components. A mixed slot is virtual and + // is never loaded into a tray, so callers (AMS mapping, filament checks) must see the + // physical filaments it resolves to instead. + { + auto& project_config = wxGetApp().preset_bundle->project_config; + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + auto* comp_strs_opt = project_config.option("filament_mixed_components"); + if (is_mixed_opt && comp_strs_opt && has_any_mixed_filament(is_mixed_opt->values)) { + std::vector ext_0based; + for (int e : plate_extruders) + if (e >= 1) ext_0based.push_back((unsigned int)(e - 1)); + auto expanded = expand_mixed_filaments(ext_0based, is_mixed_opt->values, comp_strs_opt->values); + plate_extruders.clear(); + for (unsigned int e : expanded) + plate_extruders.push_back((int)(e + 1)); + } + } + return plate_extruders; } @@ -1836,6 +1856,24 @@ std::vector PartPlate::get_extruders_under_cli(bool conside_custom_gcode, D std::sort(plate_extruders.begin(), plate_extruders.end()); auto it_end = std::unique(plate_extruders.begin(), plate_extruders.end()); plate_extruders.resize(std::distance(plate_extruders.begin(), it_end)); + + // Expand mixed filament slots to their physical components. A mixed slot is virtual and + // is never loaded into a tray, so callers (AMS mapping, filament checks) must see the + // physical filaments it resolves to instead. + { + auto* is_mixed_opt = full_config.option("filament_is_mixed"); + auto* comp_strs_opt = full_config.option("filament_mixed_components"); + if (is_mixed_opt && comp_strs_opt && has_any_mixed_filament(is_mixed_opt->values)) { + std::vector ext_0based; + for (int e : plate_extruders) + if (e >= 1) ext_0based.push_back((unsigned int)(e - 1)); + auto expanded = expand_mixed_filaments(ext_0based, is_mixed_opt->values, comp_strs_opt->values); + plate_extruders.clear(); + for (unsigned int e : expanded) + plate_extruders.push_back((int)(e + 1)); + } + } + return plate_extruders; } @@ -1889,6 +1927,25 @@ std::vector PartPlate::get_extruders_without_support(bool conside_custom_gc std::sort(plate_extruders.begin(), plate_extruders.end()); auto it_end = std::unique(plate_extruders.begin(), plate_extruders.end()); plate_extruders.resize(std::distance(plate_extruders.begin(), it_end)); + + // Expand mixed filament slots to their physical components. A mixed slot is virtual and + // is never loaded into a tray, so callers (AMS mapping, filament checks) must see the + // physical filaments it resolves to instead. + { + auto& project_config = wxGetApp().preset_bundle->project_config; + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + auto* comp_strs_opt = project_config.option("filament_mixed_components"); + if (is_mixed_opt && comp_strs_opt && has_any_mixed_filament(is_mixed_opt->values)) { + std::vector ext_0based; + for (int e : plate_extruders) + if (e >= 1) ext_0based.push_back((unsigned int)(e - 1)); + auto expanded = expand_mixed_filaments(ext_0based, is_mixed_opt->values, comp_strs_opt->values); + plate_extruders.clear(); + for (unsigned int e : expanded) + plate_extruders.push_back((int)(e + 1)); + } + } + return plate_extruders; } @@ -1990,6 +2047,50 @@ bool PartPlate::check_tpu_printable_status(const DynamicPrintConfig & config, co return true; } +// A mixed-color filament alternates between its components constantly. On a single-nozzle +// printer every one of those switches is a full filament change plus a purge, so warn before +// slicing. Multi-nozzle printers keep the components loaded at once and are not affected. +bool PartPlate::check_single_extruder_mixed_filament_risk(const DynamicPrintConfig &config, std::string &warning_text) const +{ + warning_text.clear(); + + auto *nozzle_diameter_opt = config.option("nozzle_diameter"); + if (!nozzle_diameter_opt || nozzle_diameter_opt->values.size() > 1) + return false; + + auto *is_mixed_opt = wxGetApp().preset_bundle->project_config.option("filament_is_mixed"); + if (!is_mixed_opt || !has_any_mixed_filament(is_mixed_opt->values)) + return false; + + auto is_mixed_slot = [&](int extruder_1based) { + size_t idx = (size_t)(extruder_1based - 1); + return idx < is_mixed_opt->values.size() && is_mixed_opt->values[idx]; + }; + + const std::string mixed_warn_msg = _u8L("Printing mixed-color filament on a single-extruder printer requires frequent filament changes and flushing, " + "which may significantly increase waste and the risk of nozzle / waste-chute clogging."); + + for (int obj_idx = 0; obj_idx < (int)m_model->objects.size(); ++obj_idx) { + if (!contain_instance_totally(obj_idx, 0)) + continue; + ModelObject *mo = m_model->objects[obj_idx]; + int obj_ext = mo->config.has("extruder") ? mo->config.extruder() : 1; + if (is_mixed_slot(obj_ext)) { + warning_text = mixed_warn_msg; + return true; + } + for (ModelVolume *mv : mo->volumes) { + int vol_ext = mv->config.has("extruder") ? mv->config.extruder() : obj_ext; + if (is_mixed_slot(vol_ext)) { + warning_text = mixed_warn_msg; + return true; + } + } + } + + return false; +} + bool PartPlate::check_mixture_of_pla_and_petg(const DynamicPrintConfig &config) { bool has_pla = false; @@ -6384,6 +6485,31 @@ 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()); + + // Record mixed (virtual) filaments actually used on this plate. + // Source is ToolOrdering::used_mixed_filaments (slots that appeared in + // layer tools before resolve), persisted on GCodeProcessorResult / Print — + // not print->extruders() which only reflects assignment. + { + std::vector used_mixed; + if (auto *slice_result = m_plate_list[i]->get_slice_result()) + used_mixed = slice_result->used_mixed_filaments; + if (used_mixed.empty() && print) + used_mixed = print->get_slice_used_mixed_filaments(); + if (!used_mixed.empty() && print) { + const auto &fila_types = print->config().filament_type.values; + const auto &fila_colors = print->config().filament_colour.values; + const auto &fila_comps = print->config().filament_mixed_components.values; + for (unsigned int fid : used_mixed) { + PlateMixedFilamentInfo mixed_info; + mixed_info.id = (int) fid + 1; + if (fid < fila_types.size()) mixed_info.type = fila_types[fid]; + if (fid < fila_colors.size()) mixed_info.color = fila_colors[fid]; + if (fid < fila_comps.size()) mixed_info.components = fila_comps[fid]; + plate_data_item->mixed_filaments_info.push_back(mixed_info); + } + } + } } else { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "slice result = " << m_plate_list[i]->get_slice_result() << ", result valid = " << m_plate_list[i]->is_slice_result_valid(); @@ -6450,6 +6576,13 @@ int PartPlateList::load_from_3mf_structure(PlateDataPtrs& plate_data_list, int f m_plate_list[index]->slice_filaments_info = plate_data_list[i]->slice_filaments_info; gcode_result->warnings = plate_data_list[i]->warnings; gcode_result->filament_maps = plate_data_list[i]->filament_maps; + gcode_result->used_mixed_filaments.clear(); + for (const auto &mixed_info : plate_data_list[i]->mixed_filaments_info) { + if (mixed_info.id > 0) + gcode_result->used_mixed_filaments.push_back(static_cast(mixed_info.id - 1)); + } + if (Print *print = dynamic_cast(fff_print)) + print->set_slice_used_mixed_filaments(gcode_result->used_mixed_filaments); // Reconstruct the device-side nozzle grouping from the loaded 3mf so // the monitor/preview can map filaments to physical nozzles. diff --git a/src/slic3r/GUI/PartPlate.hpp b/src/slic3r/GUI/PartPlate.hpp index 47481dcad4..5760320b49 100644 --- a/src/slic3r/GUI/PartPlate.hpp +++ b/src/slic3r/GUI/PartPlate.hpp @@ -354,6 +354,9 @@ public: bool check_filament_printable(const DynamicPrintConfig & config, wxString& error_message); bool check_tpu_printable_status(const DynamicPrintConfig & config, const std::vector &tpu_filaments); bool check_mixture_of_pla_and_petg(const DynamicPrintConfig & config); + // Warns when a mixed-color filament is used on a single-nozzle printer, where every + // component switch costs a full filament change and purge. + bool check_single_extruder_mixed_filament_risk(const DynamicPrintConfig &config, std::string &warning_text) const; bool check_mixture_filament_compatible(const DynamicPrintConfig& config, std::string &error_msg); bool check_compatible_of_nozzle_and_filament(const DynamicPrintConfig & config, const std::vector& filament_presets, std::string& error_msg); diff --git a/src/slic3r/GUI/PlateSettingsDialog.cpp b/src/slic3r/GUI/PlateSettingsDialog.cpp index ea24d646c3..07c955ef01 100644 --- a/src/slic3r/GUI/PlateSettingsDialog.cpp +++ b/src/slic3r/GUI/PlateSettingsDialog.cpp @@ -472,6 +472,31 @@ PlateSettingsDialog::PlateSettingsDialog(wxWindow* parent, const wxString& title m_sizer_main->AddSpacer(FromDIP(5)); m_sizer_main->Add(m_other_layers_seq_panel, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(30)); + // A mixed-color slot resolves to a different physical filament per layer, so a user-defined + // filament order cannot be honoured; grey out the choice and explain that in the dialog. + { + auto &proj_cfg = wxGetApp().preset_bundle->project_config; + auto *is_mixed_opt = proj_cfg.option("filament_is_mixed"); + if (is_mixed_opt && Slic3r::has_any_mixed_filament(is_mixed_opt->values)) { + m_first_layer_print_seq_choice->Enable(false); + m_other_layers_seq_panel->enable_seq_choice(false); + + auto *warn_sizer = new wxBoxSizer(wxHORIZONTAL); + auto *warn_icon = new wxStaticBitmap(this, wxID_ANY, create_scaled_bitmap("warning", this, 16), + wxDefaultPosition, wxSize(FromDIP(16), FromDIP(16))); + auto *warn_text = new wxStaticText(this, wxID_ANY, + _L("The filament list contains mixed filaments. Custom filament sequence will not take effect.")); + warn_text->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#FF6F00"))); + warn_text->SetFont(Label::Body_12); + warn_text->Wrap(FromDIP(300)); + + warn_sizer->Add(warn_icon, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(5)); + warn_sizer->Add(warn_text, 1, wxALIGN_CENTER_VERTICAL, 0); + m_sizer_main->AddSpacer(FromDIP(5)); + m_sizer_main->Add(warn_sizer, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(30)); + } + } + auto dlg_btns = new DialogButtons(this, {"OK", "Cancel"}); dlg_btns->GetOK()->Bind(wxEVT_BUTTON, [this](auto& e) { diff --git a/src/slic3r/GUI/PlateSettingsDialog.hpp b/src/slic3r/GUI/PlateSettingsDialog.hpp index 1e61b0a708..b94739348f 100644 --- a/src/slic3r/GUI/PlateSettingsDialog.hpp +++ b/src/slic3r/GUI/PlateSettingsDialog.hpp @@ -62,6 +62,9 @@ public: int get_layers_print_seq_choice() { return m_other_layer_print_seq_choice->GetSelection(); }; std::vector get_layers_print_seq_infos() { return m_layer_seq_infos; } + // Lets callers grey out the sequence choice (e.g. when a mixed filament makes a + // user-defined filament order impossible). + void enable_seq_choice(bool enable) { m_other_layer_print_seq_choice->Enable(enable); } protected: void append_layer(const LayerSeqInfo* layer_info = nullptr); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index c3b2e1e786..d40572fe2b 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -166,6 +166,12 @@ #include // Needs to be last because reasons :-/ #include #include "WipeTowerDialog.hpp" +#include "MixedFilamentDialog.hpp" +#include "TextureImportDialog.hpp" +#include "libslic3r/TexturePainting.hpp" +#include "ColorDecomposeSupport.hpp" +#include "FilamentBitmapUtils.hpp" +#include "libslic3r/FilamentMixer.hpp" #include "ObjColorDialog.hpp" #include "libslic3r/CustomGCode.hpp" @@ -202,6 +208,20 @@ static const std::pair THUMBNAIL_SIZE_3MF = { 512, 5 namespace Slic3r { namespace GUI { +// A textured mesh is only worth routing through the import dialog when it actually carries +// decoded image data; UV-only meshes have nothing to sample. +static bool has_importable_texture(const Slic3r::TexturedMesh& textured_mesh) +{ + if (textured_mesh.vertices.empty() || textured_mesh.indices.empty()) + return false; + + if (!textured_mesh.precomputed_face_colors.empty()) + return true; + + return std::any_of(textured_mesh.textures.begin(), textured_mesh.textures.end(), + [](const Slic3r::TextureImage& texture) { return !texture.data.empty(); }); +} + wxDEFINE_EVENT(EVT_SCHEDULE_BACKGROUND_PROCESS, SimpleEvent); wxDEFINE_EVENT(EVT_SLICING_UPDATE, SlicingStatusEvent); wxDEFINE_EVENT(EVT_SLICING_COMPLETED, wxCommandEvent); @@ -717,7 +737,26 @@ struct Sidebar::priv ScalableButton * m_bpButton_ams_filament; ScalableButton * m_bpButton_set_filament; int m_menu_filament_id = -1; + + wxPanel* m_filament_area_wrapper; + wxScrolledWindow* m_panel_filament_content; + + // Mixed-color filament section. Sits directly under the physical filament list in + // scrolled_sizer. BBS hosts the equivalent widgets inside an m_filament_area_wrapper + // that Orca's sidebar has no counterpart for, so these are parented to p->scrolled. + wxPanel* m_btn_add_mixed_filament{nullptr}; // "+ Add Mixed Filament" full-width button + wxPanel* m_panel_mixed_title{nullptr}; // title row: "Mixed Filament" + add/del buttons + wxStaticText* m_text_mixed_title{nullptr}; + ScalableButton* m_btn_mixed_add{nullptr}; + ScalableButton* m_btn_mixed_del{nullptr}; + wxScrolledWindow* m_mixed_scroll_area{nullptr}; // independent scrollbar for mixed rows + wxPanel* m_panel_mixed_content{nullptr}; + wxBoxSizer* m_sizer_mixed_filaments{nullptr}; // two-column, mirrors sizer_filaments + wxPanel* m_panel_mixed_warning{nullptr}; // red bar for broken/mismatched mixes + wxStaticText* m_text_mixed_warning{nullptr}; + bool m_mixed_filament_broken{false}; + wxScrolledWindow* m_scrolledWindow_filament_content; wxStaticLine* m_staticline2; wxPanel* m_panel_project_title; @@ -1060,23 +1099,38 @@ std::vector get_min_flush_volumes(const DynamicPrintConfig &full_config, si struct DynamicFilamentList : DynamicList { + // Orca: support and wipe-tower keys are consumed by the engine without per-layer mixed + // resolution (see ConfigManipulation::update_print_fff_config), so their dropdowns list + // physical slots only; the per-feature *_filament_id keys keep every slot. BBS uses one + // physical-only list for all of its keys. + explicit DynamicFilamentList(bool physical_only = false) : physical_only(physical_only) {} + bool physical_only; std::vector> items; + std::vector slot_map{0}; // combo index -> 1-based filament slot; slot_map[0] = 0 is "Default" void apply_on(Choice *c) override { + if (!c) + return; if (items.empty()) update(true); auto cb = dynamic_cast(c->window); + if (!cb) + return; wxString old_selection = cb->GetStringSelection(); int old_index = cb->GetSelection(); + // slot_map is already rebuilt here: restoring through it keeps the index of every slot + // still listed and sends a vanished slot to the fallback below. + int old_slot = old_index >= 0 && old_index < int(slot_map.size()) ? slot_map[old_index] : -1; cb->Clear(); cb->Append(_L("Default")); for (auto i : items) { cb->Append(i.first, i.second ? *i.second : wxNullBitmap); } - if (old_index >= 0 && (unsigned int) old_index < cb->GetCount()) { - cb->SetSelection(old_index); + int restored = index_of(wxString::Format("%d", old_slot)); + if (restored > 0 || old_slot == 0) { + cb->SetSelection(restored); return; } @@ -1092,27 +1146,36 @@ struct DynamicFilamentList : DynamicList wxString get_value(int index) override { wxString str; - str << index; + str << (index >= 0 && index < int(slot_map.size()) ? slot_map[index] : 0); return str; } int index_of(wxString value) override { long n = 0; - return (value.ToLong(&n) && n <= items.size()) ? int(n) : -1; + if (!value.ToLong(&n)) + return -1; + for (int i = 0; i < int(slot_map.size()); ++i) + if (slot_map[i] == int(n)) + return i; + return 0; } void update(bool force = false) { items.clear(); + slot_map.assign(1, 0); if (!force && m_choices.empty()) return; auto icons = get_extruder_color_icons(true); auto presets = wxGetApp().preset_bundle->filament_presets; for (int i = 0; i < presets.size(); ++i) { + if (physical_only && wxGetApp().preset_bundle->is_mixed_filament(i)) + continue; wxString str; std::string type; wxGetApp().preset_bundle->filaments.find_preset(presets[i])->get_filament_type(type); str << type; items.push_back({str, i < icons.size() ? icons[i] : nullptr}); + slot_map.push_back(i + 1); } DynamicList::update(); } @@ -1133,7 +1196,8 @@ static bool has_junction_deviation(const DynamicPrintConfig* printer_config) junction_dev->values.front() > 0.0; } -static DynamicFilamentList dynamic_filament_list; +static DynamicFilamentList dynamic_filament_list; // every slot, mixed included (per-feature *_filament_id keys) +static DynamicFilamentList dynamic_physical_filament_list(true); // physical slots only (support_*, wipe_tower_filament) class AMSCountPopupWindow : public PopupWindow { @@ -1403,12 +1467,12 @@ void ExtruderGroup::update_ams() size_t left = 4; size_t index = 0; for (size_t i = i4; i < ams_n4 && left > 0; ++i, ++index, left -= 2) { - ams[index]->Update(i < ams_4.size() ? ams_4[i] : info4); + ams[index]->UpdateInfo(i < ams_4.size() ? ams_4[i] : info4); ams[index]->Refresh(); ams[index]->Open(); } for (size_t i = i1; i < ams_n1 && left > 0; ++i, ++index, --left) { - ams[index]->Update(i < ams_1.size() ? ams_1[i] : info1); + ams[index]->UpdateInfo(i < ams_1.size() ? ams_1[i] : info1); ams[index]->Refresh(); ams[index]->Open(); } @@ -2355,15 +2419,15 @@ void Sidebar::update_sync_ams_btn_enable(wxUpdateUIEvent &e) Sidebar::Sidebar(Plater *parent) : wxPanel(parent, wxID_ANY, wxDefaultPosition, wxSize(39 * wxGetApp().em_unit(), -1)), p(new priv(parent)) { - Choice::register_dynamic_list("support_filament", &dynamic_filament_list); - Choice::register_dynamic_list("support_interface_filament", &dynamic_filament_list); + Choice::register_dynamic_list("support_filament", &dynamic_physical_filament_list); + Choice::register_dynamic_list("support_interface_filament", &dynamic_physical_filament_list); Choice::register_dynamic_list("outer_wall_filament_id", &dynamic_filament_list); Choice::register_dynamic_list("inner_wall_filament_id", &dynamic_filament_list); Choice::register_dynamic_list("sparse_infill_filament_id", &dynamic_filament_list); Choice::register_dynamic_list("internal_solid_filament_id", &dynamic_filament_list); Choice::register_dynamic_list("top_surface_filament_id", &dynamic_filament_list); Choice::register_dynamic_list("bottom_surface_filament_id", &dynamic_filament_list); - Choice::register_dynamic_list("wipe_tower_filament", &dynamic_filament_list); + Choice::register_dynamic_list("wipe_tower_filament", &dynamic_physical_filament_list); p->scrolled = new wxPanel(this); // p->scrolled->SetScrollbars(0, 100, 1, 2); // ys_DELETE_after_testing. pixelsPerUnitY = 100 @@ -2835,7 +2899,7 @@ Sidebar::Sidebar(Plater *parent) p->m_panel_filament_title->SetBackgroundColor(title_bg); p->m_panel_filament_title->SetBackgroundColor2(0xF1F1F1); p->m_panel_filament_title->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent &e) { - if (!p || !p->m_panel_filament_content || !m_scrolled_sizer || !p->m_bpButton_set_filament || !p->m_purge_mode_btn || !p->m_flushing_volume_btn || !p->m_bpButton_add_filament || !ams_btn) + if (!p || !p->m_filament_area_wrapper || !m_scrolled_sizer || !p->m_bpButton_set_filament || !p->m_purge_mode_btn || !p->m_flushing_volume_btn || !p->m_bpButton_add_filament || !ams_btn) return; // ORCA exclude area of del button from titlebar collapse/expand feature to fix undesired collapse when user spams del filament button // also block fold/unfold feature when user clicks to spacing between icons @@ -2846,8 +2910,8 @@ Sidebar::Sidebar(Plater *parent) else if (ams_btn->IsShown()) exclude_pt = ams_btn->GetPosition().x; if (e.GetPosition().x > exclude_pt) return; - bool isShown = p->m_panel_filament_content->IsShown(); - p->m_panel_filament_content->Show(!isShown); + bool isShown = p->m_filament_area_wrapper->IsShown(); + p->m_filament_area_wrapper->Show(!isShown); p->m_panel_filament_separator->Show(isShown); m_scrolled_sizer->Layout(); @@ -2961,8 +3025,13 @@ Sidebar::Sidebar(Plater *parent) bSizer39->Add(set_btn, 0, wxALIGN_CENTER | wxLEFT, FromDIP(SidebarProps::WideSpacing())); bSizer39->AddSpacer(FromDIP(SidebarProps::TitlebarMargin())); + // ---- Wrapper panel for collapse/expand of all filament content ---- + p->m_filament_area_wrapper = new wxPanel(p->scrolled, wxID_ANY); + p->m_filament_area_wrapper->SetBackgroundColour(*wxWHITE); + auto* wrapper_sizer = new wxBoxSizer(wxVERTICAL); + // add filament content - p->m_panel_filament_content = new wxScrolledWindow( p->scrolled, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); + p->m_panel_filament_content = new wxScrolledWindow(p->m_filament_area_wrapper, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL ); p->m_panel_filament_content->SetScrollbars(0, 100, 1, 2); p->m_panel_filament_content->SetScrollRate(0, 5); //p->m_panel_filament_content->SetMaxSize(wxSize{-1, FromDIP(174)}); @@ -2990,7 +3059,129 @@ Sidebar::Sidebar(Plater *parent) update_filaments_area_height(); // ORCA - scrolled_sizer->Add(p->m_panel_filament_content, 0, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(SidebarProps::ContentMarginV())); // ORCA use vertical margin on parent otherwise it shows scrollbar even on 1 filament + wrapper_sizer->Add(p->m_panel_filament_content, 0, wxEXPAND); + + // ---- Mixed-color filament section ---- + // A mixed filament is a virtual slot realized from 2-3 physical filaments at slicing time. + // Everything here stays hidden until at least two physical filaments exist, so a single + // filament setup looks exactly as before. + { + // 1) "+ Add Mixed Filament" button, shown only while no mixed filament exists yet. + p->m_btn_add_mixed_filament = new wxPanel(p->m_filament_area_wrapper, wxID_ANY); + p->m_btn_add_mixed_filament->SetBackgroundColour(StateColor::darkModeColorFor(wxColour("#F8F8F8"))); + p->m_btn_add_mixed_filament->SetMinSize(wxSize(-1, FromDIP(23))); + { + auto* btn_sizer = new wxBoxSizer(wxHORIZONTAL); + auto* icon_add = new ScalableButton(p->m_btn_add_mixed_filament, wxID_ANY, "add_filament", wxEmptyString, + wxDefaultSize, wxDefaultPosition, wxBU_EXACTFIT | wxNO_BORDER, false, 16); + auto* add_label = new wxStaticText(p->m_btn_add_mixed_filament, wxID_ANY, _L("Add Mixed Filament"), + wxDefaultPosition, wxDefaultSize, 0); + add_label->SetFont(::Label::Body_13); + btn_sizer->AddStretchSpacer(); + btn_sizer->Add(icon_add, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); + btn_sizer->Add(add_label, 0, wxALIGN_CENTER_VERTICAL); + btn_sizer->AddStretchSpacer(); + p->m_btn_add_mixed_filament->SetSizer(btn_sizer); + p->m_btn_add_mixed_filament->SetCursor(wxCursor(wxCURSOR_HAND)); + // Whole panel is the hit target, so forward clicks from the children too. + auto on_click = [this](wxMouseEvent&) { add_mixed_filament(); }; + p->m_btn_add_mixed_filament->Bind(wxEVT_LEFT_UP, on_click); + add_label->Bind(wxEVT_LEFT_UP, on_click); + icon_add->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { add_mixed_filament(); }); + } + wrapper_sizer->Add(p->m_btn_add_mixed_filament, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, FromDIP(8)); + + // 2) Title row with add / remove buttons, shown once a mixed filament exists. + p->m_panel_mixed_title = new wxPanel(p->m_filament_area_wrapper, wxID_ANY); + p->m_panel_mixed_title->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + { + auto* title_sizer = new wxBoxSizer(wxHORIZONTAL); + p->m_text_mixed_title = new wxStaticText(p->m_panel_mixed_title, wxID_ANY, _L("Mixed Filament")); + p->m_text_mixed_title->SetFont(::Label::Head_14); + title_sizer->Add(p->m_text_mixed_title, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(SidebarProps::TitlebarMargin())); + title_sizer->AddStretchSpacer(); + + p->m_btn_mixed_del = new ScalableButton(p->m_panel_mixed_title, wxID_ANY, "delete_filament"); + p->m_btn_mixed_del->SetToolTip(_L("Remove last mixed filament")); + p->m_btn_mixed_del->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { + auto* plater_ptr = dynamic_cast(GetParent()); + if (!plater_ptr) return; + auto mixed_indices = plater_ptr->mixed_filament_config_indices(); + if (!mixed_indices.empty()) + delete_mixed_filament_at(mixed_indices.size() - 1); + }); + title_sizer->Add(p->m_btn_mixed_del, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(SidebarProps::IconSpacing())); + + p->m_btn_mixed_add = new ScalableButton(p->m_panel_mixed_title, wxID_ANY, "add_filament"); + p->m_btn_mixed_add->SetToolTip(_L("Add mixed filament")); + p->m_btn_mixed_add->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { add_mixed_filament(); }); + title_sizer->Add(p->m_btn_mixed_add, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(SidebarProps::IconSpacing())); + title_sizer->AddSpacer(FromDIP(SidebarProps::TitlebarMargin())); + + p->m_panel_mixed_title->SetSizer(title_sizer); + } + wrapper_sizer->Add(p->m_panel_mixed_title, 0, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(8)); + + // 3) Mixed filament rows, in their own scroll area so a long mixed list does not + // push the physical filament list off screen. + p->m_mixed_scroll_area = new wxScrolledWindow(p->m_filament_area_wrapper, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL); + p->m_mixed_scroll_area->SetScrollbars(0, 100, 1, 2); + p->m_mixed_scroll_area->SetScrollRate(0, 5); + p->m_mixed_scroll_area->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + { + auto* mix_scroll_sizer = new wxBoxSizer(wxVERTICAL); + p->m_panel_mixed_content = new wxPanel(p->m_mixed_scroll_area, wxID_ANY); + p->m_panel_mixed_content->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + + // Two columns, same idiom as sizer_filaments. + p->m_sizer_mixed_filaments = new wxBoxSizer(wxHORIZONTAL); + p->m_sizer_mixed_filaments->Add(new wxBoxSizer(wxVERTICAL), 1, wxEXPAND); + p->m_sizer_mixed_filaments->Add(new wxBoxSizer(wxVERTICAL), 1, wxEXPAND); + + auto* sizer_mixed2 = new wxBoxSizer(wxVERTICAL); + sizer_mixed2->Add(p->m_sizer_mixed_filaments, 0, wxEXPAND, 0); + p->m_panel_mixed_content->SetSizer(sizer_mixed2); + mix_scroll_sizer->Add(p->m_panel_mixed_content, 0, wxEXPAND | wxBOTTOM, FromDIP(8)); + p->m_mixed_scroll_area->SetSizer(mix_scroll_sizer); + } + p->m_mixed_scroll_area->EnableScrolling(false, true); + p->m_mixed_scroll_area->ShowScrollbars(wxSHOW_SB_NEVER, wxSHOW_SB_DEFAULT); + p->m_mixed_scroll_area->Bind(wxEVT_SIZE, [this](wxSizeEvent& e) { + int w = p->m_mixed_scroll_area->GetClientSize().GetWidth(); + if (w > 0) + p->m_mixed_scroll_area->SetVirtualSize(w, p->m_mixed_scroll_area->GetVirtualSize().GetHeight()); + e.Skip(); + }); + wrapper_sizer->Add(p->m_mixed_scroll_area, 0, wxEXPAND, 0); + + // 4) Warning bar for mixes whose components were deleted or whose types disagree. + p->m_panel_mixed_warning = new wxPanel(p->m_filament_area_wrapper, wxID_ANY); + p->m_panel_mixed_warning->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + { + auto* warn_sizer = new wxBoxSizer(wxHORIZONTAL); + p->m_text_mixed_warning = new wxStaticText(p->m_panel_mixed_warning, wxID_ANY, + _L("Mixed filament has invalid or mismatched components. Please re-edit affected entries.")); + p->m_text_mixed_warning->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#D01B1B"))); + p->m_text_mixed_warning->SetFont(::Label::Body_12); + p->m_text_mixed_warning->Wrap(FromDIP(360)); + warn_sizer->Add(p->m_text_mixed_warning, 1, wxALL, FromDIP(6)); + p->m_panel_mixed_warning->SetSizer(warn_sizer); + } + wrapper_sizer->Add(p->m_panel_mixed_warning, 0, wxEXPAND | wxLEFT | wxRIGHT, FromDIP(8)); + + // Hidden until update_mixed_filament_list() decides otherwise. + p->m_btn_add_mixed_filament->Hide(); + p->m_panel_mixed_title->Hide(); + p->m_mixed_scroll_area->Hide(); + p->m_panel_mixed_content->Hide(); + p->m_panel_mixed_warning->Hide(); + } + // ---- End mixed-color filament section ---- + + p->m_filament_area_wrapper->SetSizer(wrapper_sizer); + p->m_filament_area_wrapper->Layout(); + scrolled_sizer->Add(p->m_filament_area_wrapper, 0, wxEXPAND | wxTOP | wxBOTTOM, FromDIP(SidebarProps::ContentMarginV())); // ORCA use vertical margin on parent otherwise it shows scrollbar even on 1 filament + // ---- End filament area ---- } { @@ -3679,7 +3870,7 @@ bool Sidebar::reset_bed_type_combox_choices(bool is_sidebar_init) } } m_last_combo_bedtype_count = p->combo_printer_bed->GetCount(); - if (!is_sidebar_init && &p->plater->get_partplate_list()) { + if (!is_sidebar_init) { p->plater->get_partplate_list().check_all_plate_local_bed_type(m_cur_combox_bed_types); } return true; @@ -3696,6 +3887,1131 @@ void Sidebar::change_top_border_for_mode_sizer(bool increase_border) #endif } + +// ---- Mixed-color filament sidebar support ---- +// The mixed rows get their own scroll area, capped by Orca's filaments_area_preferred_count +// row budget rather than BBS's fixed 3-row / 12-filament limit. +void Sidebar::recalc_filament_scroll_sizes() +{ + if (!p->m_mixed_scroll_area || !p->m_mixed_scroll_area->GetSizer()) + return; + + // Same preferred-row budget the physical list uses, so both lists cap consistently. + auto left_sizer = p->sizer_filaments->GetItem((size_t) 0)->GetSizer(); + auto combo_sizer = left_sizer->GetItem((size_t) 0)->GetSizer(); + const int row_h = combo_sizer ? combo_sizer->GetSize().GetHeight() : 0; + int preferred_rows = std::ceil(0.5 * std::stoi(wxGetApp().app_config->get("filaments_area_preferred_count"))); + const int max_h = (row_h > 0) ? preferred_rows * row_h : -1; + + auto content_size = p->m_mixed_scroll_area->GetSizer()->GetMinSize(); + if (max_h > 0 && content_size.y > max_h) { + p->m_mixed_scroll_area->SetMaxSize({-1, max_h}); + content_size.y = max_h; + } else { + p->m_mixed_scroll_area->SetMaxSize({-1, -1}); + } + p->m_mixed_scroll_area->SetMinSize({0, content_size.y}); +} +static std::string blend_mixed_color(const std::vector &comp_ids, + const std::vector &ratios, + const std::vector &color_strs) +{ + std::vector hex_colors; + hex_colors.reserve(comp_ids.size()); + for (unsigned int id : comp_ids) + hex_colors.push_back((id >= 1 && id <= color_strs.size()) ? color_strs[id - 1] : "#808080"); + return Slic3r::blend_color_multi(hex_colors, ratios); +} + +void Sidebar::update_mixed_filament_list() +{ + auto* plater = dynamic_cast(GetParent()); + if (!plater) return; + + wxWindowUpdateLocker noUpdates(this); + + const wxColour mc_bg = StateColor::darkModeColorFor(*wxWHITE); + const wxColour mc_border = StateColor::darkModeColorFor(wxColour("#CECECE")); + const wxColour mc_text = StateColor::darkModeColorFor(wxColour("#262E30")); + const wxColour mc_dim = StateColor::darkModeColorFor(wxColour("#ACACAC")); + + auto& project_config = wxGetApp().preset_bundle->project_config; + auto mixed_indices = plater->mixed_filament_config_indices(); + size_t num_physical = p->combos_filament.size(); + + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + auto* components_opt = project_config.option("filament_mixed_components"); + auto* ratios_opt = project_config.option("filament_mixed_sublayer_ratios"); + auto* colours_opt = project_config.option("filament_colour"); + auto* grad_opt = project_config.option("filament_mixed_gradient"); + auto* grad_range_opt = project_config.option("filament_mixed_gradient_range"); + + bool can_mix = (num_physical >= 2); + bool has_mixed = can_mix && !mixed_indices.empty(); + + // Check integrity of mixed filament component references + std::vector broken_slots; + if (is_mixed_opt && components_opt) + broken_slots = check_mixed_filament_integrity(is_mixed_opt->values, components_opt->values, num_physical); + std::set broken_set(broken_slots.begin(), broken_slots.end()); + + // Type consistency check + if (is_mixed_opt && components_opt) { + std::vector physical_types; + auto& preset_bundle = *wxGetApp().preset_bundle; + for (size_t i = 0; i < num_physical; ++i) { + std::string ft; + if (i < preset_bundle.filament_presets.size()) { + auto* preset = preset_bundle.filaments.find_preset(preset_bundle.filament_presets[i]); + if (preset) { + std::string display_type; + ft = preset->config.get_filament_type(display_type); + } + } + if (ft.empty()) ft = "PLA"; + physical_types.push_back(ft); + } + auto type_mismatch_slots = check_mixed_filament_type_consistency( + is_mixed_opt->values, components_opt->values, physical_types); + for (size_t s : type_mismatch_slots) + broken_set.insert(s); + broken_slots.insert(broken_slots.end(), type_mismatch_slots.begin(), type_mismatch_slots.end()); + } + + bool at_limit = (wxGetApp().preset_bundle->filament_presets.size() >= size_t(EnforcerBlockerType::ExtruderMax)); + p->m_btn_add_mixed_filament->Show(can_mix && !has_mixed && !at_limit); + p->m_panel_mixed_title->Show(has_mixed); + p->m_mixed_scroll_area->Show(has_mixed); + p->m_panel_mixed_content->Show(has_mixed); + if (p->m_btn_mixed_add) + p->m_btn_mixed_add->Enable(!at_limit); + p->m_panel_mixed_warning->Show(false); + + // Show/dismiss 3D canvas notification for broken mixed filaments + if (has_mixed && !broken_set.empty()) { + auto* notify = wxGetApp().plater()->get_notification_manager(); + if (notify) + notify->push_notification(NotificationType::BBLMixedFilamentBroken, + NotificationManager::NotificationLevel::ErrorNotificationLevel, + _u8L("Mixed filament has invalid or mismatched components. Please re-edit affected entries.")); + } else { + auto* notify = wxGetApp().plater()->get_notification_manager(); + if (notify) + notify->close_notification_of_type(NotificationType::BBLMixedFilamentBroken); + } + + if (has_mixed) { + auto* left_col = p->m_sizer_mixed_filaments->GetItem(size_t(0))->GetSizer(); + auto* right_col = p->m_sizer_mixed_filaments->GetItem(size_t(1))->GetSizer(); + left_col->Clear(true); + right_col->Clear(true); + + std::vector physical_colors; + if (colours_opt) { + for (size_t i = 0; i < num_physical && i < colours_opt->values.size(); ++i) + physical_colors.push_back(colours_opt->values[i]); + } + + auto make_swatch_panel = [this](wxWindow* parent, const wxColour& col, unsigned int num) -> wxPanel* { + int swatch_sz = FromDIP(20); + auto* panel = new wxPanel(parent, wxID_ANY, wxDefaultPosition, wxSize(swatch_sz, swatch_sz)); + panel->SetMinSize(wxSize(swatch_sz, swatch_sz)); + bool is_dark = wxGetApp().dark_mode(); + panel->Bind(wxEVT_PAINT, [panel, col, num, is_dark](wxPaintEvent&) { + wxPaintDC dc(panel); + wxSize sz = panel->GetClientSize(); + dc.SetBackground(wxBrush(col)); + dc.Clear(); + if (!is_dark && col.Red() > 224 && col.Green() > 224 && col.Blue() > 224) { + dc.SetPen(wxPen(wxColour(130, 130, 128), 1)); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + } + if (is_dark && col.Red() < 45 && col.Green() < 45 && col.Blue() < 45) { + dc.SetPen(wxPen(wxColour(207, 207, 207), 1)); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + } + wxString txt = wxString::Format("%u", num); + dc.SetFont(::Label::Body_14); + wxSize txt_sz = dc.GetTextExtent(txt); + dc.SetTextForeground(col.GetLuminance() > 0.5 ? wxColour(50, 58, 61) : *wxWHITE); + dc.DrawText(txt, (sz.GetWidth() - txt_sz.GetWidth()) / 2, + (sz.GetHeight() - txt_sz.GetHeight()) / 2); + }); + return panel; + }; + + for (size_t i = 0; i < mixed_indices.size(); ++i) { + size_t cfg_idx = mixed_indices[i]; + auto* combo_and_btn_sizer = new wxBoxSizer(wxHORIZONTAL); + + combo_and_btn_sizer->Add(FromDIP(10), 0, 0, 0, 0); + + // Parse components and ratios from config strings (supports 2-N components) + std::vector comp_ids; + std::vector comp_ratios; + if (components_opt && cfg_idx < components_opt->values.size()) { + std::istringstream iss(components_opt->values[cfg_idx]); + std::string tok; + while (std::getline(iss, tok, ',')) { + unsigned int v = 0; + if (std::sscanf(tok.c_str(), "%u", &v) == 1) + comp_ids.push_back(v); + } + } + if (ratios_opt && cfg_idx < ratios_opt->values.size()) { + CNumericLocalesSetter c_locale_setter; + std::istringstream iss(ratios_opt->values[cfg_idx]); + std::string tok; + while (std::getline(iss, tok, ',')) { + float v = 0; + if (std::sscanf(tok.c_str(), "%f", &v) == 1) + comp_ratios.push_back((int)(v * 100 + 0.5f)); + } + } + if (!comp_ids.empty() && comp_ratios.size() != comp_ids.size()) { + BOOST_LOG_TRIVIAL(warning) << "Mixed filament slot " << cfg_idx + << ": ratio count (" << comp_ratios.size() + << ") != component count (" << comp_ids.size() + << "), resetting to even distribution"; + int n = (int)comp_ids.size(); + comp_ratios.assign(n, 100 / n); + comp_ratios[0] += 100 - (100 / n) * n; + } + + bool is_broken = broken_set.count(cfg_idx) > 0; + + // Recalculate mixed color based on current physical colors + if (!is_broken && !comp_ids.empty() && comp_ids.size() == comp_ratios.size()) { + std::string new_mixed_color = blend_mixed_color(comp_ids, comp_ratios, physical_colors); + + if (colours_opt && cfg_idx < colours_opt->values.size() && colours_opt->values[cfg_idx] != new_mixed_color) { + colours_opt->values[cfg_idx] = new_mixed_color; + + auto* multi_colour_opt = project_config.option("filament_multi_colour"); + if (multi_colour_opt && cfg_idx < multi_colour_opt->values.size()) { + multi_colour_opt->values[cfg_idx] = new_mixed_color; + } + } + } + + bool is_gradient = false; + int gradient_direction = 0; + if (grad_opt && cfg_idx < grad_opt->values.size()) + is_gradient = grad_opt->values[cfg_idx]; + if (is_gradient && grad_range_opt && cfg_idx < grad_range_opt->values.size()) { + CNumericLocalesSetter c_locale_setter; + float v0 = 0, v1 = 0; + if (std::sscanf(grad_range_opt->values[cfg_idx].c_str(), "%f,%f", &v0, &v1) == 2) + gradient_direction = (v0 > v1) ? 0 : 1; + } + + std::string mix_color_str = (colours_opt && cfg_idx < colours_opt->values.size()) + ? colours_opt->values[cfg_idx] : "#888888"; + wxColour mix_col(mix_color_str); + unsigned int mix_num = (unsigned int)(cfg_idx + 1); + + // The swatch fades bottom to top over the model's height, sampled the same way + // the slicer builds the sublayers, so it matches the editor's Effect Preview. The + // ramp comes back empty for every slot that is not a two component gradient mix. + const int swatch_sz = FromDIP(20); + const std::vector gradient_ramp = mixed_gradient_ramp(project_config, cfg_idx, swatch_sz); + + if (!gradient_ramp.empty()) { + auto* grad_panel = new wxPanel(p->m_panel_mixed_content, wxID_ANY, + wxDefaultPosition, wxSize(swatch_sz, swatch_sz)); + grad_panel->SetMinSize(wxSize(swatch_sz, swatch_sz)); + grad_panel->SetBackgroundStyle(wxBG_STYLE_PAINT); + grad_panel->Bind(wxEVT_PAINT, [grad_panel, gradient_ramp, mix_num](wxPaintEvent&) { + wxBufferedPaintDC dc(grad_panel); + wxSize sz = grad_panel->GetClientSize(); + fill_gradient_ramp_rect(dc, wxRect(0, 0, sz.GetWidth(), sz.GetHeight()), gradient_ramp); + wxString txt = wxString::Format("%u", mix_num); + dc.SetFont(::Label::Body_14); + wxSize txt_sz = dc.GetTextExtent(txt); + // The number sits at the swatch's middle, so take its contrast from the + // colour printed at mid height rather than from either endpoint. + dc.SetTextForeground(gradient_ramp[gradient_ramp.size() / 2].GetLuminance() > 0.5 ? wxColour(50, 58, 61) : *wxWHITE); + dc.DrawText(txt, (sz.GetWidth() - txt_sz.GetWidth()) / 2, + (sz.GetHeight() - txt_sz.GetHeight()) / 2); + }); + combo_and_btn_sizer->Add(grad_panel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); + } else { + combo_and_btn_sizer->Add(make_swatch_panel(p->m_panel_mixed_content, mix_col, mix_num), + 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); + } + + auto* content_panel = new wxPanel(p->m_panel_mixed_content, wxID_ANY); + content_panel->SetBackgroundColour(mc_bg); + content_panel->SetBackgroundStyle(wxBG_STYLE_PAINT); + + // Pre-compute all values the paint lambda needs (avoid capturing `this` for FromDIP) + int cp_pad = FromDIP(4); + int cp_swatch_sz = FromDIP(20); + int cp_sep_margin = FromDIP(3); + int cp_pct_left = FromDIP(2); + int cp_gap = FromDIP(2); + int cp_pct_gap = FromDIP(4); + bool cp_is_dark = wxGetApp().dark_mode(); + + // Build per-component colour list for the lambda + std::vector cp_colours; + std::vector cp_valid; + std::vector cp_ids = comp_ids; + std::vector cp_ratios = comp_ratios; + bool cp_is_gradient = is_gradient; + int cp_gradient_dir = gradient_direction; + for (size_t ci = 0; ci < comp_ids.size(); ++ci) { + bool valid = (comp_ids[ci] >= 1 && comp_ids[ci] <= physical_colors.size()); + cp_valid.push_back(valid); + cp_colours.push_back(valid ? wxColour(physical_colors[comp_ids[ci] - 1]) : wxColour("#D9D9D9")); + } + + // Reorder for gradient display: from -> to + std::vector draw_ids; + std::vector draw_ratios; + std::vector draw_colours; + std::vector draw_valid; + if (cp_is_gradient && cp_ids.size() == 2) { + int fi = (cp_gradient_dir == 0) ? 0 : 1; + int ti = 1 - fi; + draw_ids = { cp_ids[fi], cp_ids[ti] }; + draw_ratios = { cp_ratios.size() > (size_t)fi ? cp_ratios[fi] : 0, + cp_ratios.size() > (size_t)ti ? cp_ratios[ti] : 0 }; + draw_colours = { cp_colours[fi], cp_colours[ti] }; + draw_valid = { cp_valid[fi], cp_valid[ti] }; + } else { + draw_ids = cp_ids; + draw_ratios = cp_ratios; + draw_colours = cp_colours; + draw_valid = cp_valid; + } + + content_panel->Bind(wxEVT_PAINT, [content_panel, mc_bg, mc_border, mc_text, mc_dim, + cp_pad, cp_swatch_sz, cp_sep_margin, cp_pct_left, + cp_gap, cp_pct_gap, cp_is_dark, + cp_is_gradient, + draw_ids, draw_ratios, draw_colours, draw_valid](wxPaintEvent&) { + wxBufferedPaintDC dc(content_panel); + wxSize sz = content_panel->GetClientSize(); + + dc.SetBrush(wxBrush(mc_bg)); + dc.SetPen(wxPen(mc_border, 1)); + dc.DrawRectangle(0, 0, sz.GetWidth(), sz.GetHeight()); + + dc.SetFont(::Label::Body_13); + int x = cp_pad; + int y_swatch = (sz.GetHeight() - cp_swatch_sz) / 2; + int text_h = dc.GetTextExtent(wxT("A")).GetHeight(); + int y_text = y_swatch + (cp_swatch_sz - text_h) / 2; + int avail = sz.GetWidth() - cp_pad; + wxString ellipsis = wxT("..."); + int ellipsis_w = dc.GetTextExtent(ellipsis).GetWidth(); + + auto fits = [&](int needed) -> bool { + return (x + needed) <= (avail - ellipsis_w); + }; + + size_t n = draw_ids.size(); + for (size_t ci = 0; ci < n; ++ci) { + // Separator: "+" or arrow + if (ci > 0) { + wxString sep = cp_is_gradient ? wxT("\u2192") : wxT("+"); + int sep_w = dc.GetTextExtent(sep).GetWidth() + cp_sep_margin * 2; + if (!fits(sep_w + cp_swatch_sz)) { + dc.SetTextForeground(mc_text); + dc.DrawText(ellipsis, x, y_text); + break; + } + dc.SetTextForeground(mc_text); + dc.DrawText(sep, x + cp_sep_margin, y_text); + x += sep_w; + } + + // Swatch + if (!fits(cp_swatch_sz)) { + dc.SetTextForeground(mc_text); + dc.DrawText(ellipsis, x, y_text); + break; + } + + if (draw_valid[ci]) { + wxColour col = draw_colours[ci]; + dc.SetBrush(wxBrush(col)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(x, y_swatch, cp_swatch_sz, cp_swatch_sz); + if (!cp_is_dark && col.Red() > 224 && col.Green() > 224 && col.Blue() > 224) { + dc.SetPen(wxPen(wxColour(130, 130, 128), 1)); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.DrawRectangle(x, y_swatch, cp_swatch_sz, cp_swatch_sz); + } + if (cp_is_dark && col.Red() < 45 && col.Green() < 45 && col.Blue() < 45) { + dc.SetPen(wxPen(wxColour(207, 207, 207), 1)); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.DrawRectangle(x, y_swatch, cp_swatch_sz, cp_swatch_sz); + } + dc.SetFont(::Label::Body_14); + wxString num = wxString::Format("%u", draw_ids[ci]); + wxSize num_sz = dc.GetTextExtent(num); + dc.SetTextForeground(col.GetLuminance() > 0.5 ? wxColour(50, 58, 61) : *wxWHITE); + dc.DrawText(num, x + (cp_swatch_sz - num_sz.GetWidth()) / 2, + y_swatch + (cp_swatch_sz - num_sz.GetHeight()) / 2); + dc.SetFont(::Label::Body_13); + } else { + dc.SetBrush(wxBrush(mc_bg)); + dc.SetPen(wxPen(mc_dim, 1)); + dc.DrawRectangle(x, y_swatch, cp_swatch_sz, cp_swatch_sz); + wxString dash = wxT("\u2014"); + wxSize dash_sz = dc.GetTextExtent(dash); + dc.SetTextForeground(mc_dim); + dc.DrawText(dash, x + (cp_swatch_sz - dash_sz.GetWidth()) / 2, + y_swatch + (cp_swatch_sz - dash_sz.GetHeight()) / 2); + } + x += cp_swatch_sz + cp_gap; + + // Ratio text (skip for gradient) + if (!cp_is_gradient) { + int r = (ci < draw_ratios.size()) ? draw_ratios[ci] : 0; + wxString pct = wxString::Format("%d%%", r); + int pct_w = dc.GetTextExtent(pct).GetWidth(); + if (!fits(pct_w)) { + dc.SetTextForeground(mc_text); + dc.DrawText(ellipsis, x, y_text); + break; + } + dc.SetTextForeground(mc_text); + dc.DrawText(pct, x + cp_pct_left, y_text); + x += pct_w + cp_pct_gap; + } + } + }); + + // Tooltip: always show full info + { + wxString tip; + for (size_t ci = 0; ci < draw_ids.size(); ++ci) { + if (ci > 0) tip += cp_is_gradient ? wxT(" \u2192 ") : wxT(" + "); + int r = (ci < draw_ratios.size()) ? draw_ratios[ci] : 0; + tip += wxString::Format("%u (%d%%)", draw_ids[ci], r); + } + content_panel->SetToolTip(tip); + } + + // Repaint on resize so truncation updates + content_panel->Bind(wxEVT_SIZE, [content_panel](wxSizeEvent& e) { + content_panel->Refresh(); + e.Skip(); + }); + + content_panel->SetCursor(wxCursor(wxCURSOR_HAND)); + size_t panel_idx = i; + content_panel->Bind(wxEVT_LEFT_UP, [this, panel_idx](wxMouseEvent&) { edit_mixed_filament(panel_idx); }); + + combo_and_btn_sizer->Add(content_panel, 1, wxALL | wxEXPAND, FromDIP(2))->SetMinSize({-1, FromDIP(30)}); + + auto* menu_btn = new ScalableButton(p->m_panel_mixed_content, wxID_ANY, + is_broken ? "error" : "menu_filament"); + menu_btn->SetToolTip(is_broken ? _L("Mixed filament has broken component references") : _L("Edit / Delete / Merge")); + menu_btn->Bind(wxEVT_BUTTON, [this, panel_idx, cfg_idx](wxCommandEvent&) { + wxMenu menu; + + auto* edit_item = menu.Append(wxID_ANY, _L("Edit")); + menu.Bind(wxEVT_MENU, [this, panel_idx](wxCommandEvent&) { + edit_mixed_filament(panel_idx); + }, edit_item->GetId()); + + wxMenu* sub_menu = new wxMenu(); + std::vector icons = get_extruder_color_icons(true); + int filaments_cnt = icons.size(); + for (int j = 0; j < filaments_cnt; ++j) { + if ((size_t)j == cfg_idx) + continue; + + wxString item_name; + bool is_target_mixed = wxGetApp().preset_bundle->is_mixed_filament(j); + if (is_target_mixed) { + item_name = wxString::Format(_L("Filament %d"), j + 1); + } else { + auto preset = wxGetApp().preset_bundle->filaments.find_preset( + wxGetApp().preset_bundle->filament_presets[j]); + item_name = preset ? from_u8(preset->label(false)) + : wxString::Format(_L("Filament %d"), j + 1); + } + + auto* mi = new wxMenuItem(sub_menu, wxID_ANY, item_name); +#ifndef __linux__ + mi->SetBitmap(*icons[j]); +#endif + sub_menu->Append(mi); + sub_menu->Bind(wxEVT_MENU, [this, cfg_idx, j](wxCommandEvent&) { + change_filament(cfg_idx, j); + }, mi->GetId()); + } + if (filaments_cnt > 1) + menu.AppendSubMenu(sub_menu, _L("Merge with")); + else + delete sub_menu; + + menu.AppendSeparator(); // ORCA use seperator for reducing accidental clicks to delete + + // ORCA use delete item on end of menu to prevent accidental clicks. clicking to submenus(merge) already not allowed by OS + auto* del_item = menu.Append(wxID_ANY, _L("Delete")); + menu.Bind(wxEVT_MENU, [this, panel_idx](wxCommandEvent&) { + delete_mixed_filament_at(panel_idx); + }, del_item->GetId()); + + PopupMenu(&menu); + }); + combo_and_btn_sizer->Add(menu_btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(4)); + + combo_and_btn_sizer->Add(FromDIP(16), 0, 0, 0, 0); + + int side = i % 2; + auto* col = (side == 0) ? left_col : right_col; + if (side == 1 && i > 1) col->Remove(i / 2); + col->Add(combo_and_btn_sizer, 1, wxEXPAND); + if (side == 0 && i > 0) { + right_col->AddStretchSpacer(1); + } + } + } + + recalc_filament_scroll_sizes(); + + p->m_panel_filament_content->FitInside(); + p->m_mixed_scroll_area->FitInside(); + p->scrolled->Layout(); + m_scrolled_sizer->Layout(); + p->scrolled->Layout(); + + size_t total = wxGetApp().preset_bundle->filament_presets.size(); + obj_list()->update_objects_list_filament_column(total); + + // Sync mixed filament colors into the config used by 3D view rendering. + plater->update_filament_colors_in_full_config(); + obj_list()->update_filament_colors(); + + // Check if any broken mixed filament is used by objects on current plate. + // Scan raw extruder assignments (object / volume / height-range / painting) + // instead of get_extruders() which expands mixed slots and loses their IDs. + p->m_mixed_filament_broken = false; + if (!broken_slots.empty()) { + std::set broken_1based; + for (size_t s : broken_slots) broken_1based.insert(s + 1); + + auto* curr_plate = plater->get_partplate_list().get_curr_plate(); + if (curr_plate) { + for (auto& obj : plater->model().objects) { + if (!curr_plate->contain_instance_totally(obj, 0)) + continue; + // Check object-level extruder + int obj_ext = obj->config.has("extruder") ? obj->config.extruder() : 1; + if (broken_1based.count((size_t)obj_ext)) { + p->m_mixed_filament_broken = true; + break; + } + bool found = false; + for (auto* vol : obj->volumes) { + // Check volume-level extruder + int vol_ext = vol->config.has("extruder") ? vol->config.extruder() : obj_ext; + if (broken_1based.count((size_t)vol_ext)) { found = true; break; } + // Check color painting data (mmu segmentation facets) + if (vol->is_model_part() && !vol->mmu_segmentation_facets.empty()) { + for (size_t broken_slot : broken_1based) { + if (vol->mmu_segmentation_facets.has_facets(*vol, EnforcerBlockerType(broken_slot))) + { found = true; break; } + } + if (found) break; + } + } + if (found) { p->m_mixed_filament_broken = true; break; } + // Check height range modifier extruder overrides + for (auto& [range, cfg] : obj->layer_config_ranges) { + if (cfg.has("extruder")) { + int layer_ext = cfg.option("extruder")->getInt(); + if (layer_ext > 0 && broken_1based.count((size_t)layer_ext)) + { found = true; break; } + } + } + if (found) { p->m_mixed_filament_broken = true; break; } + } + } + } + + if (plater->canvas3D()) { + plater->canvas3D()->set_as_dirty(); + plater->get_view3D_canvas3D()->reload_scene(false); + } + + if (p->m_mixed_filament_broken) { + auto* mf = wxGetApp().mainframe; + if (mf) + mf->update_slice_print_status(MainFrame::eEventObjectUpdate, false); + } + + if (auto *tab = dynamic_cast(wxGetApp().plate_tab)) + tab->update_mixed_filament_seq_state(); + +} + +bool Sidebar::has_broken_mixed_filament() const +{ + auto* plater = dynamic_cast(GetParent()); + if (!plater) return false; + return has_broken_mixed_filament(plater->get_partplate_list().get_curr_plate()); +} + +bool Sidebar::has_broken_mixed_filament(const PartPlate* plate) const +{ + if (!plate) return false; + auto* plater = dynamic_cast(GetParent()); + if (!plater) return false; + + auto& project_config = wxGetApp().preset_bundle->project_config; + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + auto* comp_strs_opt = project_config.option("filament_mixed_components"); + if (!is_mixed_opt || !comp_strs_opt) return false; + + size_t num_physical = p->combos_filament.size(); + auto broken_slots = check_mixed_filament_integrity(is_mixed_opt->values, comp_strs_opt->values, num_physical); + + // Type consistency check + { + std::vector physical_types; + auto& preset_bundle = *wxGetApp().preset_bundle; + for (size_t i = 0; i < num_physical; ++i) { + std::string ft; + if (i < preset_bundle.filament_presets.size()) { + auto* preset = preset_bundle.filaments.find_preset(preset_bundle.filament_presets[i]); + if (preset) { + std::string display_type; + ft = preset->config.get_filament_type(display_type); + } + } + if (ft.empty()) ft = "PLA"; + physical_types.push_back(ft); + } + auto type_mismatch_slots = check_mixed_filament_type_consistency( + is_mixed_opt->values, comp_strs_opt->values, physical_types); + broken_slots.insert(broken_slots.end(), type_mismatch_slots.begin(), type_mismatch_slots.end()); + } + + if (broken_slots.empty()) return false; + + std::set broken_1based; + for (size_t s : broken_slots) broken_1based.insert(s + 1); + + // Scan model objects on the given plate for raw extruder assignments + // (don't use get_extruders() which expands mixed slots) + for (auto& entry : plater->model().objects) { + if (!plate->contain_instance_totally(entry, 0)) + continue; + // Check object-level extruder + int obj_ext = entry->config.has("extruder") ? entry->config.extruder() : 1; + if (broken_1based.count((size_t)obj_ext)) + return true; + for (auto* vol : entry->volumes) { + // Check volume-level extruder + int vol_ext = vol->config.has("extruder") ? vol->config.extruder() : obj_ext; + if (broken_1based.count((size_t)vol_ext)) + return true; + // Check color painting data (mmu segmentation facets) + if (vol->is_model_part() && !vol->mmu_segmentation_facets.empty()) { + for (size_t broken_slot : broken_1based) { + if (vol->mmu_segmentation_facets.has_facets(*vol, EnforcerBlockerType(broken_slot))) + return true; + } + } + } + // Check height range modifier extruder overrides + for (auto& [range, cfg] : entry->layer_config_ranges) { + if (cfg.has("extruder")) { + int layer_ext = cfg.option("extruder")->getInt(); + if (layer_ext > 0 && broken_1based.count((size_t)layer_ext)) + return true; + } + } + } + + return false; +} + +void Sidebar::collect_physical_filament_info(std::vector& color_strs, + std::vector& names, + std::vector& types, + std::vector* config_indices) +{ + color_strs.clear(); + names.clear(); + types.clear(); + if (config_indices) + config_indices->clear(); + + size_t num_physical = p->combos_filament.size(); + auto& project_config = wxGetApp().preset_bundle->project_config; + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + std::vector physical_indices; + const size_t total = wxGetApp().preset_bundle->filament_presets.size(); + physical_indices.reserve(num_physical); + for (size_t i = 0; i < total && physical_indices.size() < num_physical; ++i) { + if (!is_mixed_opt || i >= is_mixed_opt->values.size() || !is_mixed_opt->values[i]) + physical_indices.push_back(i); + } + while (physical_indices.size() < num_physical) + physical_indices.push_back(physical_indices.size()); + if (config_indices) + *config_indices = physical_indices; + + auto* colours_opt = project_config.option("filament_colour"); + if (colours_opt) { + for (size_t i = 0; i < num_physical; ++i) { + const size_t cfg_idx = physical_indices[i]; + if (cfg_idx < colours_opt->values.size()) + color_strs.push_back(colours_opt->values[cfg_idx]); + } + } + + for (size_t i = 0; i < num_physical; ++i) { + auto* combo = p->combos_filament[i]; + names.push_back(combo ? into_u8(combo->GetValue()) : "Filament " + std::to_string(i + 1)); + } + + auto& preset_bundle = *wxGetApp().preset_bundle; + for (size_t i = 0; i < num_physical; ++i) { + const size_t cfg_idx = physical_indices[i]; + Preset* preset = nullptr; + if (cfg_idx < preset_bundle.filament_presets.size()) + preset = preset_bundle.filaments.find_preset(preset_bundle.filament_presets[cfg_idx]); + std::string ft; + if (preset) { + std::string display_type; + ft = preset->config.get_filament_type(display_type); + } + if (ft.empty()) ft = "PLA"; + types.push_back(ft); + } +} + +// Serialize the dialog's custom gradient curve only when it deviates from the +// direction-implied two-point linear default. Returning an empty string keeps +// projects with the default shape bit-identical with the legacy 2-field format +// (curve string stays "" so the slicer falls back to gradient_range linear). +// Shared by add_mixed_filament / edit_mixed_filament so the "is default" rule +// stays consistent between both entry points. +static std::string serialize_mixed_gradient_curve_if_custom(const MixedFilamentResult& result) +{ + if (!(result.components.size() == 2 && !result.gradient_curve.empty())) + return {}; + + const double y0 = (result.gradient_direction == 0) ? kGradientMaxRatio : kGradientMinRatio; + const double y1 = (result.gradient_direction == 0) ? kGradientMinRatio : kGradientMaxRatio; + const double eps = 1e-4; + if (result.gradient_curve.size() == 2) { + const auto& a0 = result.gradient_curve[0]; + const auto& a1 = result.gradient_curve[1]; + // Default curve also requires no tangent overrides; any finite tangent + // means the user bent the segment, so we must serialize it. + const bool is_default = + std::abs(a0.x - 0.0) < eps + && std::abs(a1.x - 1.0) < eps + && std::abs(a0.y - y0) < eps + && std::abs(a1.y - y1) < eps + && !std::isfinite(a0.m_in) && !std::isfinite(a0.m_out) + && !std::isfinite(a1.m_in) && !std::isfinite(a1.m_out); + if (is_default) return {}; + } + + Slic3r::GradientCurve gc; + gc.points = result.gradient_curve; + return Slic3r::serialize_gradient_curve(gc); +} + +static bool create_mixed_filament_from_result( + Sidebar* sidebar, + const MixedFilamentResult& result, + const std::vector& color_strs) +{ + if (!sidebar || result.components.size() < 2 || result.ratios.size() < 2) + return false; + if (!dynamic_cast(sidebar->GetParent())) + return false; + + size_t num_physical = sidebar->combos_filament().size(); + if (num_physical < 2) + return false; + if (wxGetApp().preset_bundle->filament_presets.size() >= size_t(EnforcerBlockerType::ExtruderMax)) + return false; + + auto& project_config = wxGetApp().preset_bundle->project_config; + size_t total = wxGetApp().preset_bundle->filament_presets.size(); + size_t new_idx = total; + + std::string mixed_color = blend_mixed_color(result.components, result.ratios, color_strs); + wxGetApp().preset_bundle->set_num_filaments(total + 1, mixed_color); + + auto* multi_colour_opt = project_config.option("filament_multi_colour"); + if (multi_colour_opt) { + while (multi_colour_opt->values.size() <= new_idx) multi_colour_opt->values.push_back(""); + multi_colour_opt->values[new_idx] = mixed_color; + } + + // set_num_filaments() above already grows these parallel arrays; the writes are still + // size-guarded so a sizing bug degrades into a no-op rather than a heap overwrite. + { + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + while (is_mixed_opt->values.size() <= new_idx) is_mixed_opt->values.push_back(false); + is_mixed_opt->values[new_idx] = true; + } + + std::string comp_str; + for (size_t i = 0; i < result.components.size(); ++i) { + if (i > 0) comp_str += ","; + comp_str += std::to_string(result.components[i]); + } + { + auto* comp_opt = project_config.option("filament_mixed_components"); + while (comp_opt->values.size() <= new_idx) comp_opt->values.push_back(std::string{}); + comp_opt->values[new_idx] = comp_str; + } + + int ratio_sum = 0; + for (int r : result.ratios) ratio_sum += r; + if (ratio_sum <= 0) ratio_sum = 100; + + std::string ratio_str; + { + CNumericLocalesSetter c_locale_setter; + for (size_t i = 0; i < result.ratios.size(); ++i) { + if (i > 0) ratio_str += ","; + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.4f", (float)result.ratios[i] / ratio_sum); + ratio_str += buf; + } + } + { + auto* ratios_opt = project_config.option("filament_mixed_sublayer_ratios"); + while (ratios_opt->values.size() <= new_idx) ratios_opt->values.push_back(std::string{}); + ratios_opt->values[new_idx] = ratio_str; + } + + if (!project_config.option("filament_mixed_gradient")) + project_config.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false})); + if (!project_config.option("filament_mixed_gradient_range")) + project_config.set_key_value("filament_mixed_gradient_range", new ConfigOptionStrings({""}) ); + if (!project_config.option("filament_mixed_gradient_curve")) + project_config.set_key_value("filament_mixed_gradient_curve", new ConfigOptionStrings({""}) ); + if (!project_config.option("filament_mixed_gradient_per_part")) + project_config.set_key_value("filament_mixed_gradient_per_part", new ConfigOptionBools({false})); + + { + auto* grad_opt = project_config.option("filament_mixed_gradient"); + while (grad_opt->values.size() <= new_idx) grad_opt->values.push_back(false); + grad_opt->values[new_idx] = result.gradient_enabled; + } + { + auto* grad_range_opt = project_config.option("filament_mixed_gradient_range"); + while (grad_range_opt->values.size() <= new_idx) grad_range_opt->values.push_back(""); + if (result.gradient_enabled && result.components.size() == 2) { + const char* fmt = (result.gradient_direction == 0) ? "0.9000,0.1000" : "0.1000,0.9000"; + grad_range_opt->values[new_idx] = fmt; + } else { + grad_range_opt->values[new_idx] = ""; + } + } + { + auto* grad_curve_opt = project_config.option("filament_mixed_gradient_curve"); + while (grad_curve_opt->values.size() <= new_idx) grad_curve_opt->values.push_back(""); + grad_curve_opt->values[new_idx] = serialize_mixed_gradient_curve_if_custom(result); + } + { + auto* per_part_opt = project_config.option("filament_mixed_gradient_per_part"); + while (per_part_opt->values.size() <= new_idx) per_part_opt->values.push_back(false); + per_part_opt->values[new_idx] = result.gradient_enabled && result.per_part_gradient; + } + + auto& presets = wxGetApp().preset_bundle->filament_presets; + if (result.components[0] >= 1 && result.components[0] <= num_physical && presets.size() > new_idx) + presets[new_idx] = presets[result.components[0] - 1]; + + size_t filament_count = wxGetApp().preset_bundle->filament_presets.size(); + wxGetApp().plater()->get_partplate_list().on_filament_added(filament_count); + wxGetApp().plater()->on_filament_count_change(filament_count); + wxGetApp().get_tab(Preset::TYPE_PRINT)->update(); + wxGetApp().preset_bundle->export_selections(*wxGetApp().app_config); + + sidebar->update_mixed_filament_list(); + wxGetApp().plater()->update_project_dirty_from_presets(); + wxPostEvent(sidebar, SimpleEvent(EVT_SCHEDULE_BACKGROUND_PROCESS, sidebar)); + return true; +} + +void Sidebar::add_mixed_filament() +{ + auto* plater = dynamic_cast(GetParent()); + if (!plater) return; + + size_t num_physical = p->combos_filament.size(); + if (num_physical < 2) return; + if (wxGetApp().preset_bundle->filament_presets.size() >= size_t(EnforcerBlockerType::ExtruderMax)) return; + + std::vector color_strs, names, types; + collect_physical_filament_info(color_strs, names, types); + + MixedFilamentDialog dlg(this, color_strs, names, types); + if (dlg.ShowModal() == wxID_OK) { + auto result = dlg.get_result(); + create_mixed_filament_from_result(this, result, color_strs); + } +} + +void Sidebar::edit_mixed_filament(size_t panel_idx) +{ + auto* plater = dynamic_cast(GetParent()); + if (!plater) return; + + auto mixed_indices = plater->mixed_filament_config_indices(); + if (panel_idx >= mixed_indices.size()) return; + size_t cfg_idx = mixed_indices[panel_idx]; + + std::vector color_strs, names, types; + collect_physical_filament_info(color_strs, names, types); + + auto& project_config = wxGetApp().preset_bundle->project_config; + MixedFilamentResult existing; + auto* components_opt = project_config.option("filament_mixed_components"); + auto* ratios_opt = project_config.option("filament_mixed_sublayer_ratios"); + + // Parse existing components + if (components_opt && cfg_idx < components_opt->values.size()) { + const std::string& cs = components_opt->values[cfg_idx]; + std::istringstream iss(cs); + std::string tok; + while (std::getline(iss, tok, ',')) { + unsigned int v = 0; + if (std::sscanf(tok.c_str(), "%u", &v) == 1) + existing.components.push_back(v); + } + } + // Parse existing ratios + if (ratios_opt && cfg_idx < ratios_opt->values.size()) { + CNumericLocalesSetter c_locale_setter; + const std::string& rs = ratios_opt->values[cfg_idx]; + std::istringstream iss(rs); + std::string tok; + while (std::getline(iss, tok, ',')) { + float v = 0; + if (std::sscanf(tok.c_str(), "%f", &v) == 1) + existing.ratios.push_back((int)(v * 100 + 0.5f)); + } + } + if (existing.components.size() < 2) { + existing.components = {1, 2}; + existing.ratios = {50, 50}; + } else if (existing.ratios.size() != existing.components.size()) { + BOOST_LOG_TRIVIAL(warning) << "Mixed filament edit: ratio count (" + << existing.ratios.size() << ") != component count (" + << existing.components.size() + << "), resetting to even distribution"; + int n = (int)existing.components.size(); + existing.ratios.assign(n, 100 / n); + existing.ratios[0] += 100 - (100 / n) * n; + } + + // Read gradient settings + auto* grad_opt = project_config.option("filament_mixed_gradient"); + if (grad_opt && cfg_idx < grad_opt->values.size()) + existing.gradient_enabled = grad_opt->values[cfg_idx]; + auto* grad_range_opt = project_config.option("filament_mixed_gradient_range"); + if (existing.gradient_enabled && grad_range_opt && cfg_idx < grad_range_opt->values.size()) { + CNumericLocalesSetter c_locale_setter; + float v0 = 0, v1 = 0; + if (std::sscanf(grad_range_opt->values[cfg_idx].c_str(), "%f,%f", &v0, &v1) == 2) + existing.gradient_direction = (v0 > v1) ? 0 : 1; + } + auto* grad_curve_opt = project_config.option("filament_mixed_gradient_curve"); + if (existing.gradient_enabled && grad_curve_opt && cfg_idx < grad_curve_opt->values.size()) { + auto curve = Slic3r::parse_gradient_curve(grad_curve_opt->values[cfg_idx]); + existing.gradient_curve = curve.points; + } + auto* per_part_opt = project_config.option("filament_mixed_gradient_per_part"); + if (existing.gradient_enabled && per_part_opt && cfg_idx < per_part_opt->values.size()) + existing.per_part_gradient = per_part_opt->values[cfg_idx]; + + MixedFilamentDialog dlg(this, existing, color_strs, names, types); + if (dlg.ShowModal() == wxID_OK) { + auto result = dlg.get_result(); + if (result.components.size() < 2 || result.ratios.size() < 2) return; + + // Serialize components + std::string comp_str; + for (size_t i = 0; i < result.components.size(); ++i) { + if (i > 0) comp_str += ","; + comp_str += std::to_string(result.components[i]); + } + components_opt->values[cfg_idx] = comp_str; + + // Serialize ratios + int ratio_sum = 0; + for (int r : result.ratios) ratio_sum += r; + if (ratio_sum <= 0) ratio_sum = 100; + + std::string ratio_str; + { + CNumericLocalesSetter c_locale_setter; + for (size_t i = 0; i < result.ratios.size(); ++i) { + if (i > 0) ratio_str += ","; + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.4f", (float)result.ratios[i] / ratio_sum); + ratio_str += buf; + } + } + ratios_opt->values[cfg_idx] = ratio_str; + + // Gradient settings — ensure keys exist in dynamic config + if (!project_config.option("filament_mixed_gradient")) + project_config.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false})); + if (!project_config.option("filament_mixed_gradient_range")) + project_config.set_key_value("filament_mixed_gradient_range", new ConfigOptionStrings({""}) ); + if (!project_config.option("filament_mixed_gradient_curve")) + project_config.set_key_value("filament_mixed_gradient_curve", new ConfigOptionStrings({""}) ); + if (!project_config.option("filament_mixed_gradient_per_part")) + project_config.set_key_value("filament_mixed_gradient_per_part", new ConfigOptionBools({false})); + + { + auto* grad_opt = project_config.option("filament_mixed_gradient"); + while (grad_opt->values.size() <= cfg_idx) grad_opt->values.push_back(false); + grad_opt->values[cfg_idx] = result.gradient_enabled; + } + { + auto* grad_range_opt = project_config.option("filament_mixed_gradient_range"); + while (grad_range_opt->values.size() <= cfg_idx) grad_range_opt->values.push_back(""); + if (result.gradient_enabled && result.components.size() == 2) { + const char* fmt = (result.gradient_direction == 0) ? "0.9000,0.1000" : "0.1000,0.9000"; + grad_range_opt->values[cfg_idx] = fmt; + } else { + grad_range_opt->values[cfg_idx] = ""; + } + } + { + auto* grad_curve_opt = project_config.option("filament_mixed_gradient_curve"); + while (grad_curve_opt->values.size() <= cfg_idx) grad_curve_opt->values.push_back(""); + grad_curve_opt->values[cfg_idx] = serialize_mixed_gradient_curve_if_custom(result); + } + { + auto* per_part_opt = project_config.option("filament_mixed_gradient_per_part"); + while (per_part_opt->values.size() <= cfg_idx) per_part_opt->values.push_back(false); + per_part_opt->values[cfg_idx] = result.gradient_enabled && result.per_part_gradient; + } + + // Compute blended color + std::string blended = blend_mixed_color(result.components, result.ratios, color_strs); + auto* colours_opt = project_config.option("filament_colour"); + if (colours_opt && cfg_idx < colours_opt->values.size()) + colours_opt->values[cfg_idx] = blended; + + auto* multi_colour_opt = project_config.option("filament_multi_colour"); + if (multi_colour_opt && cfg_idx < multi_colour_opt->values.size()) + multi_colour_opt->values[cfg_idx] = blended; + + // The edited slot keeps its index, so nothing else refreshes the per-feature filament + // lists - and its blended colour and type are what they show for it. + update_mixed_filament_list(); + update_dynamic_filament_list(); + wxGetApp().plater()->update_project_dirty_from_presets(); + wxPostEvent(this, SimpleEvent(EVT_SCHEDULE_BACKGROUND_PROCESS, this)); + } +} + +void Sidebar::delete_mixed_filament_at(size_t panel_idx) +{ + auto* plater = dynamic_cast(GetParent()); + if (!plater) return; + + auto mixed_indices = plater->mixed_filament_config_indices(); + if (panel_idx >= mixed_indices.size()) return; + size_t cfg_idx = mixed_indices[panel_idx]; + + delete_filament(cfg_idx, -1); +} + +void Sidebar::decompose_filament_color(int filament_idx) +{ + if (filament_idx == kSidebarContextMenuFilamentId) + filament_idx = p->m_menu_filament_id; + if (filament_idx < 0) + return; + + auto& project_config = wxGetApp().preset_bundle->project_config; + auto* colours_opt = project_config.option("filament_colour"); + if (!colours_opt || static_cast(filament_idx) >= colours_opt->values.size()) + return; + + wxColour target_color(colours_opt->values[filament_idx]); + + std::vector color_strs, names, types; + std::vector physical_config_indices; + collect_physical_filament_info(color_strs, names, types, &physical_config_indices); + + // Build decompose-specific types: ColorDecomposeDialog needs "PLA Basic" + // distinction (for CMYW/RYBW card visibility), while collect_physical_filament_info + // now returns coarse filament_type (e.g. "PLA" for all PLA variants). + std::vector decompose_types; + { + auto& pb = *wxGetApp().preset_bundle; + for (size_t i = 0; i < physical_config_indices.size(); ++i) { + const size_t ci = physical_config_indices[i]; + Preset* pr = (ci < pb.filament_presets.size()) + ? pb.filaments.find_preset(pb.filament_presets[ci]) : nullptr; + decompose_types.push_back(filament_type_for_color_decompose(pr)); + } + } + + size_t source_physical_idx = size_t(-1); + for (size_t i = 0; i < physical_config_indices.size(); ++i) { + if (physical_config_indices[i] == static_cast(filament_idx)) { + source_physical_idx = i; + break; + } + } + + ColorDecomposeDialog dlg(this, + source_physical_idx == size_t(-1) ? -1 : static_cast(source_physical_idx), + target_color, color_strs, names, decompose_types, + wxGetApp().preset_bundle->filament_presets.size(), + static_cast(EnforcerBlockerType::ExtruderMax), + physical_config_indices); + int modal_res = dlg.ShowModal(); + if (modal_res == wxID_OK) { + ColorDecomposeResult dialog_result = dlg.get_result(); + MixedFilamentResult mixed_result; + std::vector missing_components; + if (!prepare_decompose_mixed_result(dialog_result, static_cast(filament_idx), source_physical_idx, + color_strs, decompose_types, physical_config_indices, mixed_result, missing_components)) + return; + + if (!confirm_create_decompose_missing_components(this, missing_components)) + return; + + for (const DecomposeMissingComponent& missing : missing_components) { + size_t before_count = p->combos_filament.size(); + add_custom_filament(wxColour(missing.official_component.color_hex), missing.preset_name, true); + size_t after_count = p->combos_filament.size(); + if (after_count <= before_count) + return; + set_created_standard_component_metadata(before_count, missing.official_component); + if (missing.component_idx < mixed_result.components.size()) + mixed_result.components[missing.component_idx] = static_cast(before_count + 1); + } + + if (!missing_components.empty()) { + collect_physical_filament_info(color_strs, names, types, &physical_config_indices); + } + + create_mixed_filament_from_result(this, mixed_result, color_strs); + } +} + void Sidebar::update_filaments_area_height() // ORCA { @@ -3918,6 +5234,9 @@ void Sidebar::sys_color_changed() p->scrolled->Layout(); + // Mixed rows are custom-drawn, so they need rebuilding for the new theme colours. + update_mixed_filament_list(); + p->searcher.dlg_sys_color_changed(); } @@ -3952,21 +5271,42 @@ void Sidebar::jump_to_option(size_t selected) // BBS. Move logic from Plater::on_extruders_change() to Sidebar::on_filament_count_change(). void Sidebar::on_filament_count_change(size_t num_filaments) { + // num_filaments counts every slot; mixed-color slots are virtual and get no combo of + // their own (they are rendered by update_mixed_filament_list instead), so the physical + // subset drives the combo list. + auto& project_config = wxGetApp().preset_bundle->project_config; + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + + std::vector physical_indices; + for (size_t i = 0; i < num_filaments; ++i) { + if (!is_mixed_opt || i >= is_mixed_opt->values.size() || !is_mixed_opt->values[i]) + physical_indices.push_back(i); + } + const size_t num_physical = physical_indices.size(); + auto& choices = combos_filament(); - if (num_filaments == choices.size()) + if (num_physical == choices.size()) { + // The ctor pre-creates one combo, so a single-filament project hits this guard before + // any layout pass has sized the scroll areas; refresh them here as well. + // Adding a mixed slot also lands here, since only the virtual count changed, so the + // per-feature filament lists - which do list mixed slots - have to be refreshed too. + recalc_filament_scroll_sizes(); + update_mixed_filament_list(); + update_dynamic_filament_list(); return; + } - if (choices.size() == 1 || num_filaments == 1) + if (choices.size() == 1 || num_physical == 1) choices[0]->GetDropDown().Invalidate(); wxWindowUpdateLocker noUpdates_scrolled_panel(this); size_t i = choices.size(); - while (i < num_filaments) + while (i < num_physical) { PlaterPresetComboBox* choice/*{ nullptr }*/; - init_filament_combo(&choice, i); + init_filament_combo(&choice, physical_indices[i]); int last_selection = choices.back()->GetSelection(); choices.push_back(choice); @@ -3977,11 +5317,13 @@ void Sidebar::on_filament_count_change(size_t num_filaments) } // remove unused choices if any - remove_unused_filament_combos(num_filaments); + remove_unused_filament_combos(num_physical); show_SEMM_buttons(); // ORCA update_filaments_area_height(); // ORCA + recalc_filament_scroll_sizes(); + update_mixed_filament_list(); Layout(); p->m_panel_filament_title->Refresh(); @@ -3993,51 +5335,54 @@ void Sidebar::on_filaments_delete(size_t filament_id) { auto &choices = combos_filament(); - if (filament_id >= choices.size()) - return; + // A mixed (virtual) slot has no combo of its own, so there is no combo UI to remove — + // but the shared refresh below must still run so the mixed filament panel drops its row. + if (filament_id < choices.size()) { + if (choices.size() == 1) + choices[0]->GetDropDown().Invalidate(); - if (choices.size() == 1) - choices[0]->GetDropDown().Invalidate(); + wxWindowUpdateLocker noUpdates_scrolled_panel(this); - wxWindowUpdateLocker noUpdates_scrolled_panel(this); + // delete UI item + { + const int last = p->combos_filament.size() - 1; + auto sizer_filaments = this->p->sizer_filaments->GetItem(last % 2)->GetSizer(); + sizer_filaments->Remove(last / 2); - // delete UI item - if (filament_id < p->combos_filament.size()) { - const int last = p->combos_filament.size() - 1; - auto sizer_filaments = this->p->sizer_filaments->GetItem(last % 2)->GetSizer(); - sizer_filaments->Remove(last / 2); + PlaterPresetComboBox* to_delete_combox = p->combos_filament[filament_id]; + (*p->combos_filament[last]).Destroy(); + p->combos_filament.pop_back(); - PlaterPresetComboBox* to_delete_combox = p->combos_filament[filament_id]; - (*p->combos_filament[last]).Destroy(); - p->combos_filament.pop_back(); + // BBS: filament double columns + auto sizer_filaments0 = this->p->sizer_filaments->GetItem((size_t) 0)->GetSizer(); + auto sizer_filaments1 = this->p->sizer_filaments->GetItem(1)->GetSizer(); + if (p->combos_filament.size() < 2) { + sizer_filaments1->Clear(); + } else { + size_t c0 = sizer_filaments0->GetChildren().GetCount(); + size_t c1 = sizer_filaments1->GetChildren().GetCount(); + if (c0 < c1) + sizer_filaments1->Remove(c1 - 1); + else if (c0 > c1) + sizer_filaments1->AddStretchSpacer(1); + } + } - // BBS: filament double columns - auto sizer_filaments0 = this->p->sizer_filaments->GetItem((size_t) 0)->GetSizer(); - auto sizer_filaments1 = this->p->sizer_filaments->GetItem(1)->GetSizer(); - if (p->combos_filament.size() < 2) { - sizer_filaments1->Clear(); - } else { - size_t c0 = sizer_filaments0->GetChildren().GetCount(); - size_t c1 = sizer_filaments1->GetChildren().GetCount(); - if (c0 < c1) - sizer_filaments1->Remove(c1 - 1); - else if (c0 > c1) - sizer_filaments1->AddStretchSpacer(1); + show_SEMM_buttons(); // ORCA + + for (size_t idx = filament_id ; idx < p->combos_filament.size(); ++idx) { + p->combos_filament[idx]->update(); } } - show_SEMM_buttons(); // ORCA - - for (size_t idx = filament_id ; idx < p->combos_filament.size(); ++idx) { - p->combos_filament[idx]->update(); - } - update_filaments_area_height(); // ORCA + recalc_filament_scroll_sizes(); + update_mixed_filament_list(); Layout(); p->m_panel_filament_title->Refresh(); update_ui_from_settings(); - dynamic_filament_list.update(); + update_dynamic_filament_list(); } void Sidebar::add_filament() { @@ -4065,20 +5410,38 @@ void Sidebar::delete_filament(size_t filament_id, int replace_filament_id) { filament_id = filament_count; } - if (filament_id > filament_count) + // Mixed (virtual) slots have no combo of their own, so their config index lies past + // filament_count; bound explicit ids by the total slot count instead. + size_t total_filaments = wxGetApp().preset_bundle->filament_presets.size(); + if (filament_id > filament_count && filament_id >= total_filaments) return; - if (wxGetApp().preset_bundle->is_the_only_edited_filament(filament_id) || (filament_id == 0)) { - wxGetApp().get_tab(Preset::TYPE_FILAMENT)->select_preset(wxGetApp().preset_bundle->filament_presets[0], false, "", true); + bool is_mixed = (filament_id >= p->combos_filament.size()); + + if (!is_mixed) { + if (wxGetApp().preset_bundle->is_the_only_edited_filament(filament_id) || (filament_id == 0)) { + wxGetApp().get_tab(Preset::TYPE_FILAMENT)->select_preset(wxGetApp().preset_bundle->filament_presets[0], false, "", true); + } + + if (p->editing_filament == filament_id || p->editing_filament >= filament_count) { + p->editing_filament = -1; + } } - if (p->editing_filament == filament_id || p->editing_filament >= filament_count) { - p->editing_filament = -1; - } + // update_num_filaments() shrinks filament_is_mixed along with the other per-filament arrays, + // so snapshot it first — the paint cleanup below needs to know which slots were mixed + // *before* the delete to avoid discarding assignments to still-valid mixed slots. + std::vector is_mixed_snapshot; + if (auto* opt = wxGetApp().preset_bundle->project_config.option("filament_is_mixed")) + is_mixed_snapshot = opt->values; wxGetApp().preset_bundle->update_num_filaments(filament_id); - wxGetApp().plater()->get_partplate_list().on_filament_deleted(filament_count, filament_id); - wxGetApp().plater()->on_filaments_delete(filament_count, filament_id, replace_filament_id > (int)filament_id ? (replace_filament_id - 1) : replace_filament_id); + + // filament_count only counts physical combos, so with mixed slots present it is not the + // new number of slots; recompute from the shrunk preset list for the downstream updates. + size_t total_after_delete = wxGetApp().preset_bundle->filament_presets.size(); + wxGetApp().plater()->get_partplate_list().on_filament_deleted(total_after_delete, filament_id); + wxGetApp().plater()->on_filaments_delete(total_after_delete, filament_id, replace_filament_id > (int)filament_id ? (replace_filament_id - 1) : replace_filament_id, is_mixed_snapshot); wxGetApp().get_tab(Preset::TYPE_PRINT)->update(); wxGetApp().preset_bundle->export_selections(*wxGetApp().app_config); @@ -4095,6 +5458,36 @@ void Sidebar::delete_filament(size_t filament_id, int replace_filament_id) { void Sidebar::change_filament(size_t from_id, size_t to_id) { + // Merging a physical filament into a mixed one that lists it as a component would delete + // the very filament the mix depends on, leaving it broken. Warn before doing so. + auto& pb = *wxGetApp().preset_bundle; + bool from_is_physical = !pb.is_mixed_filament(from_id); + bool to_is_mixed = pb.is_mixed_filament(to_id); + + if (from_is_physical && to_is_mixed) { + auto* comp_opt = pb.project_config.option("filament_mixed_components"); + if (comp_opt && to_id < comp_opt->values.size()) { + auto comps = Slic3r::parse_mixed_components(comp_opt->values[to_id]); + unsigned int from_1based = (unsigned int)from_id + 1; + bool target_uses_source = false; + for (unsigned int c : comps) { + if (c == from_1based) { + target_uses_source = true; + break; + } + } + if (target_uses_source) { + int ret = wxMessageBox( + _L("The target mixed filament uses this physical filament as a component. " + "Merging will remove this physical filament and may invalidate the mixed filament. Continue?"), + _L("Warning"), + wxOK | wxCANCEL | wxICON_WARNING); + if (ret != wxOK) + return; + } + } + } + delete_filament(from_id, int(to_id)); } @@ -4106,18 +5499,96 @@ void Sidebar::edit_filament() p->editing_filament = p->m_menu_filament_id; // sync with TabPresetComboxBox's m_filament_idx } -void Sidebar::add_custom_filament(wxColour new_col) { +void Sidebar::add_custom_filament(wxColour new_col, const std::string& preset_name, bool /*skip_preset_validation*/) { if (is_new_project_in_gcode3mf()) { return; } if (p->combos_filament.size() >= MAXIMUM_EXTRUDER_NUMBER) return; + if (wxGetApp().preset_bundle->filament_presets.size() >= MAXIMUM_EXTRUDER_NUMBER) return; - int filament_count = p->combos_filament.size() + 1; + // Mixed-color slots are kept at the tail of the filament arrays, so a new physical + // filament has to be inserted just after the last physical one rather than appended. + // Count off filament_is_mixed, not filament_presets or the combos: the extruder-count spinner + // reaches this before the sidebar has rebuilt, and update_multi_material_filament_presets() + // can have grown filament_presets alone. + auto *bundle = wxGetApp().preset_bundle; + size_t insert_pos = bundle->num_physical_filaments(); + size_t total = insert_pos + bundle->num_mixed_filaments(); + int filament_count = (int)(total + 1); std::string new_color = new_col.GetAsString(wxC2S_HTML_SYNTAX).ToStdString(); - wxGetApp().preset_bundle->set_num_filaments(filament_count, new_color); + bundle->set_num_filaments(filament_count, new_color); + + // Maintain physical-first ordering: rotate the new slot from end to insert_pos. + // No mixed slots -> insert_pos == total -> every rotate below is a no-op. + if (insert_pos < total) { + auto& presets = wxGetApp().preset_bundle->filament_presets; + std::rotate(presets.begin() + insert_pos, presets.begin() + total, presets.end()); + + auto& project_config = wxGetApp().preset_bundle->project_config; + auto& ams_mc = wxGetApp().preset_bundle->ams_multi_color_filment; + + auto rotate_strings = [&](const char* key) { + if (auto* opt = project_config.option(key)) + if (opt->values.size() > total) + std::rotate(opt->values.begin() + insert_pos, opt->values.begin() + total, opt->values.end()); + }; + auto rotate_ints = [&](const char* key) { + if (auto* opt = project_config.option(key)) + if (opt->values.size() > total) + std::rotate(opt->values.begin() + insert_pos, opt->values.begin() + total, opt->values.end()); + }; + auto rotate_bools = [&](const char* key) { + if (auto* opt = project_config.option(key)) + if (opt->values.size() > total) + std::rotate(opt->values.begin() + insert_pos, opt->values.begin() + total, opt->values.end()); + }; + + rotate_strings("filament_colour"); + rotate_strings("filament_multi_colour"); + rotate_strings("filament_colour_type"); + rotate_ints("filament_map"); + rotate_ints("filament_nozzle_map"); + rotate_ints("filament_volume_map"); + rotate_bools("filament_is_mixed"); + rotate_strings("filament_mixed_components"); + rotate_strings("filament_mixed_sublayer_ratios"); + rotate_bools("filament_mixed_gradient"); + rotate_strings("filament_mixed_gradient_range"); + rotate_strings("filament_mixed_gradient_curve"); + rotate_bools("filament_mixed_gradient_per_part"); + + if (ams_mc.size() > total) + std::rotate(ams_mc.begin() + insert_pos, ams_mc.begin() + total, ams_mc.end()); + + // Remap object/volume extruder IDs and paint data: anything >= insert_pos+1 (1-based) shifts up by 1 + int threshold_1based = (int)(insert_pos + 1); + auto ebt_threshold = EnforcerBlockerType(threshold_1based); + for (auto* obj : wxGetApp().plater()->model().objects) { + if (obj->config.has("extruder")) { + int ext = obj->config.extruder(); + if (ext >= threshold_1based) + obj->config.set("extruder", ext + 1); + } + for (auto* vol : obj->volumes) { + if (vol->config.has("extruder")) { + int ext = vol->config.extruder(); + if (ext >= threshold_1based) + vol->config.set("extruder", ext + 1); + } + vol->mmu_segmentation_facets.shift_states_above(*vol, ebt_threshold, +1); + } + } + } + + if (!preset_name.empty() && + wxGetApp().preset_bundle->filaments.find_preset(preset_name, false) && + insert_pos < wxGetApp().preset_bundle->filament_presets.size()) { + wxGetApp().preset_bundle->filament_presets[insert_pos] = preset_name; + } + wxGetApp().plater()->get_partplate_list().on_filament_added(filament_count); wxGetApp().plater()->on_filament_count_change(filament_count); wxGetApp().get_tab(Preset::TYPE_PRINT)->update(); wxGetApp().preset_bundle->export_selections(*wxGetApp().app_config); - auto_calc_flushing_volumes(filament_count - 1); + auto_calc_flushing_volumes(insert_pos); } bool Sidebar::is_new_project_in_gcode3mf() @@ -4452,7 +5923,7 @@ void Sidebar::sync_ams_list(bool is_from_big_sync_btn) if (m_sync_dlg->is_dirty_filament()) { wxGetApp().get_tab(Preset::TYPE_FILAMENT)->select_preset(wxGetApp().preset_bundle->filament_presets[0], false, "", false, true); wxGetApp().preset_bundle->export_selections(*wxGetApp().app_config); - dynamic_filament_list.update(); + update_dynamic_filament_list(); } m_sync_dlg->set_check_dirty_fialment(false); dlg_res = m_sync_dlg->ShowModal(); @@ -4722,6 +6193,7 @@ void Sidebar::enable_nozzle_count_edit(bool enable) void Sidebar::update_dynamic_filament_list() { dynamic_filament_list.update(); + dynamic_physical_filament_list.update(); } PlaterPresetComboBox* Sidebar::printer_combox() @@ -5103,6 +6575,10 @@ void Sidebar::auto_calc_flushing_volumes(const int filament_idx, const int extru void Sidebar::auto_calc_flushing_volumes_internal(const int modify_id, const int extruder_id) { auto& preset_bundle = wxGetApp().preset_bundle; + // A mixed-colour slot is virtual and is never flushed to or from: leave its row and column + // alone (the flushing dialog hides them and only compares physical slots). + if (modify_id >= 0 && preset_bundle->is_mixed_filament((size_t)modify_id)) + return; auto& project_config = preset_bundle->project_config; const auto& full_config = wxGetApp().preset_bundle->full_config(); auto& ams_multi_color_filament = preset_bundle->ams_multi_color_filment; @@ -5141,6 +6617,8 @@ void Sidebar::auto_calc_flushing_volumes_internal(const int modify_id, const int if (modify_id >= 0 && modify_id < multi_colours.size()) { for (int i = 0; i < multi_colours.size(); ++i) { + if (preset_bundle->is_mixed_filament((size_t)i)) + continue; // from to modify int from_idx = i; if (from_idx != modify_id) { @@ -5510,9 +6988,34 @@ struct Plater::priv BoundingBox scaled_bed_shape_bb() const; // BBS: backup & restore + using LoadProgressCallback = std::function; std::vector load_files(const std::vector& input_files, LoadStrategy strategy, bool ask_multi = false); std::vector load_model_objects(const ModelObjectPtrs& model_objects, bool allow_negative_z = false, bool split_object = false, bool auto_drop = true); + // Texture-to-color import: a mesh loaded with UVs + a texture map gets its faces clustered + // into printable colours, which are then matched against (or added to) the filament list. + struct TextureImportResult { + Slic3r::PaintedMesh painted; + std::vector matches; + std::vector> new_filament_colors; + std::vector new_filament_preset_names; + std::vector new_mixed_filaments; + std::vector filament_entries; + size_t existing_filament_count = 0; + bool skipped = false; + bool fallback_to_geometry_only = false; + wxString fallback_warning; + }; + + bool run_textured_mesh_import_dialog(Slic3r::Model& loaded_model, TextureImportResult& result, + std::function cancel_callback = {}, + std::function progress_callback = {}); + void apply_textured_mesh_import_result(Slic3r::Model& loaded_model, const std::vector& obj_idxs, + const TextureImportResult& result, + LoadProgressCallback progress_callback = {}, bool update_scene = true); + void handle_textured_mesh_import(Slic3r::Model& model, const std::vector& obj_idxs, + std::function cancel_callback = {}); + fs::path get_export_file_path(GUI::FileType file_type); wxString get_export_file(GUI::FileType file_type); @@ -5887,7 +7390,7 @@ Plater::priv::priv(Plater *q, MainFrame *main_frame) "brim_width", "brim_object_gap", "brim_flow_ratio", "brim_use_efc_outline", "combine_brims", "brim_type", "nozzle_diameter", "single_extruder_multi_material", "preferred_orientation", "enable_prime_tower", "wipe_tower_x", "wipe_tower_y", "prime_tower_width", "prime_tower_brim_width", "prime_tower_skip_points", "prime_tower_enable_framework", "prime_tower_infill_gap", "prime_volume", - "extruder_colour", "filament_colour", "filament_type", "material_colour", "printable_height", "extruder_printable_height", "printer_model", "printer_technology", + "extruder_colour", "filament_colour", "filament_type", "filament_is_support", "material_colour", "printable_height", "extruder_printable_height", "printer_model", "printer_technology", // These values are necessary to construct SlicingParameters by the Canvas3D variable layer height editor. "layer_height", "initial_layer_print_height", "min_layer_height", "max_layer_height", "wall_loops", "outer_wall_filament_id", "inner_wall_filament_id", "sparse_infill_density", "sparse_infill_filament_id", "top_shell_layers", @@ -7783,6 +9286,38 @@ std::vector Plater::priv::load_files(const std::vector& input_ q->model().load_from(model); load_auxiliary_files(); } + // Texture-to-color: a mesh that arrived with UVs and a decoded texture gets its + // faces clustered into printable colours and matched against the filament list, + // before the objects are handed to the plater. Inert for every other model. + if (model.texture_mesh && has_importable_texture(*model.texture_mesh)) { + TextureImportResult texture_import_result; + auto cancel_cb = [&dlg, &dlg_cont]() { return !dlg_cont || dlg.WasCancelled(); }; + auto progress_cb = [&dlg, &dlg_cont, &progress_percent](int percent) { + progress_percent = std::clamp(percent, 0, 100); + dlg_cont = dlg.Update(progress_percent, _L("Matching textures to filaments")); + return dlg_cont; + }; + if (!run_textured_mesh_import_dialog(model, texture_import_result, cancel_cb, progress_cb)) { + q->skip_thumbnail_invalid = false; + return empty_result; + } + if (texture_import_result.fallback_to_geometry_only && !texture_import_result.fallback_warning.empty()) { + MessageDialog(q, texture_import_result.fallback_warning, + _L("Texture Import Warning"), + wxOK | wxICON_WARNING).ShowModal(); + } + if (!texture_import_result.painted.face_colors.empty()) { + std::vector texture_object_idxs(model.objects.size()); + std::iota(texture_object_idxs.begin(), texture_object_idxs.end(), 0); + auto apply_progress_cb = [&dlg](int percent, const wxString& msg) { + dlg.Update(std::clamp(percent, 0, 100), msg); + return true; + }; + apply_textured_mesh_import_result(model, texture_object_idxs, texture_import_result, + apply_progress_cb, false); + } + } + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ":" << __LINE__ << boost::format(", before load_model_objects, count %1%")%model.objects.size(); auto loaded_idxs = load_model_objects(model.objects, is_project_file); obj_idxs.insert(obj_idxs.end(), loaded_idxs.begin(), loaded_idxs.end()); @@ -8386,7 +9921,11 @@ void Plater::priv::object_list_changed() // BBS //sidebar->enable_buttons(!model.objects.empty() && !export_in_progress && model_fits && part_plate->has_printable_instances()); - bool can_slice = !model.objects.empty() && !export_in_progress && model_fits && part_plate->has_printable_instances(); + // A mixed filament with deleted or type-mismatched components cannot be resolved at slicing + // time, so block the slice buttons the same way MainFrame::get_enable_slice_status() does. + bool mixed_broken = sidebar->has_broken_mixed_filament(); + bool can_slice = !model.objects.empty() && !export_in_progress && model_fits && part_plate->has_printable_instances() + && !mixed_broken; BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": can_slice %1%, model_fits= %2%, export_in_progress %3%, has_printable_instances %4% ")%can_slice %model_fits %export_in_progress %part_plate->has_printable_instances(); main_frame->update_slice_print_status(MainFrame::eEventObjectUpdate, can_slice); @@ -10134,9 +11673,11 @@ void Plater::priv::set_current_panel(wxPanel* panel, bool no_slice) if (current_plate->is_slice_result_valid() && this->model.objects.empty() && !current_has_print_instances) only_has_gcode_need_preview = true; - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": from set_current_panel, no_slice %1%, export_in_progress %2%, model_fits %3%, m_is_slicing %4%")%no_slice%export_in_progress%model_fits%m_is_slicing; + bool mixed_broken = sidebar->has_broken_mixed_filament(); - if (!no_slice && !this->model.objects.empty() && !export_in_progress && model_fits && current_has_print_instances) + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": from set_current_panel, no_slice %1%, export_in_progress %2%, model_fits %3%, m_is_slicing %4%, mixed_broken %5%")%no_slice%export_in_progress%model_fits%m_is_slicing%mixed_broken; + + if (!no_slice && !this->model.objects.empty() && !export_in_progress && model_fits && current_has_print_instances && !mixed_broken) { //if already running in background, not relice here //BBS: add more judge for slicing @@ -11478,6 +13019,9 @@ void Plater::priv::on_filament_color_changed(wxCommandEvent &event) if (wxGetApp().app_config->get("auto_calculate_flush") != "disabled") { sidebar->auto_calc_flushing_volumes(modify_id); } + + // A mixed slot's colour is derived from its components, so recompute the swatches. + sidebar->update_mixed_filament_list(); } void Plater::priv::install_network_plugin(wxCommandEvent &event) @@ -12602,6 +14146,23 @@ bool Plater::priv::can_layers_editing() const void Plater::priv::on_action_layersediting(SimpleEvent&) { + // Sub-layer splitting divides each layer by the mix ratio, so a variable layer height profile + // makes those sub-layer heights uneven and degrades the blend. ConfigManipulation warns for the + // opposite order, when the option is switched on while a variable profile already exists. + if (!view3D->is_layers_editing_enabled()) { + const auto& print_config = wxGetApp().preset_bundle->prints.get_edited_preset().config; + if (print_config.opt_bool("enable_mixed_color_sublayer")) { + if (wxGetApp().app_config->get("no_warn_mixed_sublayer_variable_layer") != "1") { + MessageDialog dlg(q, + _L("Using variable layer height together with mixed color sublayer may result in poor color mixing quality."), + _L("Warning"), wxICON_WARNING | wxOK); + dlg.show_dsa_button(); + dlg.ShowModal(); + if (dlg.get_checkbox_state()) + wxGetApp().app_config->set("no_warn_mixed_sublayer_variable_layer", "1"); + } + } + } view3D->enable_layers_editing(!view3D->is_layers_editing_enabled()); notification_manager->set_move_from_overlay(view3D->is_layers_editing_enabled()); } @@ -13087,6 +14648,348 @@ void Plater::reset_project_dirty_initial_presets() { p->reset_project_dirty_init void Plater::render_project_state_debug_window() const { p->render_project_state_debug_window(); } #endif // ENABLE_PROJECT_DIRTY_STATE_DEBUG_WINDOW +std::vector Plater::mixed_filament_config_indices() const +{ + std::vector indices; + auto& config = wxGetApp().preset_bundle->project_config; + auto* opt = config.option("filament_is_mixed"); + if (!opt) return indices; + for (size_t i = 0; i < opt->values.size(); ++i) + if (opt->values[i]) indices.push_back(i); + return indices; +} + +std::vector Plater::physical_filament_config_indices() const +{ + std::vector indices; + auto& config = wxGetApp().preset_bundle->project_config; + auto* opt = config.option("filament_is_mixed"); + size_t total = wxGetApp().preset_bundle->filament_presets.size(); + for (size_t i = 0; i < total; ++i) { + if (!opt || i >= opt->values.size() || !opt->values[i]) + indices.push_back(i); + } + return indices; +} + +bool Plater::priv::run_textured_mesh_import_dialog(Slic3r::Model& loaded_model, TextureImportResult& result, + std::function cancel_callback, + std::function progress_callback) +{ + if (!loaded_model.texture_mesh || !has_importable_texture(*loaded_model.texture_mesh)) return false; + + // Defense in depth: if all geometry got dropped earlier (e.g. by a future + // regression of the zero-volume cleanup) but the textured mesh is still + // alive, there is nothing for the dialog to paint onto. Skip the dialog + // gracefully so load_files() can fall through to its "no geometry" + // message instead of making the user round-trip a meaningless matcher. + if (loaded_model.objects.empty()) { + BOOST_LOG_TRIVIAL(warning) << "handle_textured_mesh_import: skipping dialog because the loaded model has no geometry objects"; + loaded_model.texture_mesh.reset(); + result.skipped = true; + return true; + } + + const wxString fallback_warning = _L("Texture import failed. The model appears to contain texture data, but the texture import process could not be completed. The model will be imported as geometry only."); + + BOOST_LOG_TRIVIAL(info) << "handle_textured_mesh_import: opening texture import dialog"; + + std::vector filament_entries; + { + auto& preset_bundle = *wxGetApp().preset_bundle; + auto& project_config = preset_bundle.project_config; + auto* colours_opt = project_config.option("filament_colour"); + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + auto* type_opt = project_config.option("filament_type"); + auto* components_opt = project_config.option("filament_mixed_components"); + auto* ratios_opt = project_config.option("filament_mixed_sublayer_ratios"); + const size_t total = preset_bundle.filament_presets.size(); + filament_entries.reserve(total); + for (size_t i = 0; i < total; ++i) { + TextureFilamentEntry entry; + entry.kind = (is_mixed_opt && i < is_mixed_opt->values.size() && is_mixed_opt->values[i]) ? + TextureFilamentKind::ExistingMixed : TextureFilamentKind::ExistingPhysical; + entry.dialog_index = (int)filament_entries.size(); + entry.project_config_index = i; + entry.color_hex = (colours_opt && i < colours_opt->values.size()) ? colours_opt->values[i] : "#808080"; + entry.type = (type_opt && i < type_opt->values.size()) ? type_opt->values[i] : ""; + + std::string name; + if (i < preset_bundle.filament_presets.size()) { + auto* preset = preset_bundle.filaments.find_preset(preset_bundle.filament_presets[i]); + if (preset) + name = preset->label(false); + } + if (name.empty()) + name = "Filament " + std::to_string(i + 1); + entry.name = name; + + if (entry.kind == TextureFilamentKind::ExistingMixed) { + if (components_opt && i < components_opt->values.size()) + entry.mixed_components = Slic3r::parse_mixed_components(components_opt->values[i]); + std::vector ratios = Slic3r::parse_mixed_ratios( + ratios_opt && i < ratios_opt->values.size() ? ratios_opt->values[i] : "", + entry.mixed_components.size()); + entry.mixed_ratios.reserve(ratios.size()); + for (double ratio : ratios) + entry.mixed_ratios.push_back((int)std::lround(ratio * 100.0)); + } + filament_entries.push_back(std::move(entry)); + } + } + + TextureImportDialog dlg(q, *loaded_model.texture_mesh, filament_entries, + std::move(cancel_callback), std::move(progress_callback)); + if (dlg.ShowModal() != wxID_OK) { + if (dlg.was_skipped()) { + BOOST_LOG_TRIVIAL(info) << "handle_textured_mesh_import: user skipped texture matching"; + result.skipped = true; + loaded_model.texture_mesh.reset(); + return true; + } + if (dlg.fallback_to_geometry_only()) { + BOOST_LOG_TRIVIAL(warning) << "handle_textured_mesh_import: texture import failed, falling back to geometry-only import"; + result.fallback_to_geometry_only = true; + result.fallback_warning = fallback_warning; + loaded_model.texture_mesh.reset(); + return true; + } + BOOST_LOG_TRIVIAL(info) << "handle_textured_mesh_import: user cancelled"; + loaded_model.texture_mesh.reset(); + return false; + } + + auto painted = dlg.get_painted_mesh(); + auto final_matches = dlg.get_matches(); + + if (painted.face_colors.empty() || final_matches.empty()) { + BOOST_LOG_TRIVIAL(warning) << "handle_textured_mesh_import: no painting result"; + result.fallback_to_geometry_only = true; + result.fallback_warning = fallback_warning; + loaded_model.texture_mesh.reset(); + return true; + } + + BOOST_LOG_TRIVIAL(info) << "handle_textured_mesh_import: got " << painted.cluster_colors.size() + << " clusters, skipped=" << dlg.was_skipped(); + + result.painted = std::move(painted); + result.matches = std::move(final_matches); + result.new_filament_colors = dlg.get_new_filament_colors(); + result.new_filament_preset_names = dlg.get_new_filament_preset_names(); + result.new_mixed_filaments = dlg.get_new_mixed_filaments(); + result.filament_entries = dlg.get_filament_entries(); + result.existing_filament_count = dlg.get_existing_filament_count(); + result.skipped = dlg.was_skipped(); + return true; +} + +void Plater::priv::apply_textured_mesh_import_result(Slic3r::Model& loaded_model, const std::vector& obj_idxs, + const TextureImportResult& result, + LoadProgressCallback progress_callback, bool update_scene) +{ + auto update_apply_progress = [&progress_callback](int percent, const wxString& message) { + return !progress_callback || progress_callback(std::clamp(percent, 0, 100), message); + }; + + const auto& painted = result.painted; + const auto& final_matches = result.matches; + + if (painted.face_colors.empty() || final_matches.empty()) { + BOOST_LOG_TRIVIAL(warning) << "handle_textured_mesh_import: no painting result"; + loaded_model.texture_mesh.reset(); + return; + } + + BOOST_LOG_TRIVIAL(info) << "handle_textured_mesh_import: got " << painted.cluster_colors.size() + << " clusters, skipped=" << result.skipped; + if (!update_apply_progress(0, _L("Applying texture colors..."))) + return; + + auto collect_physical_color_strs = []() { + std::vector colors; + auto& project_config = wxGetApp().preset_bundle->project_config; + auto* colours_opt = project_config.option("filament_colour"); + auto* is_mixed_opt = project_config.option("filament_is_mixed"); + const size_t total = wxGetApp().preset_bundle->filament_presets.size(); + for (size_t i = 0; i < total; ++i) { + const bool is_mixed = is_mixed_opt && i < is_mixed_opt->values.size() && is_mixed_opt->values[i]; + if (!is_mixed) + colors.push_back(colours_opt && i < colours_opt->values.size() ? colours_opt->values[i] : "#808080"); + } + return colors; + }; + + const auto& entries = result.filament_entries; + std::vector filament_index_remap(entries.size(), -1); + size_t existing_physical_count = 0; + size_t new_physical_count = 0; + for (const auto& entry : entries) { + if (entry.kind == TextureFilamentKind::ExistingPhysical) + ++existing_physical_count; + else if (entry.kind == TextureFilamentKind::NewPhysical) + ++new_physical_count; + } + + for (const auto& entry : entries) { + if (entry.dialog_index < 0 || entry.dialog_index >= (int)filament_index_remap.size()) + continue; + if (entry.kind == TextureFilamentKind::ExistingPhysical) { + filament_index_remap[entry.dialog_index] = (int)entry.project_config_index; + } else if (entry.kind == TextureFilamentKind::ExistingMixed) { + filament_index_remap[entry.dialog_index] = (int)(entry.project_config_index + new_physical_count); + } + } + + size_t new_physical_order = 0; + for (const auto& entry : entries) { + if (entry.kind != TextureFilamentKind::NewPhysical) + continue; + wxColour new_col(entry.color_hex); + const size_t final_idx = existing_physical_count + new_physical_order; + sidebar->add_custom_filament(new_col, entry.preset_name); + if (entry.dialog_index >= 0 && entry.dialog_index < (int)filament_index_remap.size()) + filament_index_remap[entry.dialog_index] = (int)final_idx; + BOOST_LOG_TRIVIAL(info) << "handle_textured_mesh_import: created pending physical filament dialog=" + << entry.dialog_index << " final=" << final_idx + << " color=" << entry.color_hex + << " preset=" << entry.preset_name; + ++new_physical_order; + } + + std::vector physical_colors_for_mixing = collect_physical_color_strs(); + for (const auto& mixed : result.new_mixed_filaments) { + MixedFilamentResult mixed_result; + mixed_result.ratios = mixed.ratios; + mixed_result.components.reserve(mixed.component_dialog_indices.size()); + bool valid_components = true; + for (int component_dialog_idx : mixed.component_dialog_indices) { + if (component_dialog_idx < 0 || component_dialog_idx >= (int)filament_index_remap.size() || + filament_index_remap[component_dialog_idx] < 0) { + valid_components = false; + break; + } + mixed_result.components.push_back((unsigned int)(filament_index_remap[component_dialog_idx] + 1)); + } + if (!valid_components || mixed_result.components.size() < 2 || + mixed_result.components.size() != mixed_result.ratios.size()) { + BOOST_LOG_TRIVIAL(warning) << "handle_textured_mesh_import: invalid pending mixed filament dialog=" + << mixed.dialog_index; + continue; + } + + const int final_idx = (int)wxGetApp().preset_bundle->filament_presets.size(); + if (create_mixed_filament_from_result(sidebar, mixed_result, physical_colors_for_mixing)) { + if (mixed.dialog_index >= 0 && mixed.dialog_index < (int)filament_index_remap.size()) + filament_index_remap[mixed.dialog_index] = final_idx; + physical_colors_for_mixing = collect_physical_color_strs(); + BOOST_LOG_TRIVIAL(info) << "handle_textured_mesh_import: created pending mixed filament dialog=" + << mixed.dialog_index << " final=" << final_idx; + } + } + + std::vector remapped_matches = final_matches; + for (auto& m : remapped_matches) { + if (m.filament_index < 0) + continue; + if (m.filament_index < (int)filament_index_remap.size() && filament_index_remap[m.filament_index] >= 0) { + m.filament_index = filament_index_remap[m.filament_index]; + } else { + BOOST_LOG_TRIVIAL(warning) << "handle_textured_mesh_import: invalid filament index " + << m.filament_index << " in texture mapping"; + m.filament_index = -1; + } + } + + int min_used_filament_1based = -1; + { + std::map, int> color_to_filament; + for (const auto& m : remapped_matches) { + if (m.cluster_index >= 0 && m.cluster_index < (int)painted.cluster_colors.size() && m.filament_index >= 0) + color_to_filament[painted.cluster_colors[m.cluster_index]] = m.filament_index + 1; + } + for (const auto& face_color : painted.face_colors) { + auto it = color_to_filament.find(face_color); + if (it == color_to_filament.end()) + continue; + if (min_used_filament_1based < 0 || it->second < min_used_filament_1based) + min_used_filament_1based = it->second; + } + } + if (min_used_filament_1based < 0) + BOOST_LOG_TRIVIAL(warning) << "handle_textured_mesh_import: cannot determine base filament from painted faces"; + + if (!update_apply_progress(25, _L("Applying texture colors..."))) + return; + + for (size_t obj_order = 0; obj_order < obj_idxs.size(); ++obj_order) { + size_t idx = obj_idxs[obj_order]; + if (idx >= loaded_model.objects.size()) continue; + ModelObject* obj = loaded_model.objects[idx]; + if (!obj) continue; + + // painted is derived from the whole textured mesh and is meaningful + // only against a single MODEL_PART volume. Applying it to every + // volume of a multi-part / modifier object would overwrite each + // volume with the same painted geometry. Restrict to the first + // model_part and warn when the object holds more than one. + ModelVolume* target = nullptr; + int part_count = 0; + for (ModelVolume* vol : obj->volumes) { + if (vol && vol->is_model_part()) { + ++part_count; + if (!target) target = vol; + } + } + if (!target) continue; + if (part_count > 1) { + BOOST_LOG_TRIVIAL(warning) + << "handle_textured_mesh_import: object has " << part_count + << " model parts; painting only applied to the first part."; + } + if (Slic3r::apply_painted_mesh_to_volume(painted, remapped_matches, *target) + && min_used_filament_1based > 0) { + target->config.set("extruder", min_used_filament_1based); + obj->config.set("extruder", min_used_filament_1based); + if (update_scene) { + if (auto* obj_list = wxGetApp().obj_list()) { + obj_list->update_objects_list_filament_column(std::max( + wxGetApp().filaments_cnt(), (size_t)min_used_filament_1based)); + obj_list->update_info_items(idx); + } + } + BOOST_LOG_TRIVIAL(info) << "handle_textured_mesh_import: set base filament to " + << min_used_filament_1based << " for object index " << idx + << ", object extruder=" << obj->config.extruder() + << ", volume extruder=" << target->config.extruder(); + } + // bbox invalidation is performed inside apply_painted_mesh_to_volume. + obj->ensure_on_bed(); + const int object_percent = 25 + (int)(60 * (obj_order + 1) / std::max(obj_idxs.size(), 1)); + if (!update_apply_progress(object_percent, _L("Applying texture colors..."))) + return; + } + + BOOST_LOG_TRIVIAL(info) << "handle_textured_mesh_import: painting applied to model volumes"; + loaded_model.texture_mesh.reset(); + if (update_scene) { + if (!update_apply_progress(90, _L("Updating 3D view..."))) + return; + update(); + } + update_apply_progress(100, _L("Texture colors applied.")); +} + +void Plater::priv::handle_textured_mesh_import(Slic3r::Model& loaded_model, const std::vector& obj_idxs, + std::function cancel_callback) +{ + TextureImportResult result; + if (!run_textured_mesh_import_dialog(loaded_model, result, std::move(cancel_callback))) + return; + if (!result.painted.face_colors.empty()) + apply_textured_mesh_import_result(loaded_model, obj_idxs, result); +} + Sidebar& Plater::sidebar() { return *p->sidebar; } const Model& Plater::model() const { return p->model; } Model& Plater::model() { return p->model; } @@ -16987,6 +18890,15 @@ void Plater::reslice() return; } + // A mixed filament with deleted or type-mismatched components cannot be resolved at slicing + // time. MainFrame::get_enable_slice_status() already disables the Slice button for it, but the + // Preview-tab switch, auto-slice and queued slice events reach reslice() directly, so refuse + // here too instead of letting the engine slice the broken slot as a plain filament. + if (sidebar().has_broken_mixed_filament()) { + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": broken mixed filament detected, refuse to slice"; + return; + } + // In case SLA gizmo is in editing mode, refuse to continue // and notify user that he should leave it first. if (get_view3D_canvas3D()->get_gizmos_manager().is_in_editing_mode(true)) @@ -17682,7 +19594,7 @@ void Plater::on_filament_count_change(size_t num_filaments) } } -void Plater::on_filaments_delete(size_t num_filaments, size_t filament_id, int replace_filament_id) +void Plater::on_filaments_delete(size_t num_filaments, size_t filament_id, int replace_filament_id, const std::vector& is_mixed_before_delete) { // only update elements in plater update_filament_colors_in_full_config(); @@ -17696,14 +19608,22 @@ void Plater::on_filaments_delete(size_t num_filaments, size_t filament_id, int r }*/ // update mmu info + // A volume assigned to a mixed slot legitimately sits past the physical filament count, so + // the paint cleanup must know which slots were mixed. Callers that already shrank the arrays + // pass the pre-delete flags; otherwise read the current ones. + const auto &is_mixed = is_mixed_before_delete.empty() + ? wxGetApp().preset_bundle->project_config.option("filament_is_mixed")->values + : is_mixed_before_delete; for (ModelObject *mo : wxGetApp().model().objects) { for (ModelVolume *mv : mo->volumes) { - mv->update_extruder_count_when_delete_filament(num_filaments, filament_id + 1, replace_filament_id + 1); // this function is 1 base + mv->update_extruder_count_when_delete_filament(num_filaments, filament_id + 1, replace_filament_id + 1, is_mixed); // this function is 1 base } } - // update UI - sidebar().on_filaments_delete(filament_id); + // update object/volume/support(object and volume) filament id + // Must run before UI update which triggers update_mixed_filament_list() → + // update_objects_list_filament_column() that clips extruders above total count. + sidebar().obj_list()->update_objects_list_filament_column_when_delete_filament(filament_id, num_filaments, replace_filament_id); // update global support filament static const char *keys[] = {"support_filament", "support_interface_filament"}; @@ -17717,8 +19637,8 @@ void Plater::on_filaments_delete(size_t num_filaments, size_t filament_id, int r } } - // update object/volume/support(object and volume) filament id - sidebar().obj_list()->update_objects_list_filament_column_when_delete_filament(filament_id, num_filaments, replace_filament_id); + // update UI — runs after remap so update_mixed_filament_list() won't clip remapped extruder IDs + sidebar().on_filaments_delete(filament_id); // update customize gcode for (auto item = p->model.plates_custom_gcodes.begin(); item != p->model.plates_custom_gcodes.end(); ++item) { @@ -17811,6 +19731,7 @@ void Plater::on_config_change(const DynamicPrintConfig &config) update_scheduled = true; // update should be scheduled (for update 3DScene) #2738 if (update_filament_colors_in_full_config()) { + p->sidebar->update_mixed_filament_list(); p->sidebar->obj_list()->update_filament_colors(); p->sidebar->update_dynamic_filament_list(); continue; @@ -17818,6 +19739,15 @@ void Plater::on_config_change(const DynamicPrintConfig &config) } if (opt_key == "filament_type") { update_filament_colors_in_full_config(); + p->sidebar->update_mixed_filament_list(); + continue; + } + // The mixed-filament type check folds filament_is_support into the component type + // (DynamicPrintConfig::get_filament_type -> "PLA-S"), so a support-preset switch must + // refresh the list even though filament_type itself did not change. + if (opt_key == "filament_is_support") { + p->config->set_key_value(opt_key, config.option(opt_key)->clone()); + p->sidebar->update_mixed_filament_list(); continue; } if (opt_key == "material_colour") { @@ -18037,6 +19967,63 @@ std::vector Plater::get_extruder_colors_from_plater_config(const GC } } +namespace { + +// A gradient mixed filament fades between its two components over Z, so the UI shows it as a +// two-tone swatch rather than one blended colour. Resolve each slot to its from/to endpoint +// colours; non-gradient slots are left untouched. +struct MixedGradientSlot { + bool is_gradient = false; + std::string color_from; + std::string color_to; +}; + +std::vector parse_mixed_gradient_slots(const Slic3r::DynamicPrintConfig& config, size_t slot_count) +{ + std::vector result(slot_count); + const auto* is_mixed = config.option("filament_is_mixed"); + const auto* mixed_grad = config.option("filament_mixed_gradient"); + const auto* mixed_comp = config.option("filament_mixed_components"); + const auto* grad_range = config.option("filament_mixed_gradient_range"); + const auto* fil_colour = config.option("filament_colour"); + if (!is_mixed || !mixed_grad || !mixed_comp || !fil_colour) return result; + + for (size_t i = 0; i < slot_count && i < is_mixed->values.size(); ++i) { + if (!is_mixed->values[i]) continue; + if (i >= mixed_grad->values.size() || !mixed_grad->values[i]) continue; + if (i >= mixed_comp->values.size()) continue; + + std::vector comp_ids; + std::istringstream iss(mixed_comp->values[i]); + std::string tok; + while (std::getline(iss, tok, ',')) { + unsigned int v = 0; + if (std::sscanf(tok.c_str(), "%u", &v) == 1) + comp_ids.push_back(v); + } + if (comp_ids.size() != 2) continue; + + int direction = 0; + if (grad_range && i < grad_range->values.size()) { + CNumericLocalesSetter c_locale_setter; + float v0 = 0, v1 = 0; + if (std::sscanf(grad_range->values[i].c_str(), "%f,%f", &v0, &v1) == 2) + direction = (v0 > v1) ? 0 : 1; + } + + unsigned int from_id = (direction == 0) ? comp_ids[0] : comp_ids[1]; + unsigned int to_id = (direction == 0) ? comp_ids[1] : comp_ids[0]; + result[i].is_gradient = true; + result[i].color_from = (from_id >= 1 && from_id <= fil_colour->values.size()) + ? fil_colour->values[from_id - 1] : "#D9D9D9"; + result[i].color_to = (to_id >= 1 && to_id <= fil_colour->values.size()) + ? fil_colour->values[to_id - 1] : "#D9D9D9"; + } + return result; +} + +} // anonymous namespace + std::vector Plater::get_filament_colors_render_info() const { const Slic3r::DynamicPrintConfig* config = &wxGetApp().preset_bundle->project_config; @@ -18044,6 +20031,13 @@ std::vector Plater::get_filament_colors_render_info() const if (!config->has("filament_multi_colour")) return color_packs; color_packs = (config->option("filament_multi_colour"))->values; + + auto slots = parse_mixed_gradient_slots(*config, color_packs.size()); + for (size_t i = 0; i < color_packs.size(); ++i) { + if (slots[i].is_gradient) + color_packs[i] = slots[i].color_from + " " + slots[i].color_to; + } + return color_packs; } @@ -18054,9 +20048,51 @@ std::vector Plater::get_filament_color_render_type() const if (!config->has("filament_colour_type")) return ctype; ctype = (config->option("filament_colour_type"))->values; + + auto slots = parse_mixed_gradient_slots(*config, ctype.size()); + while (ctype.size() < slots.size()) ctype.push_back("1"); + for (size_t i = 0; i < ctype.size() && i < slots.size(); ++i) { + if (slots[i].is_gradient) + ctype[i] = "0"; + } + return ctype; } +const std::vector>& Plater::get_filament_gradient_ramps() const +{ + // Sampling a ramp walks the measured-blend recipe table once per step and the paint toolbar + // asks for the ramps every rendered frame, so they are cached against the config values they + // are built from. The cache is static rather than a Plater member because the extruder icons + // ask for the ramps from MenuFactory::init(), which runs while this Plater is still inside its + // own constructor, so wxGetApp().plater_ is not assigned yet. + static std::string s_ramps_key; + static std::vector> s_ramps; + + static const char* ramp_keys[] = {"filament_is_mixed", "filament_mixed_gradient", + "filament_mixed_components", "filament_colour", + "filament_mixed_gradient_range", "filament_mixed_gradient_curve"}; + + const Slic3r::DynamicPrintConfig& config = wxGetApp().preset_bundle->project_config; + std::string key; + for (const char* opt_key : ramp_keys) + if (const ConfigOption* opt = config.option(opt_key)) + key += opt->serialize() + '\n'; + if (key == s_ramps_key) + return s_ramps; + + // 64 bands outresolve every swatch drawn from this, all of which resample it down to their + // own height, so one cached resolution serves the icons and both ImGui filament bars. + const auto* colour_opt = config.option("filament_colour"); + const size_t n = colour_opt ? colour_opt->values.size() : 0; + s_ramps.assign(n, {}); + for (size_t i = 0; i < n; ++i) + s_ramps[i] = mixed_gradient_ramp(config, i, 64); + s_ramps_key = std::move(key); + + return s_ramps; +} + /* Get vector of colors used for rendering of a Preview scene in "Color print" mode * It consists of extruder colors and colors, saved in model.custom_gcode_per_print_z */ @@ -19652,7 +21688,7 @@ void Plater::show_object_info() auto mesh_errors = p->sidebar->obj_list()->get_mesh_errors_info(&info_manifold, &non_manifold_edges); if (non_manifold_edges > 0) { - info_manifold += into_u8("\n" + _L("Tips:") + "\n" + _L("Use \"Fix Model\" to repair the mesh.")); + info_manifold += "\n" + _L("Tips:") + "\n" + _L("Use \"Fix Model\" to repair the mesh."); } info_manifold = "" + info_manifold + ""; diff --git a/src/slic3r/GUI/Plater.hpp b/src/slic3r/GUI/Plater.hpp index 6a42f61fd5..5308deec61 100644 --- a/src/slic3r/GUI/Plater.hpp +++ b/src/slic3r/GUI/Plater.hpp @@ -5,6 +5,7 @@ #include #include +#include #include // BBS #include @@ -86,6 +87,10 @@ using t_optgroups = std::vector >; class Plater; enum class ActionButtonType : int; +// Sentinel filament id meaning "use the slot the sidebar context menu was opened on" +// (Sidebar::priv::m_menu_filament_id) rather than an explicit index. +inline constexpr int kSidebarContextMenuFilamentId = -2; + #define EVT_PUBLISHING_START 1 #define EVT_PUBLISHING_STOP 2 @@ -188,7 +193,7 @@ public: void delete_filament(size_t filament_id = size_t(-1), int replace_filament_id = -1); // 0 base, -1 means default void change_filament(size_t from_id, size_t to_id); // 0 base void edit_filament(); - void add_custom_filament(wxColour new_col); + void add_custom_filament(wxColour new_col, const std::string& preset_name = std::string(), bool skip_preset_validation = false); bool is_new_project_in_gcode3mf(); // BBS void on_bed_type_change(BedType bed_type); @@ -262,6 +267,20 @@ public: std::vector& combos_filament(); void clear_combos_filament_badge(); void udpate_combos_filament_badge(); + + // Mixed-color filament sidebar section + void add_mixed_filament(); + void edit_mixed_filament(size_t idx); + void delete_mixed_filament_at(size_t idx); + void decompose_filament_color(int filament_idx); + void recalc_filament_scroll_sizes(); + void update_mixed_filament_list(); + bool has_broken_mixed_filament() const; + bool has_broken_mixed_filament(const PartPlate* plate) const; + void collect_physical_filament_info(std::vector& color_strs, + std::vector& names, + std::vector& types, + std::vector* config_indices = nullptr); Search::OptionsSearcher& get_searcher(); std::string& get_search_line(); void update_printer_thumbnail(); @@ -313,6 +332,11 @@ public: const SLAPrint& sla_print() const; SLAPrint& sla_print(); + // Helper: returns config indices where filament_is_mixed == true + std::vector mixed_filament_config_indices() const; + // Helper: returns config indices where filament_is_mixed == false + std::vector physical_filament_config_indices() const; + int new_project(bool skip_confirm = false, bool silent = false, const wxString& project_name = wxString()); // BBS: save & backup void load_project(wxString const & filename = "", wxString const & originfile = "-"); @@ -568,7 +592,7 @@ public: void on_filament_change(size_t filament_idx); void on_filament_count_change(size_t extruders_count); - void on_filaments_delete(size_t extruders_count, size_t filament_id, int replace_filament_id = -1); + void on_filaments_delete(size_t extruders_count, size_t filament_id, int replace_filament_id = -1, const std::vector& is_mixed_before_delete = {}); std::vector get_extruders_colors(); // BBS void on_bed_type_change(BedType bed_type); @@ -583,6 +607,12 @@ public: std::vector get_extruder_colors_from_plater_config(const GCodeProcessorResult* const result = nullptr) const; std::vector get_filament_colors_render_info() const; std::vector get_filament_color_render_type() const; + + // Per slot, the colours a gradient mixed filament actually prints, sampled bottom (index 0) + // to top, so the sidebar, the paint gizmo and the extruder icons draw the same fade the + // editor previews rather than a straight blend of two endpoints. A slot that is not a + // gradient mixed filament gets an empty ramp. Cached; recomputed when the config changes. + const std::vector>& get_filament_gradient_ramps() const; std::vector get_colors_for_color_print(const GCodeProcessorResult* const result = nullptr) const; void set_global_filament_map_mode(FilamentMapMode mode); diff --git a/src/slic3r/GUI/Preferences.cpp b/src/slic3r/GUI/Preferences.cpp index ca115b9773..8f3ac17f7c 100644 --- a/src/slic3r/GUI/Preferences.cpp +++ b/src/slic3r/GUI/Preferences.cpp @@ -79,7 +79,7 @@ public: Bind(wxEVT_LEFT_DOWN, &WikiLabel::OnLeftDown, this); } - void SetLabel(const wxString& label) + void SetLabel(const wxString& label) override { m_label = label; m_last_wrap_width = -1; // force re-wrap diff --git a/src/slic3r/GUI/PrintHostDialogs.hpp b/src/slic3r/GUI/PrintHostDialogs.hpp index 988d4c8171..6f55c0d953 100644 --- a/src/slic3r/GUI/PrintHostDialogs.hpp +++ b/src/slic3r/GUI/PrintHostDialogs.hpp @@ -163,7 +163,7 @@ public: BedType bedType() const { return m_BedType; } virtual void init() override; - virtual std::map extendedInfo() const + virtual std::map extendedInfo() const override { return {{"bedType", std::to_string(static_cast(m_BedType))}, {"timeLapse", std::to_string(m_timeLapse)}, @@ -200,7 +200,7 @@ public: PrintHost* printhost); virtual void init() override; - virtual std::map extendedInfo() const; + virtual std::map extendedInfo() const override; private: static constexpr const char* CONFIG_KEY_ENABLESELFTEST = "crealityprint_enable_self_test"; diff --git a/src/slic3r/GUI/Printer/PrinterFileSystem.cpp b/src/slic3r/GUI/Printer/PrinterFileSystem.cpp index 8ecbe87391..0b280fe923 100644 --- a/src/slic3r/GUI/Printer/PrinterFileSystem.cpp +++ b/src/slic3r/GUI/Printer/PrinterFileSystem.cpp @@ -662,10 +662,10 @@ PrinterFileSystem::File const &PrinterFileSystem::GetFile(size_t index, bool &se void PrinterFileSystem::Attached() { boost::unique_lock lock(m_mutex); - m_recv_thread = std::move(boost::thread([w = weak_from_this()] { + m_recv_thread = boost::thread([w = weak_from_this()] { boost::shared_ptr s = w.lock(); if (s) s->RecvMessageThread(); - })); + }); } void PrinterFileSystem::Start() diff --git a/src/slic3r/GUI/Printer/gstbambusrc.c b/src/slic3r/GUI/Printer/gstbambusrc.c deleted file mode 100644 index d3f4ab112a..0000000000 --- a/src/slic3r/GUI/Printer/gstbambusrc.c +++ /dev/null @@ -1,657 +0,0 @@ -/* bambusrc for gstreamer - * integration with proprietary Bambu Lab blob for getting raw h.264 video - * - * Copyright (C) 2023 Joshua Wise - * - * Permission is hereby granted, free of charge, to any person obtaining a - * copy of this software and associated documentation files (the "Software"), - * to deal in the Software without restriction, including without limitation - * the rights to use, copy, modify, merge, publish, distribute, sublicense, - * and/or sell copies of the Software, and to permit persons to whom the - * Software is furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER - * DEALINGS IN THE SOFTWARE. - * - * Alternatively, the contents of this file may be used under the - * GNU Lesser General Public License Version 2.1 (the "LGPL"), in - * which case the following provisions apply instead of the ones - * mentioned above: - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Library General Public - * License as published by the Free Software Foundation; either - * version 2 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Library General Public License for more details. - * - * You should have received a copy of the GNU Library General Public - * License along with this library; if not, write to the - * Free Software Foundation, Inc., 59 Temple Place - Suite 330, - * Boston, MA 02111-1307, USA. - */ - -#ifdef HAVE_CONFIG_H -# include -#endif - -#include - -#include "gstbambusrc.h" - -#include -#include -#ifndef EXTERNAL_GST_PLUGIN -#define BAMBU_DYNAMIC -#endif -#include "BambuTunnel.h" - -#ifdef BAMBU_DYNAMIC -// From PrinterFileSystem. -#ifdef __cplusplus -extern "C" -#else -extern -#endif -BambuLib *bambulib_get(); -BambuLib *_lib = NULL; -#define BAMBULIB(x) (_lib->x) - -#else -#define BAMBULIB(x) (x) -#endif - -GST_DEBUG_CATEGORY_STATIC (gst_bambusrc_debug); -#define GST_CAT_DEFAULT gst_bambusrc_debug - -static GstStaticPadTemplate srctemplate = GST_STATIC_PAD_TEMPLATE ("src", - GST_PAD_SRC, - GST_PAD_ALWAYS, - GST_STATIC_CAPS_ANY); - //GST_STATIC_CAPS("video/x-h264,framerate=0/1,parsed=(boolean)false,stream-format=(string)byte-stream")); - -enum -{ - PROP_0, - PROP_LOCATION, -}; - -static void gst_bambusrc_uri_handler_init (gpointer g_iface, - gpointer iface_data); -static void gst_bambusrc_finalize (GObject * gobject); -static void gst_bambusrc_dispose (GObject * gobject); - -static void gst_bambusrc_set_property (GObject * object, guint prop_id, - const GValue * value, GParamSpec * pspec); -static void gst_bambusrc_get_property (GObject * object, guint prop_id, - GValue * value, GParamSpec * pspec); - -static GstStateChangeReturn gst_bambusrc_change_state (GstElement * - element, GstStateChange transition); -static GstFlowReturn gst_bambusrc_create (GstPushSrc * psrc, - GstBuffer ** outbuf); -static gboolean gst_bambusrc_start (GstBaseSrc * bsrc); -static gboolean gst_bambusrc_stop (GstBaseSrc * bsrc); -static gboolean gst_bambusrc_is_seekable (GstBaseSrc * bsrc); -static gboolean gst_bambusrc_query (GstBaseSrc * bsrc, GstQuery * query); -static gboolean gst_bambusrc_unlock (GstBaseSrc * bsrc); -static gboolean gst_bambusrc_unlock_stop (GstBaseSrc * bsrc); -static gboolean gst_bambusrc_set_location (GstBambuSrc * src, - const gchar * uri, GError ** error); - -#define gst_bambusrc_parent_class parent_class -G_DEFINE_TYPE_WITH_CODE (GstBambuSrc, gst_bambusrc, GST_TYPE_PUSH_SRC, - G_IMPLEMENT_INTERFACE (GST_TYPE_URI_HANDLER, - gst_bambusrc_uri_handler_init)); - -static void -gst_bambusrc_class_init (GstBambuSrcClass * klass) -{ - GObjectClass *gobject_class; - GstElementClass *gstelement_class; - GstBaseSrcClass *gstbasesrc_class; - GstPushSrcClass *gstpushsrc_class; - - gobject_class = (GObjectClass *) klass; - gstelement_class = (GstElementClass *) klass; - gstbasesrc_class = (GstBaseSrcClass *) klass; - gstpushsrc_class = (GstPushSrcClass *) klass; - - gobject_class->set_property = gst_bambusrc_set_property; - gobject_class->get_property = gst_bambusrc_get_property; - gobject_class->finalize = gst_bambusrc_finalize; - gobject_class->dispose = gst_bambusrc_dispose; - - g_object_class_install_property (gobject_class, - PROP_LOCATION, - g_param_spec_string ("location", "Location", - "URI to pass to Bambu Lab blobs", "", - (GParamFlags)(G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS))); - - gst_element_class_add_static_pad_template (gstelement_class, &srctemplate); - - gst_element_class_set_static_metadata (gstelement_class, "Bambu Lab source", - "Source/Network", - "Receive data as a client over the network using the proprietary Bambu Lab blobs", - "Joshua Wise "); - gstelement_class->change_state = - GST_DEBUG_FUNCPTR (gst_bambusrc_change_state); - - gstbasesrc_class->start = GST_DEBUG_FUNCPTR (gst_bambusrc_start); - gstbasesrc_class->stop = GST_DEBUG_FUNCPTR (gst_bambusrc_stop); - gstbasesrc_class->unlock = GST_DEBUG_FUNCPTR (gst_bambusrc_unlock); - gstbasesrc_class->unlock_stop = - GST_DEBUG_FUNCPTR (gst_bambusrc_unlock_stop); - gstbasesrc_class->is_seekable = - GST_DEBUG_FUNCPTR (gst_bambusrc_is_seekable); - gstbasesrc_class->query = GST_DEBUG_FUNCPTR (gst_bambusrc_query); - - gstpushsrc_class->create = GST_DEBUG_FUNCPTR (gst_bambusrc_create); - - GST_DEBUG_CATEGORY_INIT (gst_bambusrc_debug, "bambusrc", 0, - "Bambu Lab src"); -} - -static void -gst_bambusrc_reset (GstBambuSrc * src) -{ - gst_caps_replace (&src->src_caps, NULL); - if (src->tnl) { - BAMBULIB(Bambu_Close)(src->tnl); - BAMBULIB(Bambu_Destroy)(src->tnl); - src->tnl = NULL; - } -} - -static void -gst_bambusrc_init (GstBambuSrc * src) -{ - src->location = NULL; - src->tnl = NULL; - - gst_base_src_set_automatic_eos (GST_BASE_SRC (src), FALSE); - gst_base_src_set_live(GST_BASE_SRC(src), TRUE); - - gst_bambusrc_reset (src); -} - -static void -gst_bambusrc_dispose (GObject * gobject) -{ - GstBambuSrc *src = GST_BAMBUSRC (gobject); - - GST_DEBUG_OBJECT (src, "dispose"); - - G_OBJECT_CLASS (parent_class)->dispose (gobject); -} - -static void -gst_bambusrc_finalize (GObject * gobject) -{ - GstBambuSrc *src = GST_BAMBUSRC (gobject); - - GST_DEBUG_OBJECT (src, "finalize"); - - g_free (src->location); - if (src->tnl) { - BAMBULIB(Bambu_Close)(src->tnl); - BAMBULIB(Bambu_Destroy)(src->tnl); - } - - G_OBJECT_CLASS (parent_class)->finalize (gobject); -} - -static void -gst_bambusrc_set_property (GObject * object, guint prop_id, - const GValue * value, GParamSpec * pspec) -{ - GstBambuSrc *src = GST_BAMBUSRC (object); - - switch (prop_id) { - case PROP_LOCATION: - { - const gchar *location; - - location = g_value_get_string (value); - - if (location == NULL) { - GST_WARNING ("location property cannot be NULL"); - goto done; - } - if (!gst_bambusrc_set_location (src, location, NULL)) { - GST_WARNING ("badly formatted location"); - goto done; - } - break; - } - default: - G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); - break; - } -done: - return; -} - -static void -gst_bambusrc_get_property (GObject * object, guint prop_id, - GValue * value, GParamSpec * pspec) -{ - GstBambuSrc *src = GST_BAMBUSRC (object); - - switch (prop_id) { - case PROP_LOCATION: - g_value_set_string (value, src->location); - break; - default: - G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); - break; - } -} - -int gst_bambu_last_error = 0; - -static GstFlowReturn -gst_bambusrc_create (GstPushSrc * psrc, GstBuffer ** outbuf) -{ - GstBambuSrc *src; - - src = GST_BAMBUSRC (psrc); - - (void) src; - GST_DEBUG_OBJECT (src, "create()"); - - int rv; - Bambu_Sample sample; - - if (!src->tnl) { - return GST_FLOW_ERROR; - } - - while ((rv = BAMBULIB(Bambu_ReadSample)(src->tnl, &sample)) == Bambu_would_block) { - GST_DEBUG_OBJECT(src, "create would block"); - usleep(33333); /* 30Hz */ - } - - if (rv == Bambu_stream_end) { - return GST_FLOW_EOS; - } - - if (rv != Bambu_success) { - gst_bambu_last_error = rv; - return GST_FLOW_ERROR; - } - -#if GLIB_CHECK_VERSION(2,68,0) - gpointer sbuf = g_memdup2(sample.buffer, sample.size); -#else - gpointer sbuf = g_memdup(sample.buffer, sample.size); -#endif - *outbuf = gst_buffer_new_wrapped_full(0, sbuf, sample.size, 0, sample.size, sbuf, g_free); - - /* Synthesize monotonic timestamps at the announced frame rate, anchored - * to the first frame's arrival time. The X1C's RTSPS server emits - * unreliable decode timestamps (wildly non-monotonic jumps, or sometimes - * none at all); forwarding them directly froze the pipeline after a few - * seconds. Pacing on a synthesized clock — the same trick mpv uses when - * it reports "No video PTS! Making something up." — gives smooth - * playback regardless of network jitter, and only drops late frames if - * the printer can't keep up. A snap-back resets the anchor if real - * arrival drifts more than two frame periods from the synthesized - * timeline (e.g. announced framerate was wrong). - */ - GstClock *clock = GST_ELEMENT_CLOCK(psrc); - GstClockTime base_time = gst_element_get_base_time((GstElement *)psrc); - GstClockTime running_now = GST_CLOCK_TIME_NONE; - if (clock) { - GstClockTime now = gst_clock_get_time(clock); - if (now != GST_CLOCK_TIME_NONE && now >= base_time) - running_now = now - base_time; - } - - /* Adapt the period to actual inter-arrival time via EWMA. The announced - * frame_rate is unreliable on Bambu printers (X1C announces 30 but - * delivers ~28), so trusting it causes the synthesized timeline to drift - * relative to real time, which makes the sink consider frames late and - * skip pacing entirely. Measuring the real rate keeps PTS in step with - * arrival on average, so the sink can pace inside bursts while still - * tracking the printer's actual frame cadence. - */ - if (src->avg_period == 0) { - int fps = src->frame_rate > 0 ? src->frame_rate : 30; - src->avg_period = GST_SECOND / fps; - } - if (running_now != GST_CLOCK_TIME_NONE && src->last_arrival != 0) { - GstClockTimeDiff delta = GST_CLOCK_DIFF(src->last_arrival, running_now); - /* clamp to plausible video frame periods (5..200 ms) so a one-off - * burst-of-zero or long stall doesn't poison the average */ - if (delta > 5 * GST_MSECOND && delta < 200 * GST_MSECOND) { - src->avg_period = (src->avg_period * 15 + (GstClockTime)delta) / 16; - } - } - src->last_arrival = (running_now != GST_CLOCK_TIME_NONE) ? running_now : src->last_arrival; - GstClockTime period = src->avg_period; - - /* Lead time: schedule frames a few periods in the future of their - * arrival, so the sink has a small jitter buffer. Without this, frames - * arriving slightly later than expected land behind the running clock - * and the sink renders them immediately, producing visible stutter. - * 100ms is invisible for a live print-monitor view. - */ - const GstClockTime LEAD = 100 * GST_MSECOND; - - if (!src->sttime) { - src->sttime = (running_now != GST_CLOCK_TIME_NONE) ? running_now + LEAD : LEAD; - src->frame_count = 0; - } - - GstClockTime pts = src->sttime + src->frame_count * period; - - /* Safety net: with the lead applied, expected drift is roughly -LEAD - * (pts sits LEAD ns ahead of running_now). Re-anchor only if the - * synthesized timeline diverges from that expectation by several frame - * periods, which indicates a real disturbance (printer paused, stream - * resumed, large fps change) rather than ordinary jitter. - */ - if (running_now != GST_CLOCK_TIME_NONE) { - GstClockTimeDiff drift = GST_CLOCK_DIFF(pts, running_now); - GstClockTimeDiff expected = -(GstClockTimeDiff)LEAD; - GstClockTimeDiff slack = (GstClockTimeDiff)(4 * period); - if (drift > expected + slack || drift < expected - slack) { - GST_DEBUG_OBJECT(src, "ts drift %" G_GINT64_FORMAT " ns; re-anchoring", drift); - src->sttime = running_now + LEAD; - src->frame_count = 0; - pts = src->sttime; - } - } - - GST_BUFFER_PTS(*outbuf) = pts; - GST_BUFFER_DTS(*outbuf) = pts; - GST_BUFFER_DURATION(*outbuf) = period; - src->frame_count++; - GST_DEBUG_OBJECT(src, - "sttime:%lu, DTS:%lu, PTS: %lu~", - src->sttime, GST_BUFFER_DTS(*outbuf), GST_BUFFER_PTS(*outbuf)); - - return GST_FLOW_OK; -} - -static void _log(void *ctx, int lvl, const char *msg) { - GstBambuSrc *src = (GstBambuSrc *) ctx; - GST_DEBUG_OBJECT(src, "bambu: %s", msg); - BAMBULIB(Bambu_FreeLogMsg)(msg); -} - -static gboolean -gst_bambusrc_start (GstBaseSrc * bsrc) -{ - GstBambuSrc *src = GST_BAMBUSRC (bsrc); - - GST_DEBUG_OBJECT (src, "start(\"%s\")", src->location); - - if (src->tnl) { - BAMBULIB(Bambu_Close)(src->tnl); - BAMBULIB(Bambu_Destroy)(src->tnl); - src->tnl = NULL; - } - -#ifdef BAMBU_DYNAMIC - if (!_lib) { - _lib = bambulib_get(); - if (!_lib->Bambu_Open) { - return FALSE; - } - } -#endif - if (BAMBULIB(Bambu_Create)(&src->tnl, src->location) != Bambu_success) { - return FALSE; - } - - int rv = 0; - BAMBULIB(Bambu_SetLogger)(src->tnl, _log, (void *)src); - if ((rv = BAMBULIB(Bambu_Open)(src->tnl)) != Bambu_success) { - BAMBULIB(Bambu_Destroy)(src->tnl); - src->tnl = NULL; - gst_bambu_last_error = rv; - return FALSE; - } - - int n = 0; - while ((rv = BAMBULIB(Bambu_StartStream)(src->tnl, 1 /* video */)) == Bambu_would_block) { - usleep(100000); - } - if (rv != Bambu_success) { - BAMBULIB(Bambu_Close)(src->tnl); - BAMBULIB(Bambu_Destroy)(src->tnl); - src->tnl = NULL; - gst_bambu_last_error = rv; - return FALSE; - } - - src->video_type = AVC1; - n = BAMBULIB(Bambu_GetStreamCount)(src->tnl); - GST_INFO_OBJECT (src, "Bambu_GetStreamCount returned stream count=%d",n); - for (int i = 0; i < n; ++i) { - Bambu_StreamInfo info; - BAMBULIB(Bambu_GetStreamInfo)(src->tnl, i, &info); - - GST_INFO_OBJECT (src, "stream %d type=%d, sub_type=%d", i, info.type, info.sub_type); - if (info.type == VIDE) { - src->video_type = info.sub_type; - src->frame_rate = info.format.video.frame_rate; - GST_INFO_OBJECT (src, " width %d height=%d, frame_rate=%d", - info.format.video.width, info.format.video.height, info.format.video.frame_rate); - } - } - - src->sttime = 0; - src->frame_count = 0; - src->last_arrival = 0; - src->avg_period = 0; - return TRUE; -} - -static gboolean -gst_bambusrc_stop (GstBaseSrc * bsrc) -{ - GstBambuSrc *src; - - src = GST_BAMBUSRC (bsrc); - GST_DEBUG_OBJECT (src, "stop()"); - if (src->tnl) { - BAMBULIB(Bambu_Close)(src->tnl); - BAMBULIB(Bambu_Destroy)(src->tnl); - src->tnl = NULL; - } - - return TRUE; -} - -static GstStateChangeReturn -gst_bambusrc_change_state (GstElement * element, GstStateChange transition) -{ - GstStateChangeReturn ret; - GstBambuSrc *src; - - src = GST_BAMBUSRC (element); - - (void) src; - - switch (transition) { - case GST_STATE_CHANGE_READY_TO_NULL: - //gst_bambusrc_session_close (src); - break; - default: - break; - } - - ret = GST_ELEMENT_CLASS (parent_class)->change_state (element, transition); - - return ret; -} - -/* Interrupt a blocking request. */ -static gboolean -gst_bambusrc_unlock (GstBaseSrc * bsrc) -{ - GstBambuSrc *src; - - src = GST_BAMBUSRC (bsrc); - GST_DEBUG_OBJECT (src, "unlock()"); - - return TRUE; -} - -/* Interrupt interrupt. */ -static gboolean -gst_bambusrc_unlock_stop (GstBaseSrc * bsrc) -{ - GstBambuSrc *src; - - src = GST_BAMBUSRC (bsrc); - GST_DEBUG_OBJECT (src, "unlock_stop()"); - - return TRUE; -} - -static gboolean -gst_bambusrc_is_seekable (GstBaseSrc * bsrc) -{ - GstBambuSrc *src = GST_BAMBUSRC (bsrc); - - (void) src; - - return FALSE; -} - -static gboolean -gst_bambusrc_query (GstBaseSrc * bsrc, GstQuery * query) -{ - GstBambuSrc *src = GST_BAMBUSRC (bsrc); - gboolean ret; - GstSchedulingFlags flags; - gint minsize, maxsize, align; - - switch (GST_QUERY_TYPE (query)) { - case GST_QUERY_URI: - gst_query_set_uri (query, src->location); - ret = TRUE; - break; - default: - ret = FALSE; - break; - } - - if (!ret) - ret = GST_BASE_SRC_CLASS (parent_class)->query (bsrc, query); - - switch (GST_QUERY_TYPE (query)) { - case GST_QUERY_SCHEDULING: - gst_query_parse_scheduling (query, &flags, &minsize, &maxsize, &align); - flags = (GstSchedulingFlags)((int)flags | (int)GST_SCHEDULING_FLAG_SEQUENTIAL); - gst_query_set_scheduling (query, flags, minsize, maxsize, align); - break; - default: - break; - } - - return ret; -} - -static gboolean -gst_bambusrc_set_location (GstBambuSrc * src, const gchar * uri, - GError ** error) -{ - if (src->location) { - g_free (src->location); - src->location = NULL; - } - - if (uri == NULL) - return FALSE; - - src->location = g_strdup (uri); - - return TRUE; -} - -static GstURIType -gst_bambusrc_uri_get_type (GType type) -{ - return GST_URI_SRC; -} - -static const gchar *const * -gst_bambusrc_uri_get_protocols (GType type) -{ - static const gchar *protocols[] = { "bambu", NULL }; - - return protocols; -} - -static gchar * -gst_bambusrc_uri_get_uri (GstURIHandler * handler) -{ - GstBambuSrc *src = GST_BAMBUSRC (handler); - - /* FIXME: make thread-safe */ - return g_strdup (src->location); -} - -static gboolean -gst_bambusrc_uri_set_uri (GstURIHandler * handler, const gchar * uri, - GError ** error) -{ - GstBambuSrc *src = GST_BAMBUSRC (handler); - - return gst_bambusrc_set_location (src, uri, error); -} - -static void -gst_bambusrc_uri_handler_init (gpointer g_iface, gpointer iface_data) -{ - GstURIHandlerInterface *iface = (GstURIHandlerInterface *) g_iface; - - iface->get_type = gst_bambusrc_uri_get_type; - iface->get_protocols = gst_bambusrc_uri_get_protocols; - iface->get_uri = gst_bambusrc_uri_get_uri; - iface->set_uri = gst_bambusrc_uri_set_uri; -} - -static gboolean gstbambusrc_init(GstPlugin *plugin) -{ - return gst_element_register(plugin, "bambusrc", GST_RANK_PRIMARY, GST_TYPE_BAMBUSRC); -} - -#ifndef EXTERNAL_GST_PLUGIN - -// for use inside of Bambu Slicer -void gstbambusrc_register() -{ - static int did_register = 0; - if (did_register) - return; - did_register = 1; - - gst_plugin_register_static(GST_VERSION_MAJOR, GST_VERSION_MINOR, "bambusrc", "Bambu Lab source", gstbambusrc_init, "0.0.1", "GPL", "BambuStudio", "BambuStudio", "https://github.com/bambulab/BambuStudio"); -} - -#else - -#ifndef PACKAGE -#define PACKAGE "bambusrc" -#endif - -GST_PLUGIN_DEFINE (GST_VERSION_MAJOR, GST_VERSION_MINOR, bambusrc, "Bambu Lab source", gstbambusrc_init, "0.0.1", "GPL", "BambuStudio", "https://github.com/bambulab/BambuStudio") - -#endif diff --git a/src/slic3r/GUI/Printer/gstbambusrc.h b/src/slic3r/GUI/Printer/gstbambusrc.h deleted file mode 100644 index d5c022a40e..0000000000 --- a/src/slic3r/GUI/Printer/gstbambusrc.h +++ /dev/null @@ -1,78 +0,0 @@ -/* bambusrc for gstreamer - * integration with proprietary Bambu Lab blob for getting raw h.264 video - * - * Copyright (C) 2023 Joshua Wise - * - * Permission is hereby granted, free of charge, to any person obtaining a - * copy of this software and associated documentation files (the "Software"), - * to deal in the Software without restriction, including without limitation - * the rights to use, copy, modify, merge, publish, distribute, sublicense, - * and/or sell copies of the Software, and to permit persons to whom the - * Software is furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER - * DEALINGS IN THE SOFTWARE. - * - * Alternatively, the contents of this file may be used under the - * GNU Lesser General Public License Version 2.1 (the "LGPL"), in - * which case the following provisions apply instead of the ones - * mentioned above: - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Library General Public - * License as published by the Free Software Foundation; either - * version 2 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Library General Public License for more details. - * - * You should have received a copy of the GNU Library General Public - * License along with this library; if not, write to the - * Free Software Foundation, Inc., 59 Temple Place - Suite 330, - * Boston, MA 02111-1307, USA. - */ - -#ifndef __GST_BAMBUSRC_H__ -#define __GST_BAMBUSRC_H__ - -#include -#include -#include - -G_BEGIN_DECLS - -#define GST_TYPE_BAMBUSRC (gst_bambusrc_get_type()) -G_DECLARE_FINAL_TYPE (GstBambuSrc, gst_bambusrc, - GST, BAMBUSRC, GstPushSrc) - -typedef void *Bambu_Tunnel; - -struct _GstBambuSrc -{ - GstPushSrc element; - GstCaps *src_caps; - gchar *location; - Bambu_Tunnel tnl; - GstClockTime sttime; - int video_type; - int frame_rate; - guint64 frame_count; - GstClockTime last_arrival; - GstClockTime avg_period; -}; - -extern void gstbambusrc_register(); - -G_END_DECLS - -#endif /* __GST_BAMBUSRC_H__ */ diff --git a/src/slic3r/GUI/Search.cpp b/src/slic3r/GUI/Search.cpp index 1d5cf0b9e5..f8fc51ed02 100644 --- a/src/slic3r/GUI/Search.cpp +++ b/src/slic3r/GUI/Search.cpp @@ -681,7 +681,7 @@ SearchDialog::SearchDialog(OptionsSearcher *searcher, Preset::Type type, wxWindo SearchDialog::~SearchDialog() {} -void SearchDialog::Popup(wxPoint position /*= wxDefaultPosition*/) +void SearchDialog::Popup(wxWindow *focus /*= nullptr*/) { /* const std::string& line = searcher->search_string(); search_line->SetValue(line.empty() ? default_string : from_u8(line)); @@ -696,17 +696,19 @@ void SearchDialog::Popup(wxPoint position /*= wxDefaultPosition*/) search_line2->SetValue(wxString("")); //const std::string &line = searcher->search_string(); //searcher->search(into_u8(line), true); - PopupWindow::Popup(); + PopupWindow::Popup(focus); search_line2->SetFocus(); update_list(); } +#ifdef __WXMSW__ void SearchDialog::MSWDismissUnfocusedPopup() { Dismiss(); OnDismiss(); } +#endif // __WXMSW__ void SearchDialog::OnDismiss() { } @@ -926,7 +928,7 @@ SearchObjectDialog::SearchObjectDialog(GUI::ObjectList* object_list, wxWindow* p SearchObjectDialog::~SearchObjectDialog() {} -void SearchObjectDialog::Popup(wxPoint position /*= wxDefaultPosition*/) +void SearchObjectDialog::Popup(wxWindow *focus /*= nullptr*/) { if (m_is_dismissing || this->IsShown()) { return; @@ -937,7 +939,7 @@ void SearchObjectDialog::Popup(wxPoint position /*= wxDefaultPosition*/) // dropdown list, otherwise the text input won't be usable m_object_list->SetFocus(); #endif - PopupWindow::Popup(); + PopupWindow::Popup(focus); search_line2->SetFocus(); m_object_list->assembly_plate_object_name(); @@ -945,11 +947,13 @@ void SearchObjectDialog::Popup(wxPoint position /*= wxDefaultPosition*/) update_list(); } +#ifdef __WXMSW__ void SearchObjectDialog::MSWDismissUnfocusedPopup() { Dismiss(); OnDismiss(); } +#endif // __WXMSW__ void SearchObjectDialog::OnDismiss() {} diff --git a/src/slic3r/GUI/Search.hpp b/src/slic3r/GUI/Search.hpp index bdb4da83c4..4ae43dbca0 100644 --- a/src/slic3r/GUI/Search.hpp +++ b/src/slic3r/GUI/Search.hpp @@ -216,10 +216,12 @@ public: SearchDialog(OptionsSearcher *searcher, Preset::Type type, wxWindow *parent, TextInput *input, wxWindow *search_btn); ~SearchDialog(); - void MSWDismissUnfocusedPopup(); - void Popup(wxPoint position = wxDefaultPosition); - void OnDismiss(); - void Dismiss(); +#ifdef __WXMSW__ + void MSWDismissUnfocusedPopup() override; +#endif // __WXMSW__ + void Popup(wxWindow *focus = nullptr) override; + void OnDismiss() override; + void Dismiss() override; void Die(); void msw_rescale(); @@ -260,10 +262,12 @@ public: SearchObjectDialog(GUI::ObjectList* object_list, wxWindow* parent, TextInput* input); ~SearchObjectDialog(); - void MSWDismissUnfocusedPopup(); - void Popup(wxPoint position = wxDefaultPosition); - void OnDismiss(); - void Dismiss(); +#ifdef __WXMSW__ + void MSWDismissUnfocusedPopup() override; +#endif // __WXMSW__ + void Popup(wxWindow *focus = nullptr) override; + void OnDismiss() override; + void Dismiss() override; void Die(); void OnInputText(wxCommandEvent& event); diff --git a/src/slic3r/GUI/SelectMachine.cpp b/src/slic3r/GUI/SelectMachine.cpp index d18435d1a9..9634b76655 100644 --- a/src/slic3r/GUI/SelectMachine.cpp +++ b/src/slic3r/GUI/SelectMachine.cpp @@ -4,6 +4,7 @@ #include "libslic3r/Utils.hpp" #include "libslic3r/Thread.hpp" #include "libslic3r/Color.hpp" +#include "FilamentBitmapUtils.hpp" #include "GUI.hpp" #include "GUI_App.hpp" #include "GUI_Preview.hpp" @@ -482,7 +483,7 @@ SelectMachineDialog::SelectMachineDialog(Plater *plater) m_link_edit_nozzle->Bind(wxEVT_LEFT_DOWN, [this](auto &e) { - if (this && this->m_is_in_sending_mode) { + if (m_is_in_sending_mode) { return; } @@ -2846,7 +2847,7 @@ void SelectMachineDialog::on_ok_btn(wxCommandEvent &event) }); // STUDIO-9580 - /* use warning color if there are warning and normal messages* / + /* use warning color if there are warning and normal messages*/ /* use indexes if there are several messages*/ /* add header and ending if there are several messages or has none block warnings*/ if (confirm_text.size() > 1 || !is_printing_block) @@ -5720,10 +5721,15 @@ void SelectMachineDialog::clone_thumbnail_data() { m_preview_colors_in_thumbnail.resize(m_materialList.size()); } while (iter != m_materialList.end()) { - int id = iter->first; Material * item = iter->second; MaterialItem *m = item->item; - m_preview_colors_in_thumbnail[id] = m->m_material_coloul; + // Orca: key the preview colours by filament slot, as m_cur_colors_in_thumbnail and + // SyncAmsInfoDialog already do, so recompute_mixed_slot_colors() below can look a mixed + // slot's component colours up by id (BBS keys this array by list position). + if (item->id >= m_preview_colors_in_thumbnail.size()) { + m_preview_colors_in_thumbnail.resize(item->id + 1); + } + m_preview_colors_in_thumbnail[item->id] = m->m_material_coloul; if (item->id < m_cur_colors_in_thumbnail.size()) { m_cur_colors_in_thumbnail[item->id] = m->m_ams_coloul; } @@ -5733,6 +5739,20 @@ void SelectMachineDialog::clone_thumbnail_data() { } iter++; } + + // Expand color arrays to cover mixed (virtual) slots and compute their blended colors + const auto& cfg = wxGetApp().preset_bundle->project_config; + size_t total = 0; + if (auto* opt = cfg.option("filament_is_mixed")) + total = opt->values.size(); + size_t target = std::max(total, m_cur_colors_in_thumbnail.size()); + if (m_cur_colors_in_thumbnail.size() < target) + m_cur_colors_in_thumbnail.resize(target); + if (m_preview_colors_in_thumbnail.size() < target) + m_preview_colors_in_thumbnail.resize(target); + recompute_mixed_slot_colors(m_preview_colors_in_thumbnail, cfg); + recompute_mixed_slot_colors(m_cur_colors_in_thumbnail, cfg); + //copy data auto &data = m_cur_input_thumbnail_data; m_preview_thumbnail_data.reset(); @@ -5907,6 +5927,10 @@ void SelectMachineDialog::change_default_normal(int old_filament_id, wxColour te return; } } + // Recompute mixed slot colors after physical slot color change + const auto& cfg = wxGetApp().preset_bundle->project_config; + recompute_mixed_slot_colors(m_cur_colors_in_thumbnail, cfg); + ThumbnailData& data = m_cur_input_thumbnail_data; ThumbnailData& no_light_data = m_cur_no_light_thumbnail_data; if (data.width > 0 && data.height > 0 && data.width == no_light_data.width && data.height == no_light_data.height) { diff --git a/src/slic3r/GUI/SelectMachine.hpp b/src/slic3r/GUI/SelectMachine.hpp index fd326f7c85..46d6adf4f4 100644 --- a/src/slic3r/GUI/SelectMachine.hpp +++ b/src/slic3r/GUI/SelectMachine.hpp @@ -522,7 +522,7 @@ public: bool is_timeout(); int update_print_required_data(Slic3r::DynamicPrintConfig config, Slic3r::Model model, Slic3r::PlateDataPtrs plate_data_list, std::string file_name, std::string file_path); void set_print_type(PrintFromType type) {m_print_type = type;}; - bool Show(bool show); + bool Show(bool show) override; void show_init(); bool do_ams_mapping(MachineObject *obj_,bool use_ams); bool get_ams_mapping_result(std::string& mapping_array_str, std::string& mapping_array_str2, std::string& ams_mapping_info) const; diff --git a/src/slic3r/GUI/SendToPrinter.cpp b/src/slic3r/GUI/SendToPrinter.cpp index fa156456e5..a350bd19ea 100644 --- a/src/slic3r/GUI/SendToPrinter.cpp +++ b/src/slic3r/GUI/SendToPrinter.cpp @@ -994,18 +994,16 @@ void SendToPrinterDialog::on_ok(wxCommandEvent &event) m_send_job->on_check_ip_address_fail([this, token = std::weak_ptr(m_token)](int result) { CallAfter([token, this] { if (token.expired()) { return; } - if (this) { - SendFailedConfirm sfcDlg; - auto res = sfcDlg.ShowModal(); - m_status_bar->cancel(); + SendFailedConfirm sfcDlg; + auto res = sfcDlg.ShowModal(); + m_status_bar->cancel(); - if (res == wxYES) { - wxQueueEvent(m_button_ensure, new wxCommandEvent(wxEVT_BUTTON)); - } else if (res == wxAPPLY) { - wxCommandEvent *evt = new wxCommandEvent(EVT_CLEAR_IPADDRESS); - wxQueueEvent(this, evt); - wxGetApp().show_ip_address_enter_dialog(); - } + if (res == wxYES) { + wxQueueEvent(m_button_ensure, new wxCommandEvent(wxEVT_BUTTON)); + } else if (res == wxAPPLY) { + wxCommandEvent *evt = new wxCommandEvent(EVT_CLEAR_IPADDRESS); + wxQueueEvent(this, evt); + wxGetApp().show_ip_address_enter_dialog(); } }); }); diff --git a/src/slic3r/GUI/SendToPrinter.hpp b/src/slic3r/GUI/SendToPrinter.hpp index 14493a1f20..87948b28c3 100644 --- a/src/slic3r/GUI/SendToPrinter.hpp +++ b/src/slic3r/GUI/SendToPrinter.hpp @@ -180,7 +180,7 @@ public: SendToPrinterDialog(Plater *plater = nullptr); ~SendToPrinterDialog(); - bool Show(bool show); + bool Show(bool show) override; bool is_timeout(); void on_rename_click(wxCommandEvent& event); void on_rename_enter(); diff --git a/src/slic3r/GUI/StatusPanel.cpp b/src/slic3r/GUI/StatusPanel.cpp index a6de523139..b00dee3dca 100644 --- a/src/slic3r/GUI/StatusPanel.cpp +++ b/src/slic3r/GUI/StatusPanel.cpp @@ -984,7 +984,7 @@ void PrintingTaskPanel::paint(wxPaintEvent&) dc.DrawBitmap(m_thumbnail_bmp_display, wxPoint(0, 0)); } dc.SetFont(Label::Body_12); - + if (m_plate_index >= 0) { wxString plate_id_str = wxString::Format("%d", m_plate_index); dc.DrawText(plate_id_str, wxPoint(4, 4)); @@ -1272,7 +1272,7 @@ void PrintingTaskPanel::set_plate_index(int plate_idx) } void PrintingTaskPanel::market_scoring_show() -{ +{ m_score_staticline->Show(); m_score_subtask_info->Show(); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " show market scoring page"; @@ -1403,7 +1403,7 @@ void StatusBasePanel::init_bitmaps() m_bitmap_fan_off = ScalableBitmap(this, "monitor_fan_off", 22); m_bitmap_speed = ScalableBitmap(this, "monitor_speed", 24); m_bitmap_speed_active = ScalableBitmap(this, "monitor_speed_active", 24); - + m_thumbnail_brokenimg = ScalableBitmap(this, "monitor_brokenimg", 120); m_thumbnail_sdcard = ScalableBitmap(this, "monitor_sdcard_thumbnail", 120); //m_bitmap_camera = create_scaled_bitmap("monitor_camera", nullptr, 18); @@ -1530,7 +1530,7 @@ wxBoxSizer *StatusBasePanel::create_monitoring_page() // media_ctrl_panel = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxDefaultSize); // media_ctrl_panel->SetBackgroundColour(*wxBLACK); // wxBoxSizer *bSizer_monitoring = new wxBoxSizer(wxVERTICAL); - m_media_ctrl = new wxMediaCtrl2(this); + m_media_ctrl = new wxMediaCtrl3(this); m_media_ctrl->SetMinSize(wxSize(PAGE_MIN_WIDTH, FromDIP(288))); m_custom_camera_view = WebView::CreateWebView(this, wxEmptyString); @@ -2477,7 +2477,7 @@ StatusPanel::StatusPanel(wxWindow *parent, wxWindowID id, const wxPoint &pos, co m_project_task_panel->get_pause_resume_button()->Connect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_subtask_pause_resume), NULL, this); m_project_task_panel->get_abort_button()->Connect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_subtask_abort), NULL, this); m_project_task_panel->get_market_scoring_button()->Connect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_market_scoring), NULL, this); - m_project_task_panel->get_market_retry_buttom()->Connect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_market_retry), NULL, this); + m_project_task_panel->get_market_retry_buttom()->Connect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_market_retry), NULL, this); m_project_task_panel->get_clean_button()->Connect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_print_error_clean), NULL, this); m_setting_button->Connect(wxEVT_LEFT_DOWN, wxMouseEventHandler(StatusPanel::on_camera_enter), NULL, this); @@ -2539,7 +2539,7 @@ StatusPanel::~StatusPanel() m_project_task_panel->get_pause_resume_button()->Disconnect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_subtask_pause_resume), NULL, this); m_project_task_panel->get_abort_button()->Disconnect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_subtask_abort), NULL, this); m_project_task_panel->get_market_scoring_button()->Disconnect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_market_scoring), NULL, this); - m_project_task_panel->get_market_retry_buttom()->Disconnect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_market_retry), NULL, this); + m_project_task_panel->get_market_retry_buttom()->Disconnect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_market_retry), NULL, this); m_project_task_panel->get_clean_button()->Disconnect(wxEVT_COMMAND_BUTTON_CLICKED, wxCommandEventHandler(StatusPanel::on_print_error_clean), NULL, this); m_setting_button->Disconnect(wxEVT_LEFT_DOWN, wxMouseEventHandler(StatusPanel::on_camera_enter), NULL, this); @@ -2585,7 +2585,7 @@ StatusPanel::~StatusPanel() if (sdcard_hint_dlg != nullptr) delete sdcard_hint_dlg; - if (m_score_data != nullptr) { + if (m_score_data != nullptr) { delete m_score_data; } } @@ -2609,7 +2609,7 @@ void StatusPanel::init_scaled_buttons() m_bpButton_e_down_10->SetCornerRadius(FromDIP(12)); } -void StatusPanel::on_market_scoring(wxCommandEvent &event) { +void StatusPanel::on_market_scoring(wxCommandEvent &event) { if (obj && obj->is_makeworld_subtask() && obj->rating_info && obj->rating_info->request_successful) { // model is mall model and has rating_id BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": on_market_scoring" ; if (m_score_data && m_score_data->rating_id == obj->rating_info->rating_id) { // current score data for model is same as mall model @@ -2618,7 +2618,7 @@ void StatusPanel::on_market_scoring(wxCommandEvent &event) { int ret = m_score_dlg.ShowModal(); BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": old data"; - if (ret == wxID_OK) { + if (ret == wxID_OK) { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": old data is upload"; m_score_data->rating_id = -1; m_project_task_panel->set_star_count_dirty(false); @@ -2640,11 +2640,11 @@ void StatusPanel::on_market_scoring(wxCommandEvent &event) { std::string comment = obj->rating_info->content; if (!comment.empty()) { m_score_dlg.set_comment(comment); } - + std::vector images_json_array; images_json_array = obj->rating_info->image_url_paths; if (!images_json_array.empty()) m_score_dlg.set_cloud_bitmap(images_json_array); - + int ret = m_score_dlg.ShowModal(); if (ret == wxID_OK) { @@ -3684,14 +3684,14 @@ void StatusPanel::update_basic_print_data(bool def) void StatusPanel::update_model_info() { auto get_subtask_fn = [this](BBLModelTask* subtask) { - CallAfter([this, subtask]() { + CallAfter([this, subtask]() { if (obj && obj->subtask_id_ == subtask->task_id) { obj->set_modeltask(subtask); } }); }; - + if (wxGetApp().getAgent() && obj) { BBLSubTask* curr_task = obj->get_subtask(); if (curr_task) { @@ -4063,7 +4063,7 @@ void StatusPanel::reset_printing_values() m_project_task_panel->update_left_time(NA_STR); m_project_task_panel->update_layers_num(true, wxString::Format(_L("Layer: %s"), NA_STR)); update_calib_bitmap(); - + task_thumbnail_state = ThumbnailState::PLACE_HOLDER; m_start_loading_thumbnail = false; m_load_sdcard_thumbnail = false; @@ -4124,7 +4124,7 @@ bool StatusPanel::check_axis_z_at_home(MachineObject* obj) } void StatusPanel::on_axis_ctrl_z_up_10(wxCommandEvent &event) -{ +{ if (obj) { obj->command_axis_control("Z", 1.0, -10.0f, 900); if (!check_axis_z_at_home(obj)) @@ -5439,7 +5439,7 @@ void StatusPanel::msw_rescale() m_calibration_btn->Rescale(); m_options_btn->SetMinSize(wxSize(-1, FromDIP(26))); - m_options_btn->Rescale(); + m_options_btn->Rescale(); m_safety_btn->SetMinSize(wxSize(-1, FromDIP(26))); m_safety_btn->Rescale(); @@ -5621,11 +5621,11 @@ ScoreDialog::ScoreDialog(wxWindow *parent, ScoreData *score_data) , m_upload_status_code(StatusCode::CODE_NUMBER) { m_tocken.reset(new int(0)); - + wxBoxSizer *m_main_sizer = get_main_sizer(score_data->local_to_url_image, score_data->comment_text); m_image_url_paths = score_data->image_url_paths; - + this->SetSizer(m_main_sizer); Fit(); @@ -5641,16 +5641,16 @@ void ScoreDialog::on_dpi_changed(const wxRect &suggested_rect) {} void ScoreDialog::OnBitmapClicked(wxMouseEvent &event) { wxStaticBitmap *clickedBitmap = dynamic_cast(event.GetEventObject()); - if (m_image.find(clickedBitmap) != m_image.end()) { + if (m_image.find(clickedBitmap) != m_image.end()) { if (!m_image[clickedBitmap].is_selected) { - for (auto panel : m_image[clickedBitmap].image_broad) { + for (auto panel : m_image[clickedBitmap].image_broad) { panel->Show(); } m_image[clickedBitmap].is_selected = true; m_selected_image_list.insert(clickedBitmap); } else { - for (auto panel : m_image[clickedBitmap].image_broad) { - panel->Hide(); + for (auto panel : m_image[clickedBitmap].image_broad) { + panel->Hide(); } m_image[clickedBitmap].is_selected = false; m_selected_image_list.erase(clickedBitmap); @@ -5667,9 +5667,9 @@ void ScoreDialog::OnBitmapClicked(wxMouseEvent &event) } std::set > ScoreDialog::add_need_upload_imgs() -{ +{ std::set> need_upload_images; - for (auto bitmap : m_image) { + for (auto bitmap : m_image) { if (!bitmap.second.is_uploaded) { wxString &local_image_path = bitmap.second.local_image_url; if (!local_image_path.empty()) { need_upload_images.insert(std::make_pair(bitmap.first, local_image_path)); } @@ -5689,7 +5689,7 @@ std::pair ScoreDialog::create_local_thu cur_image_msg.local_image_url = local_path; cur_image_msg.img_url_paths = ""; cur_image_msg.is_uploaded = false; - + wxStaticBitmap *imageCtrl = new wxStaticBitmap(this, wxID_ANY, wxBitmap(wxImage(local_path, wxBITMAP_TYPE_ANY).Rescale(FromDIP(80), FromDIP(60))), wxDefaultPosition, wxDefaultSize, 0); imageCtrl->Bind(wxEVT_LEFT_DOWN, &ScoreDialog::OnBitmapClicked, this); @@ -5754,7 +5754,7 @@ void ScoreDialog::update_static_bitmap(wxStaticBitmap* static_bitmap, wxImage im } wxBoxSizer *ScoreDialog::create_broad_sizer(wxStaticBitmap *bitmap, ImageMsg& cur_image_msg) -{ +{ // tb: top and bottom lr: left and right auto m_image_tb_broad = new wxBoxSizer(wxVERTICAL); auto line_top = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(-1, 1), wxTAB_TRAVERSAL); @@ -5798,7 +5798,7 @@ void ScoreDialog::init() { fail_image = wxImage(Slic3r::resources_dir() + "/images/oss_picture_load_failed.png", wxBITMAP_TYPE_ANY); } -wxBoxSizer *ScoreDialog::get_score_sizer() { +wxBoxSizer *ScoreDialog::get_score_sizer() { wxBoxSizer *score_sizer = new wxBoxSizer(wxHORIZONTAL); wxStaticText *static_score_text = new wxStaticText(this, wxID_ANY, _L("Rate"), wxDefaultPosition, wxDefaultSize, 0); static_score_text->Wrap(-1); @@ -5921,18 +5921,18 @@ wxBoxSizer *ScoreDialog::get_photo_btn_sizer() { for (int i = 0; i < filePaths.GetCount(); i++) { //It's ugly, but useful bool is_repeat = false; for (auto image : m_image) { - if (filePaths[i] == image.second.local_image_url) { + if (filePaths[i] == image.second.local_image_url) { is_repeat = true; continue; } } if (!is_repeat) { local_path.push_back(std::make_pair(filePaths[i], "")); - if (local_path.size() + m_image.size() > m_photo_nums) { - break; + if (local_path.size() + m_image.size() > m_photo_nums) { + break; } } - + } load_photo(local_path); @@ -6050,7 +6050,7 @@ wxBoxSizer *ScoreDialog::get_button_sizer() } } progress_dialog->Hide(); - if (progress_dialog) { + if (progress_dialog) { delete progress_dialog; progress_dialog = nullptr; } @@ -6184,7 +6184,7 @@ wxBoxSizer *ScoreDialog::get_main_sizer(const std::vectorAdd(m_photo_sizer, 0, wxEXPAND | wxTOP, FromDIP(8)); m_image_sizer = new wxGridSizer(5, FromDIP(5), FromDIP(5)); - if (!images.empty()) { + if (!images.empty()) { load_photo(images); } m_main_sizer->Add(m_image_sizer, 0, wxEXPAND | wxLEFT, FromDIP(24)); @@ -6196,7 +6196,7 @@ wxBoxSizer *ScoreDialog::get_main_sizer(const std::vectorGetValue(); score_data.image_url_paths = m_image_url_paths; for (auto img : m_image) { score_data.local_to_url_image.push_back(std::make_pair(img.second.local_image_url, img.second.img_url_paths)); } - + return score_data; } void ScoreDialog::set_comment(std::string comment) { - if (m_comment_text) { + if (m_comment_text) { m_comment_text->SetValue(wxString::FromUTF8(comment)); } } void ScoreDialog::set_cloud_bitmap(std::vector cloud_bitmaps) -{ +{ m_image_url_paths = cloud_bitmaps; for (std::string &url : cloud_bitmaps) { if (std::string::npos == url.find(m_model_id)) continue; diff --git a/src/slic3r/GUI/StatusPanel.hpp b/src/slic3r/GUI/StatusPanel.hpp index 7754033162..1478feef8b 100644 --- a/src/slic3r/GUI/StatusPanel.hpp +++ b/src/slic3r/GUI/StatusPanel.hpp @@ -16,7 +16,6 @@ #include #include #include -#include "wxMediaCtrl2.h" #include "MediaPlayCtrl.h" #include "WebMediaController.hpp" #include "AMSSetting.hpp" @@ -198,11 +197,11 @@ public: void set_cloud_bitmap(std::vector cloud_bitmaps); protected: - enum StatusCode { - UPLOAD_PROGRESS = 0, - UPLOAD_EXIST_ISSUE, + enum StatusCode { + UPLOAD_PROGRESS = 0, + UPLOAD_EXIST_ISSUE, UPLOAD_IMG_FAILED, - CODE_NUMBER + CODE_NUMBER }; std::shared_ptr m_tocken; @@ -220,7 +219,7 @@ protected: { wxString local_image_url; //local image path std::string img_url_paths; // oss url path - vector image_broad; + vector image_broad; bool is_selected; bool is_uploaded; // load wxBoxSizer * image_tb_broad = nullptr; @@ -255,7 +254,7 @@ protected: std::set> add_need_upload_imgs(); std::pair create_local_thumbnail(wxString &local_path); std::pair create_oss_thumbnail(std::string &oss_path); - + }; class PrintingTaskPanel : public wxPanel @@ -264,7 +263,7 @@ public: PrintingTaskPanel(wxWindow* parent, PrintingTaskType type); ~PrintingTaskPanel(); void create_panel(wxWindow* parent); - + private: MachineObject* m_obj{nullptr}; @@ -356,7 +355,7 @@ public: void set_plate_index(int plate_idx = -1); void market_scoring_show(); void market_scoring_hide(); - + public: ScalableButton* get_abort_button() {return m_button_abort;}; ScalableButton* get_pause_resume_button() {return m_button_pause_resume;}; @@ -446,7 +445,7 @@ protected: // wxStaticBitmap* m_camera_switch_button; - wxMediaCtrl2 * m_media_ctrl; + wxMediaCtrl3 * m_media_ctrl; MediaPlayCtrl * m_media_play_ctrl; Label * m_staticText_printing; @@ -572,7 +571,7 @@ protected: virtual void on_bed_temp_kill_focus(wxFocusEvent &event) { event.Skip(); } virtual void on_bed_temp_set_focus(wxFocusEvent &event) { event.Skip(); } virtual void on_nozzle_temp_kill_focus(wxFocusEvent &event) { event.Skip(); } - virtual void on_nozzle_temp_set_focus(wxFocusEvent &event) { event.Skip(); } + virtual void on_nozzle_temp_set_focus(wxFocusEvent &event) { event.Skip(); } virtual void on_nozzle_fan_switch(wxCommandEvent &event) { event.Skip(); } virtual void on_printing_fan_switch(wxCommandEvent &event) { event.Skip(); } virtual void on_axis_ctrl_z_up_10(wxCommandEvent &event) { event.Skip(); } diff --git a/src/slic3r/GUI/SyncAmsInfoDialog.cpp b/src/slic3r/GUI/SyncAmsInfoDialog.cpp index bee55d53f0..be64d6f05e 100644 --- a/src/slic3r/GUI/SyncAmsInfoDialog.cpp +++ b/src/slic3r/GUI/SyncAmsInfoDialog.cpp @@ -30,6 +30,7 @@ #include "DeviceCore/DevManager.h" #include "DeviceCore/DevMapping.h" #include "DeviceCore/DevStorage.h" +#include "FilamentBitmapUtils.hpp" using namespace Slic3r; using namespace Slic3r::GUI; @@ -2575,6 +2576,10 @@ void SyncAmsInfoDialog::reset_and_sync_ams_list() m_materialList.clear(); m_filaments.clear(); + // Mixed-color slots are virtual: they never occupy a tray, so they must not appear as + // AMS sync targets. + auto* is_mixed_opt = preset_bundle->project_config.option("filament_is_mixed"); + bool use_double_extruder = get_is_double_extruder(); if (use_double_extruder) { const auto &project_config = preset_bundle->project_config; @@ -2592,6 +2597,8 @@ void SyncAmsInfoDialog::reset_and_sync_ams_list() auto colour_rgb = wxColour((int) rgb[0], (int) rgb[1], (int) rgb[2], (int) rgb[3]); if (extruder >= materials.size() || extruder < 0 || extruder >= display_materials.size()) continue; + if (is_mixed_opt && extruder < (int) is_mixed_opt->values.size() && is_mixed_opt->values[extruder]) + continue; if (contronal_index % SYNC_FLEX_GRID_COL == 0) { wxBoxSizer *ams_tip_sizer = new wxBoxSizer(wxVERTICAL); @@ -2793,6 +2800,10 @@ void SyncAmsInfoDialog::generate_override_fix_ams_list() m_fix_materialList.clear(); m_fix_filaments.clear(); + // Mixed-color slots are virtual: they never occupy a tray, so they must not appear as + // AMS sync targets. + auto* is_mixed_opt = preset_bundle->project_config.option("filament_is_mixed"); + bool use_double_extruder = get_is_double_extruder(); if (use_double_extruder) { const auto &project_config = preset_bundle->project_config; @@ -2810,6 +2821,8 @@ void SyncAmsInfoDialog::generate_override_fix_ams_list() auto colour_rgb = wxColour((int) rgb[0], (int) rgb[1], (int) rgb[2], (int) rgb[3]); if (extruder >= extruders.size() || extruder < 0 || extruder >= m_ams_combo_info.ams_filament_colors.size()) continue; + if (is_mixed_opt && extruder < (int) is_mixed_opt->values.size() && is_mixed_opt->values[extruder]) + continue; if (contronal_index % SYNC_FLEX_GRID_COL == 0) { wxBoxSizer *ams_tip_sizer = new wxBoxSizer(wxVERTICAL); @@ -2931,6 +2944,20 @@ void SyncAmsInfoDialog::clone_thumbnail_data() iter++; } } + + // Expand color arrays to cover mixed (virtual) slots and compute their blended colors + const auto& cfg = wxGetApp().preset_bundle->project_config; + size_t total = 0; + if (auto* opt = cfg.option("filament_is_mixed")) + total = opt->values.size(); + size_t target = std::max(total, m_cur_colors_in_thumbnail.size()); + if (m_cur_colors_in_thumbnail.size() < target) + m_cur_colors_in_thumbnail.resize(target); + if (m_preview_colors_in_thumbnail.size() < target) + m_preview_colors_in_thumbnail.resize(target); + recompute_mixed_slot_colors(m_preview_colors_in_thumbnail, cfg); + recompute_mixed_slot_colors(m_cur_colors_in_thumbnail, cfg); + // copy data auto &data = m_cur_input_thumbnail_data; m_preview_thumbnail_data.reset(); @@ -3119,6 +3146,10 @@ void SyncAmsInfoDialog::change_default_normal(int old_filament_id, wxColour temp return; } } + // Recompute mixed slot colors after physical slot color change + const auto& cfg = wxGetApp().preset_bundle->project_config; + recompute_mixed_slot_colors(m_cur_colors_in_thumbnail, cfg); + ThumbnailData &data = m_cur_input_thumbnail_data; ThumbnailData &no_light_data = m_cur_no_light_thumbnail_data; if (data.width > 0 && data.height > 0 && data.width == no_light_data.width && data.height == no_light_data.height) { diff --git a/src/slic3r/GUI/SyncAmsInfoDialog.hpp b/src/slic3r/GUI/SyncAmsInfoDialog.hpp index 8ff8f18aff..248ca4032c 100644 --- a/src/slic3r/GUI/SyncAmsInfoDialog.hpp +++ b/src/slic3r/GUI/SyncAmsInfoDialog.hpp @@ -371,7 +371,7 @@ public: }; FinishSyncAmsDialog(InputInfo &input_info); ~FinishSyncAmsDialog() override; - void deal_ok(); + void deal_ok() override; void update_info(InputInfo& info); bool Layout() override; diff --git a/src/slic3r/GUI/Tab.cpp b/src/slic3r/GUI/Tab.cpp index bec3bed20b..0eb5c9281d 100644 --- a/src/slic3r/GUI/Tab.cpp +++ b/src/slic3r/GUI/Tab.cpp @@ -4,6 +4,7 @@ #include "PresetHints.hpp" #include "libslic3r/PresetBundle.hpp" #include "libslic3r/PrintConfig.hpp" +#include "libslic3r/FilamentMixer.hpp" #include "libslic3r/Utils.hpp" #include "libslic3r/Model.hpp" #include "libslic3r/GCode/GCodeProcessor.hpp" @@ -2173,18 +2174,25 @@ void Tab::on_value_change(const std::string& opt_key, const boost::any& value) //Orca: sync filament num if it's a multi tool printer if (opt_key == "extruders_count" && !m_config->opt_bool("single_extruder_multi_material")){ - auto num_extruder = boost::any_cast(value); - int old_filament_size = wxGetApp().preset_bundle->filament_presets.size(); - std::vector new_colors; - for (int i = old_filament_size; i < num_extruder; ++i) { - wxColour new_col = Plater::get_next_color_for_filament(); - std::string new_color = new_col.GetAsString(wxC2S_HTML_SYNTAX).ToStdString(); - new_colors.push_back(new_color); + const size_t num_extruder = boost::any_cast(value); + auto *bundle = wxGetApp().preset_bundle; + Sidebar &sidebar = wxGetApp().plater()->sidebar(); + // A tool changer feeds filament N from nozzle N, so the extruder count sizes the physical + // run only; mixed slots are virtual and keep the tail. Go one slot at a time through the + // sidebar's own +/- calls: they insert ahead of the mixed tail and renumber filament ids, + // painted facets, custom g-code and mixed components, which a bulk resize clamps away. + // Both also refresh the print tab and export the selections, so nothing to do afterwards. + size_t physical = bundle->num_physical_filaments(); + while (physical != num_extruder) { + if (physical < num_extruder) + sidebar.add_custom_filament(Plater::get_next_color_for_filament()); + else + sidebar.delete_filament(physical - 1); // physical > num_extruder >= 1 + const size_t updated = bundle->num_physical_filaments(); + if (updated == physical) + break; // the call declined, e.g. the total slot limit - do not spin + physical = updated; } - wxGetApp().preset_bundle->set_num_filaments(num_extruder, new_colors); - wxGetApp().plater()->on_filament_count_change(num_extruder); - wxGetApp().get_tab(Preset::TYPE_PRINT)->update(); - wxGetApp().preset_bundle->export_selections(*wxGetApp().app_config); } //Orca: disable purge_in_prime_tower if single_extruder_multi_material is disabled @@ -2629,6 +2637,7 @@ void TabPrint::build() auto optgroup = page->new_optgroup(L("Layer height"), L"param_layer_height"); optgroup->append_single_option_line("layer_height","quality_settings_layer_height"); optgroup->append_single_option_line("initial_layer_print_height","quality_settings_layer_height"); + optgroup->append_single_option_line("enable_mixed_color_sublayer"); optgroup = page->new_optgroup(L("Line width"), L"param_line_width"); optgroup->append_single_option_line("line_width","quality_settings_line_width"); @@ -3497,6 +3506,21 @@ void TabPrintModel::activate_selected_page(std::function throw_if_cancel f->set_value(boost::any(), false); } } + if (m_type == Preset::TYPE_PLATE) + static_cast(this)->update_mixed_filament_seq_state(); +} + +// A mixed-color slot resolves to a different physical filament per layer, so a +// user-defined filament print order cannot be honoured while one exists. +void TabPrintPlate::update_mixed_filament_seq_state() +{ + if (!m_active_page) return; + auto &proj_cfg = m_preset_bundle->project_config; + auto *opt = proj_cfg.option("filament_is_mixed"); + bool has_mixed = opt && has_any_mixed_filament(opt->values); + + toggle_option("first_layer_sequence_choice", !has_mixed); + toggle_option("other_layers_sequence_choice", !has_mixed); } void TabPrintModel::on_value_change(const std::string& opt_id, const boost::any& value) @@ -7506,7 +7530,7 @@ void Tab::delete_preset() for (auto &preset2 : *m_presets) if (preset2.inherits() == current_preset.name) { ++count; - presets += "\n - " + preset2.name; + presets += "\n - " + from_u8(preset2.name); } if (count > 0) { msg = _L("Presets inherited by other presets cannot be deleted!"); diff --git a/src/slic3r/GUI/Tab.hpp b/src/slic3r/GUI/Tab.hpp index 19eb0b849d..c6041ba1ea 100644 --- a/src/slic3r/GUI/Tab.hpp +++ b/src/slic3r/GUI/Tab.hpp @@ -515,13 +515,13 @@ public: bool has_key(std::string const &key); protected: - virtual void activate_selected_page(std::function throw_if_canceled); + virtual void activate_selected_page(std::function throw_if_canceled) override; virtual void on_value_change(const std::string& opt_key, const boost::any& value) override; virtual void notify_changed(ObjectBase * object) = 0; - virtual void reload_config(); + virtual void reload_config() override; virtual void update_custom_dirty(std::vector &dirty_options, std::vector &nonsys_options) override; @@ -545,6 +545,8 @@ public: void build() override; void reset_model_config() override; int show_spiral_mode_settings_dialog(bool is_object_config) { return m_config_manipulation.show_spiral_mode_settings_dialog(is_object_config); } + // Disables the user-defined filament print order while a mixed-color filament exists. + void update_mixed_filament_seq_state(); protected: virtual void on_value_change(const std::string& opt_key, const boost::any& value) override; diff --git a/src/slic3r/GUI/TabButton.hpp b/src/slic3r/GUI/TabButton.hpp index 7accf248c4..05ce1c6bd3 100644 --- a/src/slic3r/GUI/TabButton.hpp +++ b/src/slic3r/GUI/TabButton.hpp @@ -40,7 +40,7 @@ public: void SetBitmap(ScalableBitmap &bitmap); - bool Enable(bool enable = true); + bool Enable(bool enable = true) override; void Rescale(); diff --git a/src/slic3r/GUI/Tabbook.hpp b/src/slic3r/GUI/Tabbook.hpp index 7f10e9dd8d..b1301a5c23 100644 --- a/src/slic3r/GUI/Tabbook.hpp +++ b/src/slic3r/GUI/Tabbook.hpp @@ -36,7 +36,6 @@ public: TabButton* pageButton; private: - wxWindow* m_parent; wxFlexGridSizer* m_buttons_sizer; wxBoxSizer* m_sizer; ScalableBitmap m_arrow_img; @@ -166,7 +165,7 @@ public: return true; } - bool RemovePage(size_t n) + bool RemovePage(size_t n) override { if (!wxBookCtrlBase::RemovePage(n)) return false; @@ -400,8 +399,6 @@ private: unsigned m_showTimeout, m_hideTimeout; - TabButtonsListCtrl *m_ctrl{nullptr}; - }; //#endif // _WIN32 #endif // slic3r_Tabbook_hpp_ diff --git a/src/slic3r/GUI/TextureImportDialog.cpp b/src/slic3r/GUI/TextureImportDialog.cpp new file mode 100644 index 0000000000..1a24799a15 --- /dev/null +++ b/src/slic3r/GUI/TextureImportDialog.cpp @@ -0,0 +1,4361 @@ +#include +#include "OpenGLManager.hpp" + +#include "TextureImportDialog.hpp" +#include "I18N.hpp" +#include "GUI_App.hpp" +#include "MsgDialog.hpp" +#include "ColorDecomposeDialog.hpp" +#include "ColorDecomposeSupport.hpp" +#include "Widgets/StateColor.hpp" +#include "Widgets/StaticLine.hpp" +#include "libslic3r/ColorDecomposeRecipe.hpp" +#include "libslic3r/MeshBoolean.hpp" +#include "libslic3r/TriangleSelector.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +static constexpr const char* DEFAULT_VIRTUAL_FILAMENT_BASIC_TYPE = "PLA Basic"; +static constexpr const char* DEFAULT_VIRTUAL_FILAMENT_SHORT_TYPE = "PLA"; +static constexpr const char* DEFAULT_VIRTUAL_FILAMENT_NAME = "Bambu PLA Basic"; + +static bool is_dark() { return Slic3r::GUI::wxGetApp().dark_mode(); } + +static wxColour texture_import_gray9000() +{ + return wxColour(38, 46, 48); +} + +static wxColour texture_import_text_colour() +{ + return StateColor::darkModeColorFor(texture_import_gray9000()); +} + +// StaticLine::SetLineColour stores the raw key and resolves it itself when it paints, so those +// sinks take SEPARATOR_COLOUR_KEY directly; only raw wx sinks need the resolved form below. +static constexpr const char* SEPARATOR_COLOUR_KEY = "#CECECE"; + +static wxColour texture_import_separator_colour() +{ + return StateColor::darkModeColorFor(wxColour(SEPARATOR_COLOUR_KEY)); +} + +// Orca's confirm palette, applied here rather than through Button::SetStyle because these buttons +// keep custom pill geometry that SetStyle resets. The Disabled entries are load-bearing: without +// one, StateColor::colorForStates falls through to the Normal entry and a disabled button paints +// as a live accent button. +static void apply_accent_button_colours(Button* btn) +{ + btn->SetBackgroundColor(StateColor( + std::pair(wxColour("#CECECE"), StateColor::Disabled), + std::pair(wxColour(0, 137, 123), StateColor::Pressed), + std::pair(wxColour(38, 166, 154), StateColor::Hovered), + std::pair(wxColour(0, 150, 136), StateColor::Normal))); + btn->SetBorderColor(StateColor( + std::pair(wxColour("#CECECE"), StateColor::Disabled), + std::pair(wxColour(0, 150, 136), StateColor::Normal))); + btn->SetTextColor(StateColor( + std::pair(wxColour("#6B6B6A"), StateColor::Disabled), + std::pair(wxColour("#FFFFFE"), StateColor::Normal))); +} + +// The same button while the parameters behind it are dirty: still clickable, but reading as +// "what you see is not what this button would apply". +static void apply_muted_button_colours(Button* btn) +{ + btn->SetBackgroundColor(wxColour("#CECECE")); + btn->SetBorderColor(wxColour("#CECECE")); + btn->SetTextColor(wxColour("#6B6B6A")); +} + +static wxFont texture_import_section_title_font(wxWindow* win) +{ + wxFont font = win ? win->GetFont() : wxSystemSettings::GetFont(wxSYS_DEFAULT_GUI_FONT); + font.MakeBold(); + return font; +} + +static wxSize gl_viewport_size(wxWindow* win, const wxSize& logical_size) +{ + wxSize viewport_size = logical_size; +#ifdef __APPLE__ + const double scale = win ? win->GetContentScaleFactor() : 1.0; + if (scale > 0.0) { + viewport_size.x = std::max(1, (int)std::round(viewport_size.x * scale)); + viewport_size.y = std::max(1, (int)std::round(viewport_size.y * scale)); + } +#else + (void)win; +#endif + return viewport_size; +} + +class ScopedInteractiveBusyCursorSuspender +{ +public: + ScopedInteractiveBusyCursorSuspender() + { +#if defined(__WXMSW__) || defined(__APPLE__) + while (wxIsBusy()) { + wxEndBusyCursor(); + ++m_suspended_count; + } +#endif + } + + ~ScopedInteractiveBusyCursorSuspender() + { +#if defined(__WXMSW__) || defined(__APPLE__) + for (int i = 0; i < m_suspended_count; ++i) + wxBeginBusyCursor(); +#endif + } + +private: + int m_suspended_count = 0; +}; + +static bool needs_filament_swatch_border(const wxColour& colour) +{ + if (is_dark()) + return colour.Red() < 45 && colour.Green() < 45 && colour.Blue() < 45; + return colour.Red() > 224 && colour.Green() > 224 && colour.Blue() > 224; +} + +static wxColour filament_swatch_border_colour() +{ + return is_dark() ? wxColour(207, 207, 207) : wxColour(130, 130, 128); +} + +static void draw_filament_swatch_border(wxDC& dc, const wxColour& colour, + int x, int y, int w, int h, int radius = 0) +{ + if (!needs_filament_swatch_border(colour)) + return; + + dc.SetPen(wxPen(filament_swatch_border_colour(), 1)); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + if (radius > 0) + dc.DrawRoundedRectangle(x, y, w, h, radius); + else + dc.DrawRectangle(x, y, w, h); +} + +static void draw_filament_swatch_ellipse_border(wxDC& dc, const wxColour& colour, + int x, int y, int w, int h) +{ + if (!needs_filament_swatch_border(colour)) + return; + + dc.SetPen(wxPen(filament_swatch_border_colour(), 1)); + dc.SetBrush(*wxTRANSPARENT_BRUSH); + dc.DrawEllipse(x, y, w, h); +} + +static wxString ellipsize_text(wxDC& dc, wxString text, int max_width) +{ + if (max_width <= 0) + return wxEmptyString; + if (dc.GetTextExtent(text).x <= max_width) + return text; + + const wxString ellipsis = "..."; + while (!text.empty() && dc.GetTextExtent(text + ellipsis).x > max_width) + text.RemoveLast(); + if (text.empty() && dc.GetTextExtent(ellipsis).x > max_width) + return wxString(); + return text + ellipsis; +} + +// ============================================================ +// AccentSlider — thin track + accent-coloured triangle thumb +// ============================================================ + +class AccentSlider : public wxPanel { +public: + AccentSlider(wxWindow* parent, int value, int minVal, int maxVal, + const wxPoint& pos = wxDefaultPosition, + const wxSize& size = wxDefaultSize); + ~AccentSlider() override; + int GetValue() const; + void SetValue(int val); + bool Enable(bool enable = true) override; +private: + void OnPaint(wxPaintEvent&); + void OnMouse(wxMouseEvent&); + int xFromValue() const; + int valueFromX(int x) const; + int m_value, m_min, m_max; + bool m_dragging = false; +}; + +AccentSlider::AccentSlider(wxWindow* parent, int value, int minVal, int maxVal, + const wxPoint& pos, const wxSize& size) + : wxPanel(parent, wxID_ANY, pos, size.IsFullySpecified() ? size : wxSize(-1, parent->FromDIP(24)), + wxTAB_TRAVERSAL | wxFULL_REPAINT_ON_RESIZE) + , m_value(std::clamp(value, minVal, maxVal)), m_min(minVal), m_max(maxVal) +{ + SetBackgroundStyle(wxBG_STYLE_PAINT); + SetMinSize(wxSize(-1, FromDIP(24))); + + Bind(wxEVT_PAINT, &AccentSlider::OnPaint, this); + Bind(wxEVT_SIZE, [this](wxSizeEvent& evt) { + evt.Skip(); + Refresh(); + }); + Bind(wxEVT_LEFT_DOWN, &AccentSlider::OnMouse, this); + Bind(wxEVT_LEFT_UP, &AccentSlider::OnMouse, this); + Bind(wxEVT_MOTION, &AccentSlider::OnMouse, this); + Bind(wxEVT_MOUSE_CAPTURE_LOST, [this](wxMouseCaptureLostEvent&) { m_dragging = false; }); +} + +AccentSlider::~AccentSlider() +{ + // See MixedFilamentDialog::~MixedFilamentDialog: a widget destroyed while it + // still holds the capture wedges mouse input for the whole application. + if (HasCapture()) + ReleaseMouse(); +} + +int AccentSlider::GetValue() const { return m_value; } + +void AccentSlider::SetValue(int val) +{ + val = std::clamp(val, m_min, m_max); + if (val != m_value) { m_value = val; Refresh(); } +} + +bool AccentSlider::Enable(bool enable) +{ + bool ok = wxPanel::Enable(enable); + Refresh(); + return ok; +} + +int AccentSlider::xFromValue() const +{ + wxSize sz = GetClientSize(); + int margin = FromDIP(6); + int track_w = sz.x - 2 * margin; + if (m_max <= m_min || track_w <= 0) return margin; + return margin + (m_value - m_min) * track_w / (m_max - m_min); +} + +int AccentSlider::valueFromX(int x) const +{ + wxSize sz = GetClientSize(); + int margin = FromDIP(6); + int track_w = sz.x - 2 * margin; + if (track_w <= 0 || m_max <= m_min) return m_min; + int val = m_min + (x - margin) * (m_max - m_min) / track_w; + return std::clamp(val, m_min, m_max); +} + +void AccentSlider::OnPaint(wxPaintEvent&) +{ + wxAutoBufferedPaintDC dc(this); + wxSize sz = GetClientSize(); + + dc.SetBackground(wxBrush(GetParent()->GetBackgroundColour())); + dc.Clear(); + + int margin = FromDIP(6); + int track_y = sz.y / 2; + int ts = FromDIP(8); + int pen_w = FromDIP(2); + + wxColour accent_clr = StateColor::darkModeColorFor(IsEnabled() ? wxColour("#009688") : wxColour("#ACACAC")); + wxColour track_clr = StateColor::darkModeColorFor(IsEnabled() ? wxColour("#CECECE") : wxColour("#DFDFDF")); + + int tx = xFromValue(); + + dc.SetPen(wxPen(accent_clr, pen_w)); + dc.DrawLine(margin, track_y, tx, track_y); + + dc.SetPen(wxPen(track_clr, pen_w)); + dc.DrawLine(tx, track_y, sz.x - margin, track_y); + + wxPoint tri[3] = { + {tx, track_y + FromDIP(1)}, + {tx - ts / 2, track_y + FromDIP(1) + ts}, + {tx + ts / 2, track_y + FromDIP(1) + ts} + }; + dc.SetBrush(wxBrush(accent_clr)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawPolygon(3, tri); +} + +void AccentSlider::OnMouse(wxMouseEvent& evt) +{ + if (!IsEnabled()) return; + + auto update = [&](int x) { + int nv = valueFromX(x); + if (nv != m_value) { + m_value = nv; + Refresh(); + wxCommandEvent e(wxEVT_SLIDER, GetId()); + e.SetEventObject(this); + ProcessWindowEvent(e); + } + }; + + if (evt.LeftDown()) { + m_dragging = true; + if (!HasCapture()) CaptureMouse(); + update(evt.GetX()); + } else if (evt.LeftUp()) { + m_dragging = false; + if (HasCapture()) ReleaseMouse(); + } else if (evt.Dragging() && m_dragging) { + update(evt.GetX()); + } +} + +namespace Slic3r { namespace GUI { + +wxDEFINE_EVENT(EVT_TEXTURE_COMPUTE_DONE, wxCommandEvent); +wxDEFINE_EVENT(EVT_TEXTURE_COMPUTE_PROGRESS, wxCommandEvent); +wxDEFINE_EVENT(EVT_TEXTURE_COMPUTE_ERROR, wxCommandEvent); +wxDEFINE_EVENT(EVT_TEXTURE_MESH_REPAIR_DECISION, wxCommandEvent); + +static std::array parse_color_string(const std::string& hex) +{ + std::array c = {1.f, 1.f, 1.f, 1.f}; + if (hex.size() >= 7 && hex[0] == '#') { + unsigned long val = std::strtoul(hex.c_str() + 1, nullptr, 16); + c[0] = ((val >> 16) & 0xFF) / 255.f; + c[1] = ((val >> 8) & 0xFF) / 255.f; + c[2] = ((val ) & 0xFF) / 255.f; + } + return c; +} + +static wxString rgb_to_hex(const std::array& c) +{ + return wxString::Format("#%02X%02X%02X", + (unsigned)c[0], (unsigned)c[1], (unsigned)c[2]); +} + +static wxString filament_name_to_wx_string(const std::string& name) +{ + wxString utf8_name = wxString::FromUTF8(name.c_str()); + if (!utf8_name.empty() || name.empty()) + return utf8_name; + return wxString(name); +} + +static std::string texture_normalize_color_hex(std::string hex) +{ + if (hex.empty()) + return "#808080"; + if (hex.front() != '#') + hex = "#" + hex; + return decompose_normalize_color_hex(std::move(hex)); +} + +static std::string texture_rgba_to_hex(const std::array& rgba) +{ + return wxString::Format("#%02X%02X%02X", + (unsigned char)std::clamp(rgba[0] * 255.f, 0.f, 255.f), + (unsigned char)std::clamp(rgba[1] * 255.f, 0.f, 255.f), + (unsigned char)std::clamp(rgba[2] * 255.f, 0.f, 255.f)).ToStdString(); +} + +static bool texture_entry_is_physical(TextureFilamentKind kind) +{ + return kind == TextureFilamentKind::ExistingPhysical || kind == TextureFilamentKind::NewPhysical; +} + +static bool texture_entry_is_mixed(TextureFilamentKind kind) +{ + return kind == TextureFilamentKind::ExistingMixed || kind == TextureFilamentKind::NewMixed; +} + +static bool texture_entry_is_pla_basic(const TextureFilamentEntry& entry) +{ + return entry.type == DEFAULT_VIRTUAL_FILAMENT_SHORT_TYPE || entry.type == DEFAULT_VIRTUAL_FILAMENT_BASIC_TYPE || + entry.name.find(DEFAULT_VIRTUAL_FILAMENT_BASIC_TYPE) != std::string::npos || + entry.preset_name.find(DEFAULT_VIRTUAL_FILAMENT_BASIC_TYPE) != std::string::npos; +} + +static bool texture_entry_official_basic(const TextureFilamentEntry& entry) +{ + if (!texture_entry_is_physical(entry.kind)) + return false; + // NewPhysical entries are created by add_virtual_filament with a fixed Bambu Basic name. + if (entry.kind == TextureFilamentKind::NewPhysical) + return !official_basic_type_from_preset_name(entry.name).empty(); + // ExistingPhysical: resolve the filament preset name from project_config_index. + auto& pb = *wxGetApp().preset_bundle; + const size_t cfg = entry.project_config_index; + if (cfg < pb.filament_presets.size()) + return !official_basic_type_from_preset_name(pb.filament_presets[cfg]).empty(); + return false; +} + +static Slic3r::ColorDecomposeRecipeMode texture_recipe_mode(TextureAutoMixMode mode) +{ + return mode == TextureAutoMixMode::CMYW ? Slic3r::ColorDecomposeRecipeMode::CMYW : + Slic3r::ColorDecomposeRecipeMode::RYBW; +} + +static bool starts_with_preset_name(const std::string& name, const char* prefix) +{ + const size_t prefix_len = std::strlen(prefix); + return name.size() >= prefix_len && name.compare(0, prefix_len, prefix) == 0; +} + +static std::string resolve_default_virtual_filament_preset_name() +{ + auto* preset_bundle = wxGetApp().preset_bundle; + if (!preset_bundle) + return {}; + + auto valid_preset_name = [preset_bundle](const std::string& name) -> bool { + return !name.empty() && preset_bundle->filaments.find_preset(name, false) != nullptr; + }; + + const auto* default_profiles = preset_bundle->printers.get_selected_preset() + .config.option("default_filament_profile"); + if (default_profiles) { + for (const std::string& name : default_profiles->values) { + if (starts_with_preset_name(name, DEFAULT_VIRTUAL_FILAMENT_NAME) && valid_preset_name(name)) + return name; + } + } + + for (const Preset& preset : preset_bundle->filaments.get_presets()) { + if (preset.is_system && preset.is_visible && preset.is_compatible && + starts_with_preset_name(preset.name, DEFAULT_VIRTUAL_FILAMENT_NAME)) { + return preset.name; + } + } + + for (const Preset& preset : preset_bundle->filaments.get_presets()) { + if (preset.is_visible && preset.is_compatible && + starts_with_preset_name(preset.name, DEFAULT_VIRTUAL_FILAMENT_NAME)) { + return preset.name; + } + } + + std::string selected = preset_bundle->filaments.get_selected_preset_name(); + return valid_preset_name(selected) ? selected : std::string(); +} + +static wxString auto_mix_mode_label(TextureAutoMixMode mode) +{ + return mode == TextureAutoMixMode::CMYW ? _L("One-click CMYW auto-mix") : + _L("One-click RYBW auto-mix"); +} + +static wxPoint constrained_dialog_position(wxWindow* anchor, const wxSize& dialog_size) +{ + if (!anchor) + return wxDefaultPosition; + + wxSize size = dialog_size; + if (size.x <= 0 || size.y <= 0) + size = wxSize(anchor->FromDIP(450), anchor->FromDIP(350)); + + wxPoint pos = anchor->ClientToScreen(wxPoint(0, anchor->GetSize().y)); + wxRect display_rect; + int display_idx = wxDisplay::GetFromPoint(pos); + if (display_idx != wxNOT_FOUND) + display_rect = wxDisplay(display_idx).GetClientArea(); + else + display_rect = wxDisplay().GetClientArea(); + + pos.x = std::clamp(pos.x, display_rect.GetLeft(), + std::max(display_rect.GetLeft(), display_rect.GetRight() - size.x)); + pos.y = std::clamp(pos.y, display_rect.GetTop(), + std::max(display_rect.GetTop(), display_rect.GetBottom() - size.y)); + return pos; +} + +// ============================================================ +// FilamentSelectPopup +// ============================================================ + +class FilamentSelectPopup : public PopupWindow +{ +public: + FilamentSelectPopup(wxWindow* parent, + const std::vector& entries, + const std::vector>& colors_rgba, + const std::vector& names, + size_t existing_count, + int popup_width, + wxWindow* dialog_anchor, + std::function on_select, + std::function on_add_filament, + std::function on_decompose_color, + std::function can_add_filament, + std::function on_close, + std::vector display_numbers) + : PopupWindow(parent, wxBORDER_NONE | wxPU_CONTAINS_CONTROLS) + , m_entries(entries) + , m_colors_rgba(colors_rgba) + , m_names(names) + , m_existing_count(existing_count) + , m_dialog_anchor(dialog_anchor) + , m_on_select(std::move(on_select)) + , m_on_add_filament(std::move(on_add_filament)) + , m_on_decompose_color(std::move(on_decompose_color)) + , m_can_add_filament(std::move(can_add_filament)) + , m_on_close(std::move(on_close)) + , m_display_numbers(std::move(display_numbers)) + { + wxColour pop_bg = StateColor::darkModeColorFor(*wxWHITE); + SetBackgroundColour(pop_bg); + + m_content = new wxScrolledWindow(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxVSCROLL); + m_content->SetBackgroundColour(pop_bg); + m_content->SetScrollRate(0, FromDIP(5)); + auto* outer = new wxBoxSizer(wxVERTICAL); + + const int pop_w = std::max(FromDIP(213), popup_width); + const int row_h = FromDIP(32); + const int pad = FromDIP(8); + const int max_visible_rows = 10; + const wxColour header_clr = StateColor::darkModeColorFor(wxColour("#ACACAC")); + + auto add_section_header = [&](const wxString& label) { + auto* hdr = new wxStaticText(m_content, wxID_ANY, label); + wxFont hf = hdr->GetFont(); + hf.SetPointSize(9); + hdr->SetFont(hf); + hdr->SetForegroundColour(header_clr); + outer->Add(hdr, 0, wxLEFT | wxRIGHT | wxTOP, pad); + auto* line = new StaticLine(m_content); + line->SetLineColour(wxColour(SEPARATOR_COLOUR_KEY)); + outer->Add(line, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, pad); + }; + + auto add_section = [&](const wxString& label, TextureFilamentKind kind) { + bool has_any = false; + for (const auto& entry : m_entries) { + if (entry.kind == kind) { + has_any = true; + break; + } + } + if (!has_any) + return; + add_section_header(label); + for (const auto& entry : m_entries) { + if (entry.kind != kind) + continue; + wxPanel* row = texture_entry_is_mixed(entry.kind) ? create_mixed_item_row(entry, row_h) + : create_item_row((size_t)entry.dialog_index, row_h); + outer->Add(row, 0, wxEXPAND | wxLEFT | wxRIGHT, pad); + } + }; + + // Section order matches compute_display_numbers() so the visible IDs + // ascend monotonically (ExistingPhysical -> NewPhysical -> ExistingMixed + // -> NewMixed) instead of jumping (e.g. 1,2 -> 7 -> 3,4,5,6 -> 8,9,10). + add_section(_L("Project Physical Filaments"), TextureFilamentKind::ExistingPhysical); + add_section(_L("New Physical Filaments"), TextureFilamentKind::NewPhysical); + add_section(_L("Project Mixed Filaments"), TextureFilamentKind::ExistingMixed); + add_section(_L("New Mixed Filaments"), TextureFilamentKind::NewMixed); + + auto* decompose_label = new wxStaticText(this, wxID_ANY, _L("Decompose Color")); + auto* add_label = new wxStaticText(this, wxID_ANY, _L("+ Add Material")); + wxFont af = add_label->GetFont(); + af.SetPointSize(10); + add_label->SetFont(af); + decompose_label->SetFont(af); + const bool add_enabled = !m_can_add_filament || m_can_add_filament(); + const wxColour action_clr = StateColor::darkModeColorFor(wxColour("#009688")); + add_label->SetForegroundColour(add_enabled ? action_clr : header_clr); + decompose_label->SetForegroundColour(add_enabled ? action_clr : header_clr); + add_label->SetCursor(wxCursor(add_enabled ? wxCURSOR_HAND : wxCURSOR_ARROW)); + decompose_label->SetCursor(wxCursor(add_enabled ? wxCURSOR_HAND : wxCURSOR_ARROW)); + if (!add_enabled) + add_label->SetToolTip(wxString::Format( + _L("The project supports up to %d filaments. Extra filaments will be discarded."), + (int)EnforcerBlockerType::ExtruderMax)); + decompose_label->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent&) { + if (m_can_add_filament && !m_can_add_filament()) + return; + auto on_decompose_color = m_on_decompose_color; + m_closing_from_action = true; + Dismiss(); + if (on_decompose_color) + on_decompose_color(); + }); + add_label->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent&) { + if (m_can_add_filament && !m_can_add_filament()) { + return; + } + auto on_add_filament = m_on_add_filament; + wxWindow* popup_parent = GetParent(); + wxWindow* color_anchor = m_dialog_anchor ? m_dialog_anchor : popup_parent; + m_closing_from_action = true; + Dismiss(); + wxColourData cd; + cd.SetChooseFull(true); + wxColourDialog dlg(popup_parent, &cd); + auto move_color_dialog = [&dlg, color_anchor]() { + dlg.Move(constrained_dialog_position(color_anchor, dlg.GetBestSize())); + }; + dlg.Bind(wxEVT_SHOW, [move_color_dialog](wxShowEvent& e) mutable { + e.Skip(); + if (e.IsShown()) + move_color_dialog(); + }); + move_color_dialog(); + if (dlg.ShowModal() == wxID_OK) { + wxColour clr = dlg.GetColourData().GetColour(); + if (on_add_filament) on_add_filament(clr); + } + }); + + m_content->SetSizer(outer); + m_content->FitInside(); + + auto* top_sizer = new wxBoxSizer(wxVERTICAL); + int list_h = outer->GetMinSize().y; + if (m_colors_rgba.size() > max_visible_rows) + list_h -= ((int)m_colors_rgba.size() - max_visible_rows) * row_h; + m_content->SetMinSize(wxSize(pop_w, list_h)); + m_content->SetMaxSize(wxSize(pop_w, list_h)); + top_sizer->Add(m_content, 0, wxEXPAND); + + top_sizer->AddSpacer(FromDIP(4)); + auto* sep_line = new StaticLine(this); + sep_line->SetLineColour(wxColour(SEPARATOR_COLOUR_KEY)); + top_sizer->Add(sep_line, 0, wxEXPAND | wxLEFT | wxRIGHT, pad); + top_sizer->Add(decompose_label, 0, wxALIGN_CENTER_HORIZONTAL | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, pad); + auto* sep_line2 = new StaticLine(this); + sep_line2->SetLineColour(wxColour(SEPARATOR_COLOUR_KEY)); + top_sizer->Add(sep_line2, 0, wxEXPAND | wxLEFT | wxRIGHT, pad); + top_sizer->Add(add_label, 0, wxALIGN_CENTER_HORIZONTAL | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, pad); + SetSizerAndFit(top_sizer); + + SetSize(pop_w, top_sizer->GetMinSize().y); + } + +private: + void OnDismiss() override + { + restore_cursor_state(); + if (m_on_close) m_on_close(m_closing_from_action); + m_closing_from_action = false; + wxPopupTransientWindow::OnDismiss(); + schedule_destroy(); + } + + void restore_cursor_state() + { + SetCursor(wxNullCursor); + if (m_content) + m_content->SetCursor(wxNullCursor); + if (m_dialog_anchor) + m_dialog_anchor->SetCursor(wxCursor(wxCURSOR_HAND)); + wxSetCursor(wxNullCursor); + } + + void schedule_destroy() + { + if (m_destroy_scheduled) + return; + m_destroy_scheduled = true; + CallAfter([this]() { Destroy(); }); + } + + wxPanel* create_item_row(size_t idx, int row_h) + { + wxColour row_bg = StateColor::darkModeColorFor(*wxWHITE); + wxColour hover_bg = StateColor::darkModeColorFor(wxColour("#F4F4F4")); + wxColour name_fg = texture_import_text_colour(); + + wxPanel* row = new wxPanel(m_content, wxID_ANY, wxDefaultPosition, wxSize(-1, row_h)); + row->SetBackgroundColour(row_bg); + row->SetBackgroundStyle(wxBG_STYLE_PAINT); + row->SetCursor(wxCursor(wxCURSOR_HAND)); + + const int sq = row->FromDIP(24); + const int sq_r = row->FromDIP(2); + const int sq_x = row->FromDIP(4); + const int gap1 = row->FromDIP(8); + + wxColour fil_clr = idx < m_colors_rgba.size() + ? wxColour((unsigned char)(m_colors_rgba[idx][0] * 255.f), + (unsigned char)(m_colors_rgba[idx][1] * 255.f), + (unsigned char)(m_colors_rgba[idx][2] * 255.f)) + : wxColour(128, 128, 128); + + wxString name_str = (idx < m_names.size()) ? filament_name_to_wx_string(m_names[idx]) + : wxString::Format("Filament %d", display_number((int)idx)); + row->SetToolTip(name_str); + + row->Bind(wxEVT_PAINT, [this, idx, sq, sq_r, sq_x, gap1, fil_clr, name_str, row_bg, hover_bg, name_fg](wxPaintEvent& e) { + auto* p = static_cast(e.GetEventObject()); + wxAutoBufferedPaintDC dc(p); + wxSize sz = p->GetClientSize(); + + bool hovered = (m_hover_idx == (int)idx); + dc.SetBrush(wxBrush(hovered ? hover_bg : row_bg)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.x, sz.y); + + int sq_y = (sz.y - sq) / 2; + wxColour paint_clr = fil_clr; + if (idx < m_colors_rgba.size()) { + paint_clr = wxColour((unsigned char)(m_colors_rgba[idx][0] * 255.f), + (unsigned char)(m_colors_rgba[idx][1] * 255.f), + (unsigned char)(m_colors_rgba[idx][2] * 255.f)); + } + dc.SetBrush(wxBrush(paint_clr)); + dc.DrawRoundedRectangle(sq_x, sq_y, sq, sq, sq_r); + draw_filament_swatch_border(dc, paint_clr, sq_x, sq_y, sq, sq, sq_r); + + { + wxFont nf = p->GetFont(); + nf.SetPointSize(9); + dc.SetFont(nf); + dc.SetTextForeground(paint_clr.GetLuminance() < 0.6 ? *wxWHITE : texture_import_gray9000()); + wxString ns = wxString::Format("%d", display_number((int)idx)); + wxSize tsz = dc.GetTextExtent(ns); + dc.DrawText(ns, sq_x + (sq - tsz.x) / 2, sq_y + (sq - tsz.y) / 2); + } + + // Material name + { + wxFont mf = p->GetFont(); + mf.SetPointSize(10); + dc.SetFont(mf); + dc.SetTextForeground(name_fg); + int tx = sq_x + sq + gap1; + wxString display = ellipsize_text(dc, name_str, sz.x - tx - p->FromDIP(4)); + wxSize tsz = dc.GetTextExtent(display); + if (!display.empty()) + dc.DrawText(display, tx, (sz.y - tsz.y) / 2); + } + }); + + row->Bind(wxEVT_MOTION, [this, idx](wxMouseEvent& evt) { + if (m_hover_idx != (int)idx) { + m_hover_idx = (int)idx; + m_content->Refresh(); + } + evt.Skip(); + }); + row->Bind(wxEVT_LEAVE_WINDOW, [this](wxMouseEvent& evt) { + if (m_hover_idx != -1) { + m_hover_idx = -1; + m_content->Refresh(); + } + evt.Skip(); + }); + + row->Bind(wxEVT_LEFT_DOWN, [this, idx](wxMouseEvent&) { + if (m_on_select) m_on_select((int)idx); + m_closing_from_action = true; + Dismiss(); + }); + + return row; + } + + wxPanel* create_mixed_item_row(const TextureFilamentEntry& entry, int row_h) + { + wxColour row_bg = StateColor::darkModeColorFor(*wxWHITE); + wxColour hover_bg = StateColor::darkModeColorFor(wxColour("#F4F4F4")); + wxColour name_fg = texture_import_text_colour(); + const int idx = entry.dialog_index; + + wxPanel* row = new wxPanel(m_content, wxID_ANY, wxDefaultPosition, wxSize(-1, row_h)); + row->SetBackgroundColour(row_bg); + row->SetBackgroundStyle(wxBG_STYLE_PAINT); + row->SetCursor(wxCursor(wxCURSOR_HAND)); + row->SetToolTip(entry.name.empty() ? wxString::Format("Filament %d", display_number(idx)) : filament_name_to_wx_string(entry.name)); + + row->Bind(wxEVT_PAINT, [this, entry, idx, row_bg, hover_bg, name_fg](wxPaintEvent& e) { + auto* p = static_cast(e.GetEventObject()); + wxAutoBufferedPaintDC dc(p); + wxSize sz = p->GetClientSize(); + const bool hovered = (m_hover_idx == idx); + dc.SetBrush(wxBrush(hovered ? hover_bg : row_bg)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.x, sz.y); + + wxFont font = p->GetFont(); + font.SetPointSize(9); + dc.SetFont(font); + int x = p->FromDIP(2); + const int sw = p->FromDIP(22); + const int sw_r = p->FromDIP(2); + const int y = (sz.y - sw) / 2; + + for (size_t ci = 0; ci < entry.mixed_components.size() && ci < entry.mixed_ratios.size(); ++ci) { + if (ci > 0) { + dc.SetTextForeground(name_fg); + wxString plus = "+"; + wxSize psz = dc.GetTextExtent(plus); + dc.DrawText(plus, x, (sz.y - psz.y) / 2); + x += psz.x + p->FromDIP(4); + } + + const unsigned int comp_id = entry.mixed_components[ci]; + const int comp_dialog_idx = comp_id >= 1 ? (int)comp_id - 1 : -1; + wxColour comp_clr("#D9D9D9"); + if (comp_dialog_idx >= 0 && comp_dialog_idx < (int)m_colors_rgba.size()) { + const auto& c = m_colors_rgba[comp_dialog_idx]; + comp_clr = wxColour((unsigned char)(c[0] * 255.f), + (unsigned char)(c[1] * 255.f), + (unsigned char)(c[2] * 255.f)); + } + + dc.SetBrush(wxBrush(comp_clr)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRoundedRectangle(x, y, sw, sw, sw_r); + draw_filament_swatch_border(dc, comp_clr, x, y, sw, sw, sw_r); + + wxString num = wxString::Format("%d", display_number(comp_dialog_idx)); + wxSize nsz = dc.GetTextExtent(num); + dc.SetTextForeground(comp_clr.GetLuminance() < 0.6 ? *wxWHITE : texture_import_gray9000()); + dc.DrawText(num, x + (sw - nsz.x) / 2, y + (sw - nsz.y) / 2); + x += sw + p->FromDIP(4); + + dc.SetTextForeground(name_fg); + wxString pct = wxString::Format("%d%%", entry.mixed_ratios[ci]); + wxSize psz = dc.GetTextExtent(pct); + dc.DrawText(pct, x, (sz.y - psz.y) / 2); + x += psz.x + p->FromDIP(4); + } + }); + + row->Bind(wxEVT_MOTION, [this, idx](wxMouseEvent& evt) { + if (m_hover_idx != idx) { + m_hover_idx = idx; + m_content->Refresh(); + } + evt.Skip(); + }); + row->Bind(wxEVT_LEAVE_WINDOW, [this](wxMouseEvent& evt) { + if (m_hover_idx != -1) { + m_hover_idx = -1; + m_content->Refresh(); + } + evt.Skip(); + }); + row->Bind(wxEVT_LEFT_DOWN, [this, idx](wxMouseEvent&) { + if (m_on_select) m_on_select(idx); + m_closing_from_action = true; + Dismiss(); + }); + + return row; + } + + wxScrolledWindow* m_content = nullptr; + std::vector m_entries; + std::vector> m_colors_rgba; + std::vector m_names; + size_t m_existing_count = 0; + wxWindow* m_dialog_anchor = nullptr; + std::function m_on_select; + std::function m_on_add_filament; + std::function m_on_decompose_color; + std::function m_can_add_filament; + std::function m_on_close; + // 1-based display number per dialog_index, mirroring the post-apply + // sidebar ordering (ExistingPhysical, NewPhysical, ExistingMixed, NewMixed). + std::vector m_display_numbers; + int m_hover_idx = -1; + bool m_closing_from_action = false; + bool m_destroy_scheduled = false; + + // Returns the display number for a dialog_index, falling back to idx + 1 + // when no mapping is available (e.g. index out of range). + int display_number(int idx) const { + return (idx >= 0 && idx < (int)m_display_numbers.size() && m_display_numbers[idx] > 0) + ? m_display_numbers[idx] : idx + 1; + } +}; + +// ============================================================ +// AutoMixSelectPopup +// ============================================================ + +class AutoMixSelectPopup : public PopupWindow +{ +public: + AutoMixSelectPopup(wxWindow* parent, + TextureAutoMixMode current_mode, + int popup_width, + int font_point_size, + std::function on_select, + std::function on_close) + : PopupWindow(parent, wxBORDER_NONE | wxPU_CONTAINS_CONTROLS) + , m_current_mode(current_mode) + , m_font_point_size(font_point_size) + , m_on_select(std::move(on_select)) + , m_on_close(std::move(on_close)) + { + wxColour pop_bg = StateColor::darkModeColorFor(*wxWHITE); + SetBackgroundColour(pop_bg); + + auto* content = new wxPanel(this, wxID_ANY); + content->SetBackgroundColour(pop_bg); + content->SetBackgroundStyle(wxBG_STYLE_PAINT); + + auto* sizer = new wxBoxSizer(wxVERTICAL); + const int row_h = FromDIP(36); + const int pop_w = std::max(FromDIP(216), popup_width); + sizer->Add(create_item_row(content, TextureAutoMixMode::CMYW, row_h), 0, wxEXPAND); + sizer->Add(create_item_row(content, TextureAutoMixMode::RYBW, row_h), 0, wxEXPAND); + content->SetSizer(sizer); + content->SetMinSize(wxSize(pop_w, row_h * 2)); + + auto* top_sizer = new wxBoxSizer(wxVERTICAL); + top_sizer->Add(content, 0, wxEXPAND | wxALL, FromDIP(4)); + SetSizerAndFit(top_sizer); + SetSize(pop_w, top_sizer->GetMinSize().y); + } + +private: + void OnDismiss() override + { + if (m_on_close) + m_on_close(); + wxPopupTransientWindow::OnDismiss(); + CallAfter([this]() { Destroy(); }); + } + + wxPanel* create_item_row(wxWindow* parent, TextureAutoMixMode mode, int row_h) + { + wxColour row_bg = StateColor::darkModeColorFor(*wxWHITE); + wxColour hover_bg = StateColor::darkModeColorFor(wxColour("#F4F4F4")); + wxColour text_fg = texture_import_text_colour(); + wxColour accent = StateColor::darkModeColorFor(wxColour("#009688")); + + wxPanel* row = new wxPanel(parent, wxID_ANY, wxDefaultPosition, wxSize(-1, row_h), + wxTAB_TRAVERSAL | wxFULL_REPAINT_ON_RESIZE); + row->SetBackgroundColour(row_bg); + row->SetBackgroundStyle(wxBG_STYLE_PAINT); + row->SetCursor(wxCursor(wxCURSOR_HAND)); + + const int row_idx = mode == TextureAutoMixMode::CMYW ? 0 : 1; + row->Bind(wxEVT_PAINT, [this, row_bg, hover_bg, text_fg, accent, mode, row_idx](wxPaintEvent& e) { + auto* p = static_cast(e.GetEventObject()); + wxAutoBufferedPaintDC dc(p); + wxSize sz = p->GetClientSize(); + const bool hovered = (m_hover_idx == row_idx); + const bool selected = (m_current_mode == mode); + + dc.SetBrush(wxBrush(hovered ? hover_bg : row_bg)); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.x, sz.y); + + wxFont font = p->GetFont(); + font.SetPointSize(m_font_point_size); + dc.SetFont(font); + dc.SetTextForeground(text_fg); + wxString label = auto_mix_mode_label(mode); + wxSize tsz = dc.GetTextExtent(label); + dc.DrawText(label, p->FromDIP(12), (sz.y - tsz.y) / 2); + + if (selected) { + wxFont check_font = p->GetFont(); + check_font.SetPointSize(12); + check_font.MakeBold(); + dc.SetFont(check_font); + dc.SetTextForeground(accent); + wxString check = wxString::FromUTF8("✓"); + wxSize csz = dc.GetTextExtent(check); + dc.DrawText(check, sz.x - p->FromDIP(16) - csz.x, (sz.y - csz.y) / 2); + } + }); + + row->Bind(wxEVT_MOTION, [this, row, row_idx](wxMouseEvent& evt) { + if (m_hover_idx != row_idx) { + m_hover_idx = row_idx; + row->Refresh(); + } + evt.Skip(); + }); + row->Bind(wxEVT_LEAVE_WINDOW, [this, row](wxMouseEvent& evt) { + m_hover_idx = -1; + row->Refresh(); + evt.Skip(); + }); + row->Bind(wxEVT_LEFT_DOWN, [this, mode](wxMouseEvent&) { + if (m_on_select) + m_on_select(mode); + Dismiss(); + }); + + return row; + } + + TextureAutoMixMode m_current_mode; + int m_font_point_size = 10; + int m_hover_idx = -1; + std::function m_on_select; + std::function m_on_close; +}; + +// ============================================================ +// TexturePreviewCanvas +// ============================================================ + +TexturePreviewCanvas::TexturePreviewCanvas(wxWindow* parent, const wxGLAttributes& attrs) + : wxGLCanvas(parent, attrs, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxFULL_REPAINT_ON_RESIZE) +{ + m_context = new wxGLContext(this); + + Bind(wxEVT_PAINT, &TexturePreviewCanvas::on_paint, this); + Bind(wxEVT_SIZE, &TexturePreviewCanvas::on_size, this); + Bind(wxEVT_MOUSEWHEEL, &TexturePreviewCanvas::on_mouse, this); + Bind(wxEVT_LEFT_DOWN, &TexturePreviewCanvas::on_mouse, this); + Bind(wxEVT_LEFT_UP, &TexturePreviewCanvas::on_mouse, this); + Bind(wxEVT_RIGHT_DOWN, &TexturePreviewCanvas::on_mouse, this); + Bind(wxEVT_RIGHT_UP, &TexturePreviewCanvas::on_mouse, this); + Bind(wxEVT_MIDDLE_DOWN, &TexturePreviewCanvas::on_mouse, this); + Bind(wxEVT_MIDDLE_UP, &TexturePreviewCanvas::on_mouse, this); + Bind(wxEVT_MOTION, &TexturePreviewCanvas::on_mouse, this); + Bind(wxEVT_MOUSE_CAPTURE_LOST, [this](wxMouseCaptureLostEvent&) { + m_drag_mode = DragMode::None; + m_reset_overlay_pressed = false; + }); + Bind(wxEVT_LEAVE_WINDOW, &TexturePreviewCanvas::on_mouse, this); +} + +TexturePreviewCanvas::~TexturePreviewCanvas() +{ + // See MixedFilamentDialog::~MixedFilamentDialog: a widget destroyed while it + // still holds the capture wedges mouse input for the whole application. + if (HasCapture()) + ReleaseMouse(); + + if (m_context) { + SetCurrent(*m_context); + if (m_tex_id) + glDeleteTextures(1, &m_tex_id); + for (unsigned int id : m_gl_tex_ids) + if (id) glDeleteTextures(1, &id); + for (unsigned int id : {m_reset_icon_tex, m_reset_icon_hover_tex, + m_reset_icon_dark_tex, m_reset_icon_dark_hover_tex}) + if (id) glDeleteTextures(1, &id); + delete m_context; + } +} + +void TexturePreviewCanvas::set_mesh_data( + const std::vector>& vertices, + const std::vector>& indices) +{ + m_vertices = vertices; + m_indices = indices; + update_bounding_box(); + compute_smooth_normals(); + Refresh(); +} + +void TexturePreviewCanvas::compute_smooth_normals() +{ + m_vertex_normals.clear(); + if (m_vertices.empty() || m_indices.empty()) return; + + m_vertex_normals.resize(m_vertices.size(), {0.f, 0.f, 0.f}); + + for (const auto& face : m_indices) { + int i0 = face[0], i1 = face[1], i2 = face[2]; + if (i0 < 0 || i0 >= (int)m_vertices.size() || + i1 < 0 || i1 >= (int)m_vertices.size() || + i2 < 0 || i2 >= (int)m_vertices.size()) + continue; + + const auto& v0 = m_vertices[i0]; + const auto& v1 = m_vertices[i1]; + const auto& v2 = m_vertices[i2]; + + float nx = (v1[1]-v0[1])*(v2[2]-v0[2]) - (v1[2]-v0[2])*(v2[1]-v0[1]); + float ny = (v1[2]-v0[2])*(v2[0]-v0[0]) - (v1[0]-v0[0])*(v2[2]-v0[2]); + float nz = (v1[0]-v0[0])*(v2[1]-v0[1]) - (v1[1]-v0[1])*(v2[0]-v0[0]); + + m_vertex_normals[i0][0] += nx; m_vertex_normals[i0][1] += ny; m_vertex_normals[i0][2] += nz; + m_vertex_normals[i1][0] += nx; m_vertex_normals[i1][1] += ny; m_vertex_normals[i1][2] += nz; + m_vertex_normals[i2][0] += nx; m_vertex_normals[i2][1] += ny; m_vertex_normals[i2][2] += nz; + } + + for (auto& n : m_vertex_normals) { + float len = std::sqrt(n[0]*n[0] + n[1]*n[1] + n[2]*n[2]); + if (len > 1e-8f) { n[0] /= len; n[1] /= len; n[2] /= len; } + } +} + +void TexturePreviewCanvas::set_texture_data( + const std::vector>& uvs, + const unsigned char* tex_data, int tex_w, int tex_h, int tex_channels) +{ + m_uvs = uvs; + m_tex_w = tex_w; + m_tex_h = tex_h; + m_tex_channels = tex_channels; + m_tex_dirty = true; + + size_t sz = (size_t)tex_w * tex_h * tex_channels; + m_tex_data.assign(tex_data, tex_data + sz); + Refresh(); +} + +void TexturePreviewCanvas::set_texture_render_data( + const std::vector>& tex_pixels_rgb, + const std::vector& tex_widths, + const std::vector& tex_heights, + const std::vector, 3>>& face_uvs, + const std::vector& face_tex_ids) +{ + m_tex_pixels_rgb = tex_pixels_rgb; + m_tex_widths = tex_widths; + m_tex_heights = tex_heights; + m_face_uvs = face_uvs; + m_face_tex_ids = face_tex_ids; + m_multi_tex_dirty = true; + Refresh(); +} + +void TexturePreviewCanvas::upload_textures() +{ + if (!m_multi_tex_dirty) return; + m_multi_tex_dirty = false; + + for (unsigned int id : m_gl_tex_ids) + if (id) glDeleteTextures(1, &id); + m_gl_tex_ids.clear(); + + m_gl_tex_ids.resize(m_tex_pixels_rgb.size(), 0); + for (size_t i = 0; i < m_tex_pixels_rgb.size(); ++i) { + if (m_tex_pixels_rgb[i].empty() || m_tex_widths[i] <= 0 || m_tex_heights[i] <= 0) + continue; + GLuint tex_id = 0; + glGenTextures(1, &tex_id); + glBindTexture(GL_TEXTURE_2D, tex_id); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, m_tex_widths[i], m_tex_heights[i], + 0, GL_RGB, GL_UNSIGNED_BYTE, m_tex_pixels_rgb[i].data()); + m_gl_tex_ids[i] = tex_id; + } + glBindTexture(GL_TEXTURE_2D, 0); +} + +void TexturePreviewCanvas::set_painted_mesh_data( + const std::vector>& vertices, + const std::vector>& indices) +{ + m_painted_vertices = vertices; + m_painted_indices = indices; + Refresh(); +} + +static void convert_face_colors(const std::vector>& src, + std::vector>& dst) +{ + dst.resize(src.size()); + for (size_t i = 0; i < src.size(); ++i) + dst[i] = { src[i][0] / 255.f, src[i][1] / 255.f, src[i][2] / 255.f }; +} + +void TexturePreviewCanvas::set_face_colors(const std::vector>& face_colors) +{ + convert_face_colors(face_colors, m_face_colors_rgb); + Refresh(); +} + +void TexturePreviewCanvas::set_original_face_colors(const std::vector>& face_colors) +{ + convert_face_colors(face_colors, m_original_face_colors_rgb); + Refresh(); +} + +void TexturePreviewCanvas::set_filament_color_map( + const std::map, std::array>& color_map) +{ + m_color_map = color_map; + m_filament_colors_rgb.resize(m_face_colors_rgb.size()); + for (size_t i = 0; i < m_face_colors_rgb.size(); ++i) { + std::array key = { + (std::size_t)(m_face_colors_rgb[i][0] * 255.f + 0.5f), + (std::size_t)(m_face_colors_rgb[i][1] * 255.f + 0.5f), + (std::size_t)(m_face_colors_rgb[i][2] * 255.f + 0.5f) + }; + auto it = color_map.find(key); + if (it != color_map.end()) + m_filament_colors_rgb[i] = it->second; + else + m_filament_colors_rgb[i] = m_face_colors_rgb[i]; + } + Refresh(); +} + +void TexturePreviewCanvas::set_render_mode(RenderMode mode) +{ + if (m_mode != mode) { + m_mode = mode; + Refresh(); + } +} + +void TexturePreviewCanvas::set_computing_overlay(bool /*show*/) +{ + Refresh(); +} + +void TexturePreviewCanvas::reset_view() +{ + m_zoom = 1.0f; + m_rot_x = -30.0f; + m_rot_y = 30.0f; + m_pan_x = 0.0f; + m_pan_y = 0.0f; + Refresh(); +} + +wxRect TexturePreviewCanvas::reset_overlay_rect() const +{ + wxSize sz = GetClientSize(); + const int button_size = FromDIP(40); + const int margin = FromDIP(20); + return wxRect( + std::max(margin, sz.x - button_size - margin), + std::max(margin, sz.y - button_size - margin), + button_size, + button_size); +} + +unsigned int TexturePreviewCanvas::upload_reset_icon_texture(const std::string& icon_name) +{ + wxBitmap bmp = create_scaled_bitmap(icon_name, this, 40); + if (!bmp.IsOk()) + return 0; + + wxImage image = bmp.ConvertToImage(); + if (!image.IsOk()) + return 0; + + const int w = image.GetWidth(); + const int h = image.GetHeight(); + const unsigned char* rgb = image.GetData(); + const unsigned char* alpha = image.HasAlpha() ? image.GetAlpha() : nullptr; + if (!rgb || w <= 0 || h <= 0) + return 0; + + std::vector rgba((size_t)w * h * 4); + for (int i = 0; i < w * h; ++i) { + rgba[(size_t)i * 4 + 0] = rgb[i * 3 + 0]; + rgba[(size_t)i * 4 + 1] = rgb[i * 3 + 1]; + rgba[(size_t)i * 4 + 2] = rgb[i * 3 + 2]; + rgba[(size_t)i * 4 + 3] = alpha ? alpha[i] : 255; + } + + GLuint tex_id = 0; + glGenTextures(1, &tex_id); + glBindTexture(GL_TEXTURE_2D, tex_id); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, rgba.data()); + glBindTexture(GL_TEXTURE_2D, 0); + return tex_id; +} + +void TexturePreviewCanvas::upload_reset_icon_textures() +{ + if (m_reset_icon_tex && m_reset_icon_hover_tex && m_reset_icon_dark_tex && m_reset_icon_dark_hover_tex) + return; + + if (!m_reset_icon_tex) + m_reset_icon_tex = upload_reset_icon_texture("canvas_zoom"); + if (!m_reset_icon_hover_tex) + m_reset_icon_hover_tex = upload_reset_icon_texture("canvas_zoom_hover"); + if (!m_reset_icon_dark_tex) + m_reset_icon_dark_tex = upload_reset_icon_texture("canvas_zoom_dark"); + if (!m_reset_icon_dark_hover_tex) + m_reset_icon_dark_hover_tex = upload_reset_icon_texture("canvas_zoom_dark_hover"); +} + +bool TexturePreviewCanvas::handle_reset_overlay_mouse(wxMouseEvent& evt) +{ + if (evt.Leaving()) { + if (m_reset_overlay_pressed) { + m_reset_overlay_hovered = false; + m_reset_overlay_pressed = false; + SetCursor(wxCursor(wxCURSOR_ARROW)); + if (HasCapture()) + ReleaseMouse(); + Refresh(); + return true; + } + if (m_reset_overlay_hovered) { + m_reset_overlay_hovered = false; + SetCursor(wxCursor(wxCURSOR_ARROW)); + Refresh(); + } + return false; + } + + const bool over = reset_overlay_rect().Contains(evt.GetPosition()); + if (over != m_reset_overlay_hovered) { + m_reset_overlay_hovered = over; + SetCursor(wxCursor(over ? wxCURSOR_HAND : wxCURSOR_ARROW)); + Refresh(); + } + + if (m_drag_mode != DragMode::None && !m_reset_overlay_pressed) + return false; + + if (evt.LeftDown() && over) { + m_reset_overlay_pressed = true; + if (!HasCapture()) + CaptureMouse(); + Refresh(); + return true; + } + + if (evt.LeftUp() && m_reset_overlay_pressed) { + const bool activate = over; + m_reset_overlay_pressed = false; + if (HasCapture()) + ReleaseMouse(); + if (activate) + reset_view(); + else + Refresh(); + return true; + } + + return over; +} + +void TexturePreviewCanvas::update_bounding_box() +{ + if (m_vertices.empty()) return; + std::array mn = m_vertices[0], mx = m_vertices[0]; + for (const auto& v : m_vertices) { + for (int i = 0; i < 3; ++i) { + mn[i] = std::min(mn[i], v[i]); + mx[i] = std::max(mx[i], v[i]); + } + } + m_center = { (mn[0]+mx[0])/2, (mn[1]+mx[1])/2, (mn[2]+mx[2])/2 }; + float dx = mx[0]-mn[0], dy = mx[1]-mn[1], dz = mx[2]-mn[2]; + m_radius = std::sqrt(dx*dx + dy*dy + dz*dz) / 2.0f; + if (m_radius < 1e-6f) m_radius = 1.0f; +} + +void TexturePreviewCanvas::ensure_gl_ready() +{ + if (m_gl_initialized) return; + + // BBS loads the GL entry points here with GLEW; Orca loads them centrally in + // OpenGLManager, so only check that this has already happened (glad leaves unresolved + // entry points null) and drain any stale error state. + if (glGetString == nullptr) { + BOOST_LOG_TRIVIAL(error) << "TexturePreviewCanvas: OpenGL functions are not loaded yet"; + return; + } + while (glGetError() != GL_NO_ERROR) {} + + m_gl_initialized = true; + + glEnable(GL_DEPTH_TEST); + glEnable(GL_LIGHTING); + glEnable(GL_LIGHT0); + glEnable(GL_COLOR_MATERIAL); + glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE); + + GLfloat light_pos[] = { 0.5f, 1.0f, 1.0f, 0.0f }; + GLfloat light_ambient[] = { 0.3f, 0.3f, 0.3f, 1.0f }; + GLfloat light_diffuse[] = { 0.8f, 0.8f, 0.8f, 1.0f }; + glLightfv(GL_LIGHT0, GL_POSITION, light_pos); + glLightfv(GL_LIGHT0, GL_AMBIENT, light_ambient); + glLightfv(GL_LIGHT0, GL_DIFFUSE, light_diffuse); +} + +void TexturePreviewCanvas::on_paint(wxPaintEvent&) +{ + wxPaintDC dc(this); + if (!m_context) return; + SetCurrent(*m_context); + ensure_gl_ready(); + render(); + SwapBuffers(); +} + +void TexturePreviewCanvas::on_size(wxSizeEvent&) +{ + Refresh(); +} + +void TexturePreviewCanvas::on_mouse(wxMouseEvent& evt) +{ + if (handle_reset_overlay_mouse(evt)) + return; + + if (evt.LeftDown()) { + m_drag_mode = DragMode::Rotate; + m_last_mouse_pos = evt.GetPosition(); + if (!HasCapture()) CaptureMouse(); + } + else if (evt.LeftUp()) { + if (m_drag_mode == DragMode::Rotate) { + m_drag_mode = DragMode::None; + if (HasCapture()) ReleaseMouse(); + } + } + else if (evt.RightDown()) { + m_drag_mode = DragMode::Pan; + m_last_mouse_pos = evt.GetPosition(); + if (!HasCapture()) CaptureMouse(); + } + else if (evt.MiddleDown()) { + m_drag_mode = DragMode::Pan; + m_last_mouse_pos = evt.GetPosition(); + if (!HasCapture()) CaptureMouse(); + } + else if (evt.RightUp() || evt.MiddleUp()) { + if (m_drag_mode == DragMode::Pan) { + m_drag_mode = DragMode::None; + if (HasCapture()) ReleaseMouse(); + } + } + else if (evt.Dragging() && m_drag_mode != DragMode::None) { + wxPoint pos = evt.GetPosition(); + float dx = (float)(pos.x - m_last_mouse_pos.x); + float dy = (float)(pos.y - m_last_mouse_pos.y); + + if (m_drag_mode == DragMode::Rotate) { + m_rot_y += dx * 0.5f; + m_rot_x += dy * 0.5f; + m_rot_x = std::max(-89.0f, std::min(89.0f, m_rot_x)); + } else if (m_drag_mode == DragMode::Pan) { + wxSize sz = GetClientSize(); + if (sz.x > 0) + m_pan_x += dx / (float)sz.x * m_radius * 2.0f / m_zoom; + if (sz.y > 0) + m_pan_y -= dy / (float)sz.y * m_radius * 2.0f / m_zoom; + } + + m_last_mouse_pos = pos; + Refresh(); + } + else if (evt.GetWheelRotation() != 0) { + float delta = evt.GetWheelRotation() > 0 ? 1.1f : 0.9f; + m_zoom *= delta; + m_zoom = std::max(0.1f, std::min(20.0f, m_zoom)); + Refresh(); + } +} + +void TexturePreviewCanvas::render() +{ + wxSize sz = GetClientSize(); + if (sz.x <= 0 || sz.y <= 0) return; + + wxSize viewport_sz = gl_viewport_size(this, sz); + glViewport(0, 0, viewport_sz.x, viewport_sz.y); + // Same palette key as the preview container, so canvas and frame cannot drift apart. + const wxColour clear_clr = StateColor::darkModeColorFor(wxColour("#EEEEEE")); + glClearColor(clear_clr.Red() / 255.f, clear_clr.Green() / 255.f, clear_clr.Blue() / 255.f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + + glMatrixMode(GL_PROJECTION); + glLoadIdentity(); + float aspect = (float)viewport_sz.x / (float)viewport_sz.y; + float dist = m_radius * 3.0f / m_zoom; + float near_plane = dist * 0.01f; + float far_plane = dist * 10.0f; + float fov_rad = 45.0f * static_cast(M_PI) / 180.0f; + float f = 1.0f / std::tan(fov_rad / 2.0f); + float proj[16] = {}; + proj[0] = f / aspect; + proj[5] = f; + proj[10] = (far_plane + near_plane) / (near_plane - far_plane); + proj[11] = -1.0f; + proj[14] = (2.0f * far_plane * near_plane) / (near_plane - far_plane); + glMultMatrixf(proj); + + glMatrixMode(GL_MODELVIEW); + glLoadIdentity(); + glTranslatef(0.0f, 0.0f, -dist); + glTranslatef(m_pan_x, m_pan_y, 0.0f); + glRotatef(m_rot_x, 1.0f, 0.0f, 0.0f); + glRotatef(m_rot_y, 0.0f, 1.0f, 0.0f); + glTranslatef(-m_center[0], -m_center[1], -m_center[2]); + + render_mesh(); + render_reset_overlay(sz, viewport_sz); +} + +void TexturePreviewCanvas::render_reset_overlay(const wxSize& logical_size, const wxSize& viewport_size) +{ + if (logical_size.x <= 0 || logical_size.y <= 0 || viewport_size.x <= 0 || viewport_size.y <= 0) + return; + + upload_reset_icon_textures(); + + const unsigned int tex_id = is_dark() + ? (m_reset_overlay_hovered ? m_reset_icon_dark_hover_tex : m_reset_icon_dark_tex) + : (m_reset_overlay_hovered ? m_reset_icon_hover_tex : m_reset_icon_tex); + if (!tex_id) + return; + + wxRect rc = reset_overlay_rect(); + const float sx = (float)viewport_size.x / (float)logical_size.x; + const float sy = (float)viewport_size.y / (float)logical_size.y; + const float x0 = rc.GetLeft() * sx; + const float y0 = rc.GetTop() * sy; + const float x1 = (rc.GetLeft() + rc.GetWidth()) * sx; + const float y1 = (rc.GetTop() + rc.GetHeight()) * sy; + const float alpha = m_reset_overlay_hovered ? 1.0f : 0.78f; + + glPushAttrib(GL_ENABLE_BIT | GL_COLOR_BUFFER_BIT | GL_TEXTURE_BIT | GL_DEPTH_BUFFER_BIT); + glDisable(GL_DEPTH_TEST); + glDisable(GL_LIGHTING); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glEnable(GL_TEXTURE_2D); + glBindTexture(GL_TEXTURE_2D, tex_id); + glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE); + + glMatrixMode(GL_PROJECTION); + glPushMatrix(); + glLoadIdentity(); + glOrtho(0.0, viewport_size.x, viewport_size.y, 0.0, -1.0, 1.0); + + glMatrixMode(GL_MODELVIEW); + glPushMatrix(); + glLoadIdentity(); + + glColor4f(1.0f, 1.0f, 1.0f, alpha); + glBegin(GL_QUADS); + glTexCoord2f(0.0f, 0.0f); glVertex2f(x0, y0); + glTexCoord2f(1.0f, 0.0f); glVertex2f(x1, y0); + glTexCoord2f(1.0f, 1.0f); glVertex2f(x1, y1); + glTexCoord2f(0.0f, 1.0f); glVertex2f(x0, y1); + glEnd(); + + glPopMatrix(); + glMatrixMode(GL_PROJECTION); + glPopMatrix(); + glMatrixMode(GL_MODELVIEW); + + glBindTexture(GL_TEXTURE_2D, 0); + glPopAttrib(); +} + +void TexturePreviewCanvas::render_textured_original() +{ + if (m_vertices.empty() || m_indices.empty()) return; + if (m_face_uvs.empty() || m_face_tex_ids.empty()) return; + if (m_face_uvs.size() != m_indices.size()) return; + + upload_textures(); + + const bool has_smooth = (m_vertex_normals.size() == m_vertices.size()); + + // Group faces by texture id for batch rendering + std::map> tex_groups; + for (size_t fi = 0; fi < m_indices.size(); ++fi) { + int tid = (fi < m_face_tex_ids.size()) ? m_face_tex_ids[fi] : -1; + tex_groups[tid].push_back(fi); + } + + glEnable(GL_LIGHTING); + glColor3f(1.0f, 1.0f, 1.0f); + + for (const auto& [tid, face_list] : tex_groups) { + bool tex_bound = false; + if (tid >= 0 && tid < (int)m_gl_tex_ids.size() && m_gl_tex_ids[tid] != 0) { + glEnable(GL_TEXTURE_2D); + glBindTexture(GL_TEXTURE_2D, m_gl_tex_ids[tid]); + tex_bound = true; + } else { + glDisable(GL_TEXTURE_2D); + } + + glBegin(GL_TRIANGLES); + for (size_t fi : face_list) { + const auto& face = m_indices[fi]; + const auto& uvs = m_face_uvs[fi]; + + if (!tex_bound) { + if (fi < m_original_face_colors_rgb.size()) + glColor3fv(m_original_face_colors_rgb[fi].data()); + else + glColor3f(0.7f, 0.7f, 0.7f); + } + + for (int vi = 0; vi < 3; ++vi) { + int idx = face[vi]; + if (idx < 0 || idx >= (int)m_vertices.size()) continue; + + if (has_smooth) { + glNormal3fv(m_vertex_normals[idx].data()); + } else if (vi == 0) { + const auto& v0 = m_vertices[face[0]]; + const auto& v1 = m_vertices[face[1]]; + const auto& v2 = m_vertices[face[2]]; + float nx = (v1[1]-v0[1])*(v2[2]-v0[2]) - (v1[2]-v0[2])*(v2[1]-v0[1]); + float ny = (v1[2]-v0[2])*(v2[0]-v0[0]) - (v1[0]-v0[0])*(v2[2]-v0[2]); + float nz = (v1[0]-v0[0])*(v2[1]-v0[1]) - (v1[1]-v0[1])*(v2[0]-v0[0]); + float len = std::sqrt(nx*nx + ny*ny + nz*nz); + if (len > 1e-8f) { nx /= len; ny /= len; nz /= len; } + glNormal3f(nx, ny, nz); + } + + if (tex_bound) + glTexCoord2fv(uvs[vi].data()); + glVertex3fv(m_vertices[idx].data()); + } + } + glEnd(); + } + + glDisable(GL_TEXTURE_2D); + glBindTexture(GL_TEXTURE_2D, 0); +} + +void TexturePreviewCanvas::render_mesh() +{ + if (m_vertices.empty() || m_indices.empty()) return; + + // Original mode with texture data: use proper texture mapping + if (m_mode == RenderMode::Original && !m_face_uvs.empty()) { + render_textured_original(); + return; + } + + // For Multi-Color / FilamentMap, use the painted (remeshed) geometry if available; + // the face color arrays match the painted mesh, not the original mesh. + const bool use_painted = (m_mode != RenderMode::Original) + && !m_painted_vertices.empty() + && !m_painted_indices.empty(); + + const auto& verts = use_painted ? m_painted_vertices : m_vertices; + const auto& faces = use_painted ? m_painted_indices : m_indices; + + const std::vector>* colors_ptr = nullptr; + if (m_mode == RenderMode::Original && !m_original_face_colors_rgb.empty() + && m_original_face_colors_rgb.size() == m_indices.size()) { + colors_ptr = &m_original_face_colors_rgb; + } else if (m_mode == RenderMode::FilamentMap && !m_filament_colors_rgb.empty() + && m_filament_colors_rgb.size() == faces.size()) { + colors_ptr = &m_filament_colors_rgb; + } else if (!m_face_colors_rgb.empty() && m_face_colors_rgb.size() == faces.size()) { + colors_ptr = &m_face_colors_rgb; + } + + // Use smooth normals for the original mesh when available + const bool has_smooth = !use_painted + && (m_vertex_normals.size() == m_vertices.size()); + + glDisable(GL_TEXTURE_2D); + glEnable(GL_LIGHTING); + + glBegin(GL_TRIANGLES); + for (size_t fi = 0; fi < faces.size(); ++fi) { + if (colors_ptr) + glColor3fv((*colors_ptr)[fi].data()); + else + glColor3f(0.7f, 0.7f, 0.7f); + + const auto& face = faces[fi]; + for (int vi = 0; vi < 3; ++vi) { + int idx = face[vi]; + if (idx < 0 || idx >= (int)verts.size()) continue; + + if (has_smooth && idx < (int)m_vertex_normals.size()) { + glNormal3fv(m_vertex_normals[idx].data()); + } else if (vi == 0) { + const auto& v0 = verts[face[0]]; + const auto& v1 = verts[face[1]]; + const auto& v2 = verts[face[2]]; + float nx = (v1[1]-v0[1])*(v2[2]-v0[2]) - (v1[2]-v0[2])*(v2[1]-v0[1]); + float ny = (v1[2]-v0[2])*(v2[0]-v0[0]) - (v1[0]-v0[0])*(v2[2]-v0[2]); + float nz = (v1[0]-v0[0])*(v2[1]-v0[1]) - (v1[1]-v0[1])*(v2[0]-v0[0]); + float len = std::sqrt(nx*nx + ny*ny + nz*nz); + if (len > 1e-8f) { nx /= len; ny /= len; nz /= len; } + glNormal3f(nx, ny, nz); + } + + glVertex3fv(verts[idx].data()); + } + } + glEnd(); +} + + +// ============================================================ +// TextureImportDialog +// ============================================================ + +wxBEGIN_EVENT_TABLE(TextureImportDialog, DPIDialog) + EVT_BUTTON(TextureImportDialog::ID_COLOR_4, TextureImportDialog::on_color_preset_clicked) + EVT_BUTTON(TextureImportDialog::ID_COLOR_8, TextureImportDialog::on_color_preset_clicked) + EVT_BUTTON(TextureImportDialog::ID_COLOR_16, TextureImportDialog::on_color_preset_clicked) + EVT_BUTTON(TextureImportDialog::ID_COLOR_AUTO, TextureImportDialog::on_color_preset_clicked) + EVT_BUTTON(TextureImportDialog::ID_BTN_APPLY, TextureImportDialog::on_apply_clicked) + EVT_BUTTON(TextureImportDialog::ID_BTN_SKIP, TextureImportDialog::on_skip_clicked) + EVT_BUTTON(wxID_OK, TextureImportDialog::on_ok_clicked) +wxEND_EVENT_TABLE() + +TextureImportDialog::TextureImportDialog( + wxWindow* parent, + const Slic3r::TexturedMesh& textured_mesh, + const std::vector& filament_entries, + std::function initial_cancel_callback, + std::function initial_progress_callback) + : DPIDialog(parent, wxID_ANY, _L("Import Model"), + wxDefaultPosition, wxDefaultSize, + (wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER) & ~(wxMINIMIZE_BOX | wxMAXIMIZE_BOX)) + , m_textured_mesh(textured_mesh) + , m_filament_entries(filament_entries) + , m_initial_cancel_callback(std::move(initial_cancel_callback)) + , m_initial_progress_callback(std::move(initial_progress_callback)) +{ + SetSize(wxSize(FromDIP(960), FromDIP(640))); + + m_filament_colors_rgba.reserve(m_filament_entries.size()); + m_filament_color_strs.reserve(m_filament_entries.size()); + m_filament_names.reserve(m_filament_entries.size()); + for (size_t i = 0; i < m_filament_entries.size(); ++i) { + auto& entry = m_filament_entries[i]; + entry.dialog_index = (int)i; + entry.color_hex = texture_normalize_color_hex(entry.color_hex); + if (entry.name.empty()) + entry.name = "Filament " + std::to_string(i + 1); + m_filament_color_strs.push_back(entry.color_hex); + m_filament_names.push_back(entry.name); + m_filament_colors_rgba.push_back(parse_color_string(entry.color_hex)); + } + + m_existing_filament_count = m_filament_colors_rgba.size(); + m_default_virtual_filament_preset_name = resolve_default_virtual_filament_preset_name(); + + Bind(EVT_TEXTURE_COMPUTE_DONE, &TextureImportDialog::on_computation_complete, this); + Bind(EVT_TEXTURE_COMPUTE_PROGRESS, &TextureImportDialog::on_computation_progress, this); + Bind(EVT_TEXTURE_COMPUTE_ERROR, &TextureImportDialog::on_computation_error, this); + Bind(EVT_TEXTURE_MESH_REPAIR_DECISION, &TextureImportDialog::on_mesh_repair_decision_required, this); + + build_ui(); + SetMinSize(wxSize(FromDIP(800), FromDIP(500))); + CenterOnParent(); + wxGetApp().UpdateDlgDarkUI(this); + + m_preview_canvas->set_mesh_data(m_textured_mesh.vertices, m_textured_mesh.indices); + + // Pre-computed face colors (OBJ vertex colors / MTL face colors): + // use them directly as the Original preview, skip texture decode. + if (!m_textured_mesh.precomputed_face_colors.empty()) { + m_preview_canvas->set_original_face_colors(m_textured_mesh.precomputed_face_colors); + } else if (!m_textured_mesh.textures.empty()) { + std::vector> tex_pixels_rgb; + std::vector tex_widths, tex_heights; + tex_pixels_rgb.reserve(m_textured_mesh.textures.size()); + tex_widths.reserve(m_textured_mesh.textures.size()); + tex_heights.reserve(m_textured_mesh.textures.size()); + + for (const auto& ti : m_textured_mesh.textures) { + std::vector bgr_pixels; + int w = 0, h = 0; + if (Slic3r::decode_texture_to_pixels(ti, bgr_pixels, w, h) && !bgr_pixels.empty()) { + // Convert BGR to RGB for OpenGL + for (size_t p = 0; p < bgr_pixels.size(); p += 3) + std::swap(bgr_pixels[p], bgr_pixels[p + 2]); + tex_pixels_rgb.push_back(std::move(bgr_pixels)); + } else { + tex_pixels_rgb.push_back({}); + } + tex_widths.push_back(w); + tex_heights.push_back(h); + } + + const size_t nf = m_textured_mesh.indices.size(); + const bool has_mapping = !m_textured_mesh.material_texture_map.empty(); + + // Build per-face UV array + std::vector, 3>> face_uvs(nf); + for (size_t fi = 0; fi < nf; ++fi) { + if (m_textured_mesh.has_face_uvs()) { + const auto& ui = m_textured_mesh.uv_indices[fi]; + for (int vi = 0; vi < 3; ++vi) { + int idx = ui[vi]; + if (idx >= 0 && static_cast(idx) < m_textured_mesh.uv_coords.size()) + face_uvs[fi][vi] = m_textured_mesh.uv_coords[idx]; + else + face_uvs[fi][vi] = {0.f, 0.f}; + } + } else if (!m_textured_mesh.uvs.empty()) { + const auto& face = m_textured_mesh.indices[fi]; + for (int vi = 0; vi < 3; ++vi) { + int idx = face[vi]; + if (idx >= 0 && static_cast(idx) < m_textured_mesh.uvs.size()) + face_uvs[fi][vi] = m_textured_mesh.uvs[idx]; + else + face_uvs[fi][vi] = {0.f, 0.f}; + } + } + } + + // Build per-face texture index + std::vector face_tex_ids(nf, 0); + for (size_t fi = 0; fi < nf; ++fi) { + int mat_idx = (fi < m_textured_mesh.material_ids.size()) + ? m_textured_mesh.material_ids[fi] : -1; + if (has_mapping && mat_idx >= 0 + && static_cast(mat_idx) < m_textured_mesh.material_texture_map.size()) + face_tex_ids[fi] = m_textured_mesh.material_texture_map[mat_idx]; + else if (!tex_pixels_rgb.empty()) + face_tex_ids[fi] = 0; + else + face_tex_ids[fi] = -1; + } + + m_preview_canvas->set_texture_render_data( + tex_pixels_rgb, tex_widths, tex_heights, face_uvs, face_tex_ids); + + // Still sample per-face colors as fallback + std::vector> orig_colors; + if (Slic3r::sample_original_face_colors(m_textured_mesh, orig_colors)) + m_preview_canvas->set_original_face_colors(orig_colors); + } + + set_state(TextureImportState::Idle); +} + +TextureImportDialog::~TextureImportDialog() +{ + dismiss_auto_mix_popup(); + dismiss_filament_popup(); + m_cancel_flag = true; + if (m_worker && m_worker->joinable()) + m_worker->join(); +} + +int TextureImportDialog::ShowModal() +{ + if (m_state == TextureImportState::Idle && m_painted.face_colors.empty()) { + start_computation(true, true); + + while (m_initial_computation_pending) { + if (auto* event_loop = wxEventLoopBase::GetActive()) + event_loop->Yield(); + else + wxYield(); + if (m_progress_dlg && m_progress_dlg->WasCancelled()) + m_cancel_flag = true; + if (m_initial_cancel_callback && m_initial_cancel_callback()) + m_cancel_flag = true; + wxMilliSleep(10); + } + + if (m_worker && m_worker->joinable()) + m_worker->join(); + m_worker.reset(); + + if (m_initial_computation_cancelled || m_initial_computation_failed) + return wxID_CANCEL; + } + + ScopedInteractiveBusyCursorSuspender busy_cursor_suspender; + return DPIDialog::ShowModal(); +} + +void TextureImportDialog::build_ui() +{ + const wxColour dialog_bg = StateColor::darkModeColorFor(*wxWHITE); + SetBackgroundColour(dialog_bg); + SetForegroundColour(StateColor::darkModeColorFor(wxColour("#323A3D"))); + + wxBoxSizer* root_sizer = new wxBoxSizer(wxVERTICAL); + + auto line_top = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxSize(-1, 1)); + line_top->SetBackgroundColour(texture_import_separator_colour()); + root_sizer->Add(line_top, 0, wxEXPAND); + + wxBoxSizer* main_sizer = new wxBoxSizer(wxHORIZONTAL); + + wxBoxSizer* left_sizer = new wxBoxSizer(wxVERTICAL); + build_preview_panel(this, left_sizer); + main_sizer->Add(left_sizer, 3, wxEXPAND | wxALL, FromDIP(8)); + + wxBoxSizer* right_sizer = new wxBoxSizer(wxVERTICAL); + build_params_panel(this, right_sizer); + build_mapping_panel(this, right_sizer); + build_bottom_buttons(right_sizer); + main_sizer->Add(right_sizer, 2, wxEXPAND | wxALL, FromDIP(8)); + + root_sizer->Add(main_sizer, 1, wxEXPAND); + + SetSizer(root_sizer); + Layout(); + Bind(wxEVT_MOUSEWHEEL, &TextureImportDialog::dismiss_filament_popup_on_wheel, this); + +#ifdef __WXMSW__ + wxPanel* size_grip_cover = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE); + size_grip_cover->SetBackgroundColour(dialog_bg); + size_grip_cover->SetBackgroundStyle(wxBG_STYLE_COLOUR); + + auto update_size_grip_cover = [this, size_grip_cover]() { + const int cover_size = FromDIP(20); + wxSize client_size = GetClientSize(); + size_grip_cover->SetSize(client_size.x - cover_size, client_size.y - cover_size, cover_size, cover_size); + size_grip_cover->Raise(); + }; + update_size_grip_cover(); + + Bind(wxEVT_SIZE, [update_size_grip_cover](wxSizeEvent& e) { + e.Skip(); + update_size_grip_cover(); + }); +#endif +} + +void TextureImportDialog::build_preview_panel(wxWindow* parent, wxSizer* sizer) +{ + wxColour preview_bg = StateColor::darkModeColorFor(wxColour("#EEEEEE")); + wxColour preview_bd = texture_import_separator_colour(); + + wxPanel* preview_container = new wxPanel(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE); + preview_container->SetBackgroundColour(preview_bg); + preview_container->SetBackgroundStyle(wxBG_STYLE_PAINT); + preview_container->Bind(wxEVT_PAINT, [preview_bg, preview_bd](wxPaintEvent& e) { + auto* p = static_cast(e.GetEventObject()); + wxAutoBufferedPaintDC dc(p); + wxSize sz = p->GetClientSize(); + dc.SetBrush(wxBrush(p->GetParent()->GetBackgroundColour())); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.x, sz.y); + dc.SetBrush(wxBrush(preview_bg)); + dc.SetPen(wxPen(preview_bd, 1)); + dc.DrawRoundedRectangle(0, 0, sz.x, sz.y, 4); + }); + + wxBoxSizer* container_sizer = new wxBoxSizer(wxVERTICAL); + + wxGLAttributes canvas_attrs; + canvas_attrs.PlatformDefaults().RGBA().DoubleBuffer().Depth(24).EndList(); + m_preview_canvas = new TexturePreviewCanvas(preview_container, canvas_attrs); + container_sizer->Add(m_preview_canvas, 1, wxEXPAND | wxALL, FromDIP(1)); + + preview_container->SetSizer(container_sizer); + sizer->Add(preview_container, 1, wxEXPAND); + + m_tab_panel = new wxPanel(preview_container, wxID_ANY); + m_tab_panel->SetBackgroundColour(preview_bg); + + m_btn_view_original = new Button(m_tab_panel, _L("Original")); + m_btn_view_original->SetId(ID_VIEW_ORIGINAL); + m_btn_view_multicolor = new Button(m_tab_panel, _L("Multi-Color")); + m_btn_view_multicolor->SetId(ID_VIEW_MULTICOLOR); + + const int view_button_height = FromDIP(27); + m_btn_view_original->SetCornerRadius(view_button_height / 2); + m_btn_view_original->SetMinSize(wxSize(FromDIP(57), view_button_height)); + m_btn_view_original->SetFont(m_btn_view_original->GetFont().Bold()); + m_btn_view_original->SetToolTip(_L("Your input texture model")); + m_btn_view_multicolor->SetCornerRadius(view_button_height / 2); + m_btn_view_multicolor->SetMinSize(wxSize(FromDIP(57), view_button_height)); + m_btn_view_multicolor->SetFont(m_btn_view_multicolor->GetFont().Bold()); + m_btn_view_multicolor->SetToolTip(_L("Processed multi-color model")); + + wxBoxSizer* tab_sizer = new wxBoxSizer(wxHORIZONTAL); + tab_sizer->Add(m_btn_view_original, 0, wxRIGHT, FromDIP(2)); + tab_sizer->Add(m_btn_view_multicolor, 0); + m_tab_panel->SetSizer(tab_sizer); + m_tab_panel->Fit(); + + m_btn_view_multicolor->Hide(); + + auto preview_original = [this](wxMouseEvent& e) { + if (m_preview_canvas) { + m_preview_canvas->set_render_mode(TexturePreviewCanvas::RenderMode::Original); + highlight_view_button(0); + } + e.Skip(); + }; + auto preview_multicolor = [this](wxMouseEvent& e) { + if (m_preview_canvas) { + m_preview_canvas->set_render_mode(TexturePreviewCanvas::RenderMode::MultiColor); + highlight_view_button(1); + } + e.Skip(); + }; + auto restore_filament_if_outside = [this](wxMouseEvent& e) { + if (m_preview_canvas && m_tab_panel) { + wxWindow* event_window = wxDynamicCast(e.GetEventObject(), wxWindow); + wxPoint screen_pos = event_window ? event_window->ClientToScreen(e.GetPosition()) : wxGetMousePosition(); + wxPoint panel_pos = m_tab_panel->ScreenToClient(screen_pos); + if (!m_tab_panel->GetClientRect().Contains(panel_pos)) { + const bool mapping_ready = (m_state == TextureImportState::Ready); + m_preview_canvas->set_render_mode(mapping_ready ? TexturePreviewCanvas::RenderMode::FilamentMap : + TexturePreviewCanvas::RenderMode::Original); + highlight_view_button(-1); + } + } + e.Skip(); + }; + + m_btn_view_original->Bind(wxEVT_ENTER_WINDOW, preview_original); + m_btn_view_multicolor->Bind(wxEVT_ENTER_WINDOW, preview_multicolor); + m_btn_view_original->Bind(wxEVT_LEAVE_WINDOW, restore_filament_if_outside); + m_btn_view_multicolor->Bind(wxEVT_LEAVE_WINDOW, restore_filament_if_outside); + m_tab_panel->Bind(wxEVT_LEAVE_WINDOW, restore_filament_if_outside); + + auto update_preview_overlay_buttons = [this]() { + if (m_tab_panel) { + m_tab_panel->Fit(); + m_tab_panel->SetPosition(wxPoint(FromDIP(8), FromDIP(8))); + m_tab_panel->Raise(); + } + }; + + preview_container->Bind(wxEVT_SIZE, [update_preview_overlay_buttons](wxSizeEvent& e) { + e.Skip(); + update_preview_overlay_buttons(); + }); + update_preview_overlay_buttons(); + + highlight_view_button(-1); +} + +void TextureImportDialog::build_params_panel(wxWindow* parent, wxSizer* sizer) +{ + wxColour label_fg = StateColor::darkModeColorFor(wxColour("#323A3D")); + + wxBoxSizer* color_header_sizer = new wxBoxSizer(wxHORIZONTAL); + wxStaticText* lbl_colors = new wxStaticText(parent, wxID_ANY, _L("Color Count")); + lbl_colors->SetForegroundColour(label_fg); + lbl_colors->SetFont(lbl_colors->GetFont().Bold()); + color_header_sizer->Add(lbl_colors, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(8)); + + m_btn_color_4 = new Button(parent, "4"); + m_btn_color_4->SetId(ID_COLOR_4); + m_btn_color_8 = new Button(parent, "8"); + m_btn_color_8->SetId(ID_COLOR_8); + m_btn_color_16 = new Button(parent, "16"); + m_btn_color_16->SetId(ID_COLOR_16); + m_btn_color_auto = new Button(parent, _L("Auto")); + m_btn_color_auto->SetId(ID_COLOR_AUTO); + + { + StateColor preset_bg( + std::pair(wxColour(0, 137, 123), StateColor::Pressed | StateColor::Checked), + std::pair(wxColour(38, 166, 154), StateColor::Hovered | StateColor::Checked), + std::pair(wxColour(0, 150, 136), StateColor::Checked), + std::pair(wxColour("#CECECE"), StateColor::Pressed), + std::pair(wxColour("#EEEEEE"), StateColor::Hovered), + std::pair(*wxWHITE, StateColor::Normal)); + StateColor preset_bd( + std::pair(wxColour(0, 150, 136), StateColor::Checked), + std::pair(wxColour("#CECECE"), StateColor::Normal)); + StateColor preset_text( + std::pair(wxColour("#FFFFFE"), StateColor::Checked), + std::pair(wxColour("#323A3D"), StateColor::Normal)); + + for (auto* btn : {m_btn_color_4, m_btn_color_8, m_btn_color_16}) { + btn->SetCornerRadius(FromDIP(12)); + btn->SetMinSize(wxSize(FromDIP(28), FromDIP(28))); + btn->SetBackgroundColor(preset_bg); + btn->SetBorderColor(preset_bd); + btn->SetTextColor(preset_text); + } + } + + update_color_count_preset_buttons(); + + color_header_sizer->Add(m_btn_color_4, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(2)); + color_header_sizer->Add(m_btn_color_8, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(2)); + color_header_sizer->Add(m_btn_color_16, 0, wxALIGN_CENTER_VERTICAL); + sizer->Add(color_header_sizer, 0, wxBOTTOM, FromDIP(4)); + + wxBoxSizer* color_slider_sizer = new wxBoxSizer(wxHORIZONTAL); + m_color_slider = new AccentSlider(parent, m_param_color_count, 1, (int)max_filament_count()); + m_color_spin = new SpinInput(parent, wxString::Format("%d", m_param_color_count), + wxEmptyString, wxDefaultPosition, + wxSize(FromDIP(60), FromDIP(28)), + wxTE_PROCESS_ENTER, 1, (int)max_filament_count(), m_param_color_count); + + m_color_slider->Bind(wxEVT_SLIDER, &TextureImportDialog::on_color_slider_changed, this); + m_color_spin->Bind(wxEVT_SPINCTRL, &TextureImportDialog::on_color_spin_changed, this); + m_color_spin->Bind(EVT_SPINCTRL_TEXT, &TextureImportDialog::on_color_spin_text_changed, this); + + color_slider_sizer->Add(m_color_slider, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); + color_slider_sizer->Add(m_color_spin, 0, wxALIGN_CENTER_VERTICAL); + sizer->Add(color_slider_sizer, 0, wxEXPAND | wxBOTTOM, FromDIP(8)); + + wxStaticText* lbl_smooth = new wxStaticText(parent, wxID_ANY, _L("Smooth Level")); + lbl_smooth->SetForegroundColour(label_fg); + lbl_smooth->SetFont(lbl_smooth->GetFont().Bold()); + sizer->Add(lbl_smooth, 0, wxBOTTOM, FromDIP(4)); + + wxBoxSizer* smooth_sizer = new wxBoxSizer(wxHORIZONTAL); + m_smooth_slider = new AccentSlider(parent, m_param_smooth, 0, 10); + m_smooth_spin = new SpinInput(parent, wxString::Format("%d", m_param_smooth), + wxEmptyString, wxDefaultPosition, + wxSize(FromDIP(60), FromDIP(28)), + wxTE_PROCESS_ENTER, 0, 10, m_param_smooth); + + m_smooth_slider->Bind(wxEVT_SLIDER, &TextureImportDialog::on_smooth_slider_changed, this); + m_smooth_spin->Bind(wxEVT_SPINCTRL, &TextureImportDialog::on_smooth_spin_changed, this); + m_smooth_spin->Bind(EVT_SPINCTRL_TEXT, &TextureImportDialog::on_smooth_spin_text_changed, this); + + smooth_sizer->Add(m_smooth_slider, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, FromDIP(4)); + smooth_sizer->Add(m_smooth_spin, 0, wxALIGN_CENTER_VERTICAL); + sizer->Add(smooth_sizer, 0, wxEXPAND | wxBOTTOM, FromDIP(4)); + + m_btn_apply = new Button(parent, _L("Apply")); + m_btn_apply->SetId(ID_BTN_APPLY); + + { + StateColor btn_bg_white( + std::pair(wxColour("#CECECE"), StateColor::Pressed), + std::pair(wxColour("#EEEEEE"), StateColor::Hovered), + std::pair(*wxWHITE, StateColor::Normal)); + const wxColour btn_bd_accent = wxColour(0, 150, 136); + const wxColour btn_text_accent = wxColour(0, 150, 136); + + m_btn_color_auto->SetCornerRadius(FromDIP(12)); + m_btn_color_auto->SetMinSize(wxSize(FromDIP(60), FromDIP(28))); + m_btn_color_auto->SetBackgroundColor(btn_bg_white); + m_btn_color_auto->SetBorderColor(btn_bd_accent); + m_btn_color_auto->SetTextColor(btn_text_accent); + + m_btn_apply->SetCornerRadius(FromDIP(12)); + m_btn_apply->SetMinSize(wxSize(FromDIP(60), FromDIP(28))); + m_btn_apply->SetBackgroundColor(btn_bg_white); + m_btn_apply->SetBorderColor(btn_bd_accent); + m_btn_apply->SetTextColor(btn_text_accent); + } + + // Defer attaching the Auto/Apply tooltips until the dialog has actually + // been shown. On macOS, AppKit creates NSTrackingArea and dispatches a + // synthetic mouseEntered: as soon as the window first becomes visible, + // which would otherwise pop the native tooltip without the user actually + // hovering when the cursor happens to land on these buttons as the dialog + // appears. + Bind(wxEVT_SHOW, [this](wxShowEvent& e) { + e.Skip(); + if (!e.IsShown() || m_initial_tooltips_set) + return; + m_initial_tooltips_set = true; + CallAfter([this]() { + if (m_btn_color_auto) + m_btn_color_auto->SetToolTip(_L("Automatically determine the optimal color count only and recompute filament mapping")); + if (m_btn_apply) + m_btn_apply->SetToolTip(_L("Convert texture to painting using the specified color count and smooth level")); + }); + }); + + wxBoxSizer* apply_sizer = new wxBoxSizer(wxHORIZONTAL); + apply_sizer->Add(m_btn_color_auto, 0, wxRIGHT, FromDIP(4)); + apply_sizer->Add(m_btn_apply, 0); + sizer->Add(apply_sizer, 0, wxALIGN_RIGHT | wxBOTTOM, FromDIP(8)); + + m_hint_label = new wxStaticText(parent, wxID_ANY, + _L("Reminder: parameters changed, click Apply to take effect")); + m_hint_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#FF6F00"))); + m_hint_label->SetFont(texture_import_section_title_font(parent)); + m_hint_label->Hide(); + sizer->Add(m_hint_label, 0, wxBOTTOM, FromDIP(4)); + + auto* mapping_separator = new StaticLine(parent); + mapping_separator->SetLineColour(wxColour(SEPARATOR_COLOUR_KEY)); + sizer->Add(mapping_separator, 0, wxEXPAND | wxBOTTOM, FromDIP(8)); +} + +void TextureImportDialog::build_mapping_panel(wxWindow* parent, wxSizer* sizer) +{ + wxColour secondary_fg = StateColor::darkModeColorFor(wxColour("#6B6B6B")); + + wxBoxSizer* header_sizer = new wxBoxSizer(wxHORIZONTAL); + + wxStaticText* lbl_mapping = new wxStaticText(parent, wxID_ANY, _L("Filament Mapping")); + lbl_mapping->SetForegroundColour(secondary_fg); + lbl_mapping->SetFont(texture_import_section_title_font(parent)); + m_auto_mix_font_point_size = lbl_mapping->GetFont().GetPointSize(); + header_sizer->Add(lbl_mapping, 0, wxALIGN_CENTER_VERTICAL); + + m_btn_mix_reset = new Button(parent, "", "revert_btn", wxBORDER_NONE, 16); + m_btn_mix_reset->SetCanFocus(false); + m_btn_mix_reset->SetPaddingSize(wxSize(FromDIP(2), FromDIP(2))); + { + StateColor reset_bg( + std::pair(wxColour("#F4F4F4"), StateColor::Pressed), + std::pair(wxColour("#F8F8F8"), StateColor::Hovered), + std::pair(*wxWHITE, StateColor::Normal)); + m_btn_mix_reset->SetBackgroundColor(reset_bg); + m_btn_mix_reset->SetBorderColor(StateColor()); + } + m_btn_mix_reset->SetToolTip(_L("Reset filament mapping to the state before one-click mixing")); + m_btn_mix_reset->Bind(wxEVT_BUTTON, [this](wxCommandEvent& evt) { + reset_auto_mix(); + evt.Skip(); + }); + m_btn_mix_reset->Hide(); + header_sizer->Add(m_btn_mix_reset, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, FromDIP(6)); + + header_sizer->AddStretchSpacer(); + + m_btn_auto_mix = new Button(parent, auto_mix_mode_label(m_auto_mix_mode)); + { + wxFont btn_font = m_btn_auto_mix->GetFont(); + btn_font.SetPointSize(m_auto_mix_font_point_size); + m_btn_auto_mix->SetFont(btn_font); + } + m_btn_auto_mix->SetCornerRadius(FromDIP(14)); + m_btn_auto_mix->SetMinSize(wxSize(FromDIP(178), FromDIP(28))); + { + StateColor btn_bg( + std::pair(wxColour("#F4F4F4"), StateColor::Pressed), + std::pair(wxColour("#F8F8F8"), StateColor::Hovered), + std::pair(*wxWHITE, StateColor::Normal)); + const wxColour btn_bd = wxColour("#CECECE"); + const wxColour btn_text = texture_import_gray9000(); + m_btn_auto_mix->SetBackgroundColor(btn_bg); + m_btn_auto_mix->SetBorderColor(btn_bd); + m_btn_auto_mix->SetTextColor(btn_text); + } + m_btn_auto_mix->SetToolTip(_L("Choose the one-click auto-mix mode for texture color import")); + m_btn_auto_mix->Bind(wxEVT_ENTER_WINDOW, [this](wxMouseEvent& evt) { + show_auto_mix_popup(); + evt.Skip(); + }); + m_btn_auto_mix->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& evt) { + show_auto_mix_popup(); + evt.Skip(); + }); + header_sizer->Add(m_btn_auto_mix, 0, wxALIGN_CENTER_VERTICAL); + + sizer->Add(header_sizer, 0, wxEXPAND | wxBOTTOM, FromDIP(4)); + + wxBoxSizer* merge_sizer = new wxBoxSizer(wxHORIZONTAL); + m_auto_merge_cb = new wxCheckBox(parent, wxID_ANY, _L("Auto-merge same filament")); + m_auto_merge_cb->SetToolTip(_L("Automatically merge identical filaments into existing filaments in the project")); + m_auto_merge_cb->SetForegroundColour(secondary_fg); + m_auto_merge_cb->SetValue(true); + m_auto_merge_cb->Bind(wxEVT_CHECKBOX, &TextureImportDialog::on_auto_merge_toggled, this); + merge_sizer->Add(m_auto_merge_cb, 0, wxALIGN_CENTER_VERTICAL); + + sizer->Add(merge_sizer, 0, wxEXPAND | wxBOTTOM, FromDIP(8)); + + m_mapping_scroll = new wxScrolledWindow(parent, wxID_ANY, wxDefaultPosition, + wxSize(-1, FromDIP(300))); + m_mapping_scroll->SetScrollRate(0, FromDIP(10)); + m_mapping_scroll->SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE)); + m_mapping_scroll->Bind(wxEVT_MOUSEWHEEL, &TextureImportDialog::dismiss_filament_popup_on_wheel, this); + + m_mapping_sizer = new wxBoxSizer(wxVERTICAL); + m_mapping_scroll->SetSizer(m_mapping_sizer); + + sizer->Add(m_mapping_scroll, 1, wxEXPAND | wxBOTTOM, FromDIP(8)); +} + +void TextureImportDialog::build_bottom_buttons(wxSizer* sizer) +{ + m_drop_warning_label = new wxStaticText(this, wxID_ANY, + wxString::Format( + _L("The project supports up to %d filaments. Extra filaments will be discarded."), + (int)max_filament_count())); + m_drop_warning_label->SetForegroundColour(StateColor::darkModeColorFor(wxColour("#FF6F00"))); + m_drop_warning_label->SetFont(texture_import_section_title_font(this)); + m_drop_warning_label->Hide(); + sizer->Add(m_drop_warning_label, 0, wxALIGN_LEFT | wxBOTTOM, FromDIP(4)); + + wxBoxSizer* btn_sizer = new wxBoxSizer(wxHORIZONTAL); + m_btn_skip = new Button(this, _L("Skip Matching")); + m_btn_skip->SetId(ID_BTN_SKIP); + m_btn_skip->SetToolTip(_L("Skip filament mapping and import as a single-color model")); + m_btn_skip->SetCornerRadius(FromDIP(20)); + m_btn_skip->SetMinSize(wxSize(FromDIP(136), FromDIP(40))); + { + StateColor skip_bg( + std::pair(wxColour("#CECECE"), StateColor::Pressed), + std::pair(wxColour("#EEEEEE"), StateColor::Hovered), + std::pair(*wxWHITE, StateColor::Normal)); + const wxColour skip_bd = wxColour("#CECECE"); + const wxColour skip_text = wxColour("#6B6B6A"); + m_btn_skip->SetBackgroundColor(skip_bg); + m_btn_skip->SetBorderColor(skip_bd); + m_btn_skip->SetTextColor(skip_text); + } + + m_btn_ok = new Button(this, _L("Confirm")); + m_btn_ok->SetId(wxID_OK); + m_btn_ok->SetCornerRadius(FromDIP(20)); + m_btn_ok->SetMinSize(wxSize(FromDIP(156), FromDIP(40))); + apply_accent_button_colours(m_btn_ok); + + btn_sizer->AddStretchSpacer(); + btn_sizer->Add(m_btn_skip, 0, wxRIGHT, FromDIP(16)); + btn_sizer->Add(m_btn_ok, 0); + + sizer->Add(btn_sizer, 0, wxEXPAND | wxTOP, FromDIP(8)); +} + +// ---- State machine ---- + +void TextureImportDialog::set_state(TextureImportState new_state) +{ + m_state = new_state; + update_ui_for_state(); +} + +void TextureImportDialog::update_ui_for_state() +{ + bool computing = (m_state == TextureImportState::Computing); + bool ready = (m_state == TextureImportState::Ready); + bool idle = (m_state == TextureImportState::Idle); + bool valid = has_valid_result(); + + m_color_slider->Enable(!computing); + m_color_spin->Enable(!computing); + m_smooth_slider->Enable(!computing); + m_smooth_spin->Enable(!computing); + m_btn_apply->Enable(!computing); + m_btn_color_4->Enable(!computing); + m_btn_color_8->Enable(!computing); + m_btn_color_16->Enable(!computing); + m_btn_color_auto->Enable(!computing); + if (m_btn_auto_mix) + m_btn_auto_mix->Enable(!computing); + if (m_btn_mix_reset) + m_btn_mix_reset->Enable(!computing); + if (computing) + dismiss_auto_mix_popup(); + + m_btn_ok->Enable(ready && valid); + m_btn_skip->Enable(ready || idle); + + m_auto_merge_cb->Enable(!computing); + + m_preview_canvas->set_computing_overlay(computing); + + if (ready && valid) + style_confirm_button(is_params_dirty()); + else if (m_hint_label) + m_hint_label->Hide(); + + m_btn_ok->Refresh(); + Layout(); +} + +// ---- Async computation ---- + +void TextureImportDialog::start_computation(bool auto_color, bool initial) +{ + cancel_computation(); + + m_cancel_flag = false; + m_current_computation_initial = initial; + m_current_computation_auto_color = auto_color; + if (initial) { + m_initial_computation_pending = true; + m_initial_computation_cancelled = false; + m_initial_computation_failed = false; + } + set_state(TextureImportState::Computing); + + bool silent_initial = initial && static_cast(m_initial_cancel_callback); + if (!silent_initial) { + m_progress_dlg = new ProgressDialog( + _L("Processing"), _L("Computing texture colors..."), + 100, initial ? GetParent() : this, wxPD_APP_MODAL | wxPD_CAN_ABORT | wxPD_AUTO_HIDE); + } + + Slic3r::TexturePaintingSettings settings; + settings.target_colors_num = auto_color ? 0 : (size_t)m_param_color_count; + settings.smooth_weight = m_param_smooth / 10.0; + settings.mesh_repair_decision = m_mesh_repair_decision; + // BBS repairs the mesh through the Windows 3D SDK, which is only available on Windows + // builds that ship the SDK. Orca's CGAL-based repair (MeshBoolean::cgal::repair) works + // on all three platforms, so use that instead. + settings.mesh_repair_callback = [](const indexed_triangle_set& mesh, + indexed_triangle_set& repaired_mesh, + std::function progress_callback, + std::function cancel_callback, + std::string* error_message) -> bool { + if (cancel_callback && cancel_callback()) + return false; + if (progress_callback) + progress_callback(_u8L("Repairing mesh").c_str(), 0); + + TriangleMesh tm(mesh); + if (!MeshBoolean::cgal::repair(tm, nullptr, error_message)) + return false; + + if (cancel_callback && cancel_callback()) + return false; + repaired_mesh = tm.its; + if (progress_callback) + progress_callback(_u8L("Repairing mesh").c_str(), 100); + return true; + }; + + Slic3r::TexturedMesh mesh_copy = m_textured_mesh; + wxEvtHandler* handler = this; + + m_worker = std::make_unique([this, settings, mesh_copy, handler]() { + Slic3r::PaintedMesh result; + + auto progress_cb = [handler](int percent, const char*) { + auto* evt = new wxCommandEvent(EVT_TEXTURE_COMPUTE_PROGRESS); + evt->SetInt(percent); + wxQueueEvent(handler, evt); + }; + + auto cancel_cb = [this]() -> bool { + return m_cancel_flag.load(); + }; + + auto worker_settings = settings; + bool mesh_repair_decision_required = false; + worker_settings.mesh_repair_decision_required = &mesh_repair_decision_required; + bool ok; + if (!mesh_copy.precomputed_face_colors.empty()) { + ok = Slic3r::face_colors_to_painting( + mesh_copy, result, worker_settings, progress_cb, cancel_cb); + } else { + ok = Slic3r::texture_to_painting(mesh_copy, result, worker_settings, progress_cb, cancel_cb); + } + + if (m_cancel_flag.load()) { + wxQueueEvent(handler, new wxCommandEvent(EVT_TEXTURE_COMPUTE_ERROR)); + return; + } + + if (!ok && mesh_repair_decision_required) { + wxQueueEvent(handler, new wxCommandEvent(EVT_TEXTURE_MESH_REPAIR_DECISION)); + return; + } + + { + std::lock_guard lock(m_result_mutex); + m_pending_result = std::move(result); + } + + if (ok) { + wxQueueEvent(handler, new wxCommandEvent(EVT_TEXTURE_COMPUTE_DONE)); + } else { + wxQueueEvent(handler, new wxCommandEvent(EVT_TEXTURE_COMPUTE_ERROR)); + } + }); +} + +void TextureImportDialog::cancel_computation() +{ + m_cancel_flag = true; + if (m_worker && m_worker->joinable()) + m_worker->join(); + m_worker.reset(); + + if (m_progress_dlg) { + m_progress_dlg->Destroy(); + m_progress_dlg = nullptr; + } + + if (m_current_computation_initial) { + m_initial_computation_cancelled = true; + m_initial_computation_pending = false; + m_current_computation_initial = false; + } +} + +void TextureImportDialog::on_computation_progress(wxCommandEvent& evt) +{ + if (m_progress_dlg) { + if (!m_progress_dlg->Update(evt.GetInt())) + m_cancel_flag = true; + } else if (m_current_computation_initial && m_initial_progress_callback) { + if (!m_initial_progress_callback(evt.GetInt())) + m_cancel_flag = true; + } +} + +void TextureImportDialog::on_computation_complete(wxCommandEvent&) +{ + bool initial = m_current_computation_initial; + + if (m_progress_dlg) { + m_progress_dlg->Destroy(); + m_progress_dlg = nullptr; + } + + { + std::lock_guard lock(m_result_mutex); + m_painted = std::move(m_pending_result); + } + + int actual_colors = (int)m_painted.cluster_colors.size(); + if (actual_colors >= 2 && actual_colors <= (int)max_filament_count()) { + set_color_count_value(actual_colors, true); + } + + m_preview_canvas->set_painted_mesh_data(m_painted.vertices, m_painted.indices); + m_preview_canvas->set_face_colors(m_painted.face_colors); + + // A fresh texture computation replaces m_painted, so virtual filaments from + // the previous computation must not consume capacity when deciding whether + // this run drops extra colors. Rebuild virtual filaments from this result. + m_current_matches.clear(); + if (m_filament_colors_rgba.size() > m_existing_filament_count) + m_filament_colors_rgba.resize(m_existing_filament_count); + if (m_filament_color_strs.size() > m_existing_filament_count) + m_filament_color_strs.resize(m_existing_filament_count); + if (m_filament_names.size() > m_existing_filament_count) + m_filament_names.resize(m_existing_filament_count); + if (m_filament_entries.size() > m_existing_filament_count) + m_filament_entries.resize(m_existing_filament_count); + while (m_filament_entries.size() < m_existing_filament_count) { + TextureFilamentEntry entry; + entry.kind = TextureFilamentKind::ExistingPhysical; + entry.dialog_index = (int)m_filament_entries.size(); + entry.project_config_index = m_filament_entries.size(); + m_filament_entries.push_back(entry); + } + for (size_t i = 0; i < m_filament_entries.size(); ++i) { + m_filament_entries[i].dialog_index = (int)i; + m_filament_entries[i].color_hex = i < m_filament_color_strs.size() ? + texture_normalize_color_hex(m_filament_color_strs[i]) : "#808080"; + m_filament_entries[i].name = i < m_filament_names.size() ? + m_filament_names[i] : "Filament " + std::to_string(i + 1); + } + m_new_filament_colors.clear(); + m_new_filament_preset_names.clear(); + m_new_mixed_filaments.clear(); + + do_auto_match(); + compact_used_virtual_filaments(); + sort_current_matches_by_filament_index(); + update_filament_color_map(); + rebuild_mapping_rows(); + + m_applied_color_count = m_param_color_count; + m_applied_smooth = m_param_smooth; + + set_state(TextureImportState::Ready); + update_drop_warning_visibility(); + update_auto_mix_reset_visibility(); + + m_btn_view_multicolor->Show(); + if (m_tab_panel) { + m_tab_panel->GetSizer()->Layout(); + m_tab_panel->Fit(); + m_tab_panel->SetPosition(wxPoint(FromDIP(8), FromDIP(8))); + } + GetSizer()->Layout(); + + m_preview_canvas->set_render_mode(TexturePreviewCanvas::RenderMode::FilamentMap); + highlight_view_button(-1); + + if (initial) { + m_initial_computation_pending = false; + m_current_computation_initial = false; + } +} + +void TextureImportDialog::on_computation_error(wxCommandEvent&) +{ + bool initial = m_current_computation_initial; + + if (m_progress_dlg) { + m_progress_dlg->Destroy(); + m_progress_dlg = nullptr; + } + + if (m_cancel_flag.load()) { + if (initial) { + m_initial_computation_cancelled = true; + m_initial_computation_pending = false; + m_current_computation_initial = false; + return; + } + if (has_valid_result()) { + if (m_applied_color_count >= 0) { + m_param_color_count = m_applied_color_count; + m_color_slider->SetValue(m_param_color_count); + m_color_spin->SetValue(m_param_color_count); + update_color_count_preset_buttons(); + } + if (m_applied_smooth >= 0) { + m_param_smooth = m_applied_smooth; + m_smooth_slider->SetValue(m_param_smooth); + m_smooth_spin->SetValue(m_param_smooth); + } + set_state(TextureImportState::Ready); + return; + } + set_state(TextureImportState::Idle); + return; + } + + if (initial) { + m_initial_computation_failed = true; + m_initial_computation_pending = false; + m_current_computation_initial = false; + m_fallback_to_geometry_only = true; + return; + } + + set_state(TextureImportState::Error); + Slic3r::GUI::MessageDialog dlg(initial ? GetParent() : this, + _L("Computation failed. Please adjust parameters and retry."), + _L("Error"), wxOK | wxICON_ERROR); + dlg.ShowModal(); +} + +void TextureImportDialog::on_mesh_repair_decision_required(wxCommandEvent&) +{ + bool initial = m_current_computation_initial; + bool auto_color = m_current_computation_auto_color; + + if (m_progress_dlg) { + m_progress_dlg->Destroy(); + m_progress_dlg = nullptr; + } + +#ifdef HAS_WIN10SDK + Slic3r::GUI::MessageDialog dlg(initial ? GetParent() : this, + _L("The mesh has non-manifold geometry or open boundaries. You can import it as-is or repair it with Windows 3D repair service before importing."), + _L("Mesh repair"), wxYES_NO | wxICON_WARNING | wxYES_DEFAULT); + dlg.SetButtonLabel(wxID_YES, _L("Import without repair")); + dlg.SetButtonLabel(wxID_NO, _L("Repair and import"), true); + // "Repair and import" is the recommended action here, so the accent moves off the default YES + // button onto NO. MsgDialog::add_button already styled both as ButtonType::Choice, so restyling + // with the same type swaps only the palette and leaves the geometry alone. + if (auto* yes_btn = dynamic_cast(dlg.FindWindow(wxID_YES))) { + yes_btn->SetStyle(ButtonStyle::Regular, ButtonType::Choice); + yes_btn->SetMinSize(wxSize(FromDIP(180), FromDIP(24))); + } + if (auto* no_btn = dynamic_cast(dlg.FindWindow(wxID_NO))) { + no_btn->SetStyle(ButtonStyle::Confirm, ButtonType::Choice); + no_btn->SetMinSize(wxSize(FromDIP(160), FromDIP(24))); + } + dlg.Layout(); + dlg.Fit(); + dlg.CenterOnParent(); + int ret = dlg.ShowModal(); + m_mesh_repair_decision = (ret == wxID_NO) + ? Slic3r::TexturePaintingSettings::MeshRepairDecision::RepairAndImport + : Slic3r::TexturePaintingSettings::MeshRepairDecision::ImportWithoutRepair; +#else + Slic3r::GUI::MessageDialog dlg(initial ? GetParent() : this, + _L("Please note that the mesh has non-manifold geometry or open boundaries."), + _L("Mesh issue"), wxOK | wxCANCEL | wxICON_WARNING | wxOK_DEFAULT); + dlg.SetButtonLabel(wxID_OK, _L("Continue"), true); + dlg.SetButtonLabel(wxID_CANCEL, _L("Cancel")); + int ret = dlg.ShowModal(); + if (ret != wxID_OK) { + m_cancel_flag = true; + if (initial) { + m_initial_computation_cancelled = true; + m_initial_computation_pending = false; + m_current_computation_initial = false; + } else if (has_valid_result()) { + set_state(TextureImportState::Ready); + } else { + set_state(TextureImportState::Idle); + } + return; + } + m_mesh_repair_decision = Slic3r::TexturePaintingSettings::MeshRepairDecision::ImportWithoutRepair; +#endif + + start_computation(auto_color, initial); +} + +// ---- Mapping ---- + +void TextureImportDialog::update_filament_color_map() +{ + std::map, std::array> color_map; + for (const auto& m : m_current_matches) { + if (m.filament_index >= 0 && m.filament_index < (int)m_filament_colors_rgba.size()) { + color_map[m.cluster_color] = { + m_filament_colors_rgba[m.filament_index][0], + m_filament_colors_rgba[m.filament_index][1], + m_filament_colors_rgba[m.filament_index][2] + }; + } + } + m_preview_canvas->set_filament_color_map(color_map); +} + +// Canonical ordering used on the very first display after a computation: +// sort ascending by filament_index, and push unmapped (filament_index < 0) +// entries to the end. This gives the user a stable, predictable mapping +// layout regardless of the cluster discovery order. +void TextureImportDialog::sort_current_matches_by_filament_index() +{ + std::stable_sort(m_current_matches.begin(), m_current_matches.end(), + [](const auto& lhs, const auto& rhs) { + const bool lhs_valid = lhs.filament_index >= 0; + const bool rhs_valid = rhs.filament_index >= 0; + + if (lhs_valid != rhs_valid) + return lhs_valid; + if (!lhs_valid) + return false; + + return lhs.filament_index < rhs.filament_index; + }); +} + +// Preserve the row order the user is currently looking at across a +// re-computation (e.g. when auto-merge is toggled). We key on cluster_index +// because it survives compact_used_virtual_filaments() and filament-index +// renumbering, whereas filament_index does not. +// +// Behaviour: +// * Entries whose cluster_index appeared in `previous_matches` keep their +// previous relative order. +// * Entries whose cluster_index is new (not in `previous_matches`) are +// appended at the end, in their current relative order. +// +// Assumption: each cluster_index appears at most once in both vectors. This +// is currently guaranteed by do_auto_match(), which emits exactly one match +// per cluster. If that invariant ever changes, the std::map::emplace below +// silently keeps only the first occurrence and the order will be wrong. +void TextureImportDialog::restore_current_match_order(const std::vector& previous_matches) +{ + if (previous_matches.empty() || m_current_matches.size() < 2) + return; + + std::map previous_order_by_cluster; + for (size_t i = 0; i < previous_matches.size(); ++i) { + if (previous_matches[i].cluster_index >= 0) + previous_order_by_cluster.emplace(previous_matches[i].cluster_index, i); + } + + std::stable_sort(m_current_matches.begin(), m_current_matches.end(), + [&previous_order_by_cluster](const auto& lhs, const auto& rhs) { + const auto lhs_it = previous_order_by_cluster.find(lhs.cluster_index); + const auto rhs_it = previous_order_by_cluster.find(rhs.cluster_index); + const bool lhs_known = lhs_it != previous_order_by_cluster.end(); + const bool rhs_known = rhs_it != previous_order_by_cluster.end(); + + if (lhs_known != rhs_known) + return lhs_known; + if (!lhs_known) + return false; + + return lhs_it->second < rhs_it->second; + }); +} + +size_t TextureImportDialog::max_filament_count() const +{ + return static_cast(EnforcerBlockerType::ExtruderMax); +} + +bool TextureImportDialog::can_add_virtual_filament() const +{ + return m_filament_colors_rgba.size() < max_filament_count(); +} + +int TextureImportDialog::find_closest_filament_index(const std::array& color) const +{ + int best_idx = -1; + double best_delta = std::numeric_limits::max(); + const size_t filament_count = std::min(m_filament_colors_rgba.size(), max_filament_count()); + for (size_t i = 0; i < filament_count; ++i) { + const double delta = Slic3r::compute_delta_e(color, m_filament_colors_rgba[i]); + if (delta < best_delta) { + best_delta = delta; + best_idx = (int)i; + } + } + return best_idx; +} + +int TextureImportDialog::add_virtual_filament(const std::array& rgba, const std::string& hex, + const std::string& preset_name) +{ + if (m_filament_color_strs.size() != m_filament_colors_rgba.size() || + m_filament_names.size() != m_filament_colors_rgba.size() || + m_filament_entries.size() != m_filament_colors_rgba.size()) { + return -1; + } + if (!can_add_virtual_filament()) { + // Mark that this do_auto_match() run hit the filament cap and had to + // drop at least one cluster. The mapping itself still falls back via + // find_closest_filament_index() below; this flag only drives the + // inline orange warning above the bottom buttons. + // Note: only the false -> true transition happens here; the flag is + // cleared exclusively at the entry of do_auto_match() so it always + // reflects the most recent match, never an accumulated history. + m_filaments_dropped = true; + return -1; + } + + const int new_idx = (int)m_filament_colors_rgba.size(); + m_filament_colors_rgba.push_back(rgba); + m_filament_color_strs.push_back(hex); + m_filament_names.push_back(DEFAULT_VIRTUAL_FILAMENT_NAME); + TextureFilamentEntry entry; + entry.kind = TextureFilamentKind::NewPhysical; + entry.dialog_index = new_idx; + entry.project_config_index = size_t(-1); + entry.color_hex = texture_normalize_color_hex(hex); + entry.name = DEFAULT_VIRTUAL_FILAMENT_NAME; + entry.preset_name = preset_name.empty() ? m_default_virtual_filament_preset_name : preset_name; + m_filament_entries.push_back(entry); + m_new_filament_colors.push_back(rgba); + m_new_filament_preset_names.push_back(preset_name.empty() ? m_default_virtual_filament_preset_name : preset_name); + return new_idx; +} + +int TextureImportDialog::add_virtual_mixed_filament(const std::string& color_hex, + const std::vector& component_dialog_indices, + const std::vector& ratios) +{ + if (m_filament_color_strs.size() != m_filament_colors_rgba.size() || + m_filament_names.size() != m_filament_colors_rgba.size() || + m_filament_entries.size() != m_filament_colors_rgba.size()) { + return -1; + } + if (component_dialog_indices.size() < 2 || component_dialog_indices.size() != ratios.size()) + return -1; + for (int idx : component_dialog_indices) { + if (idx < 0 || idx >= (int)m_filament_entries.size() || + !texture_entry_is_physical(m_filament_entries[idx].kind)) { + return -1; + } + } + if (!can_add_virtual_filament()) { + m_filaments_dropped = true; + return -1; + } + + const int new_idx = (int)m_filament_colors_rgba.size(); + TextureFilamentEntry entry; + entry.kind = TextureFilamentKind::NewMixed; + entry.dialog_index = new_idx; + entry.project_config_index = size_t(-1); + entry.color_hex = texture_normalize_color_hex(color_hex); + entry.name = DEFAULT_VIRTUAL_FILAMENT_NAME; + entry.mixed_ratios = ratios; + for (int idx : component_dialog_indices) + entry.mixed_components.push_back((unsigned int)(idx + 1)); + + TextureNewMixedFilament mixed; + mixed.dialog_index = entry.dialog_index; + mixed.color_hex = entry.color_hex; + mixed.component_dialog_indices = component_dialog_indices; + mixed.ratios = ratios; + + m_filament_entries.push_back(entry); + m_filament_color_strs.push_back(entry.color_hex); + m_filament_names.push_back(entry.name); + m_filament_colors_rgba.push_back(parse_color_string(entry.color_hex)); + m_new_mixed_filaments.push_back(mixed); + return entry.dialog_index; +} + +void TextureImportDialog::compact_used_virtual_filaments() +{ + if (m_current_matches.empty()) + return; + + const std::vector> old_colors = m_filament_colors_rgba; + const std::vector old_color_strs = m_filament_color_strs; + const std::vector old_names = m_filament_names; + const std::vector old_entries = m_filament_entries; + + auto old_new_mixed_has_valid_components = [&old_entries, &old_colors](const TextureFilamentEntry& entry) { + if (entry.kind != TextureFilamentKind::NewMixed) + return true; + if (entry.mixed_components.size() < 2 || entry.mixed_components.size() != entry.mixed_ratios.size()) + return false; + for (unsigned int comp : entry.mixed_components) { + int comp_idx = comp >= 1 ? (int)comp - 1 : -1; + if (comp_idx < 0 || comp_idx >= (int)old_entries.size() || comp_idx >= (int)old_colors.size() || + !texture_entry_is_physical(old_entries[comp_idx].kind)) { + return false; + } + } + return true; + }; + + std::set used_virtual_indices; + for (const auto& m : m_current_matches) { + if (m.filament_index >= (int)m_existing_filament_count && + m.filament_index < (int)old_colors.size()) { + if (m.filament_index < (int)old_entries.size() && + old_entries[m.filament_index].kind == TextureFilamentKind::NewMixed && + !old_new_mixed_has_valid_components(old_entries[m.filament_index])) { + continue; + } + used_virtual_indices.insert(m.filament_index); + } + } + bool added_dependency = true; + while (added_dependency) { + added_dependency = false; + std::vector current_used(used_virtual_indices.begin(), used_virtual_indices.end()); + for (int used_idx : current_used) { + if (used_idx < 0 || used_idx >= (int)old_entries.size() || + old_entries[used_idx].kind != TextureFilamentKind::NewMixed || + !old_new_mixed_has_valid_components(old_entries[used_idx])) + continue; + for (unsigned int comp : old_entries[used_idx].mixed_components) { + int comp_idx = comp >= 1 ? (int)comp - 1 : -1; + if (comp_idx >= (int)m_existing_filament_count && comp_idx < (int)old_entries.size() && + used_virtual_indices.insert(comp_idx).second) { + added_dependency = true; + } + } + } + } + + std::vector> compact_colors; + std::vector compact_color_strs; + std::vector compact_names; + std::vector compact_entries; + compact_colors.reserve(m_existing_filament_count + used_virtual_indices.size()); + compact_color_strs.reserve(m_existing_filament_count + used_virtual_indices.size()); + compact_names.reserve(m_existing_filament_count + used_virtual_indices.size()); + compact_entries.reserve(m_existing_filament_count + used_virtual_indices.size()); + + const size_t existing_count = std::min(m_existing_filament_count, old_colors.size()); + for (size_t i = 0; i < existing_count; ++i) { + compact_colors.push_back(old_colors[i]); + compact_color_strs.push_back(i < old_color_strs.size() ? old_color_strs[i] : ""); + compact_names.push_back(i < old_names.size() ? old_names[i] : "Filament " + std::to_string(i + 1)); + TextureFilamentEntry entry = i < old_entries.size() ? old_entries[i] : TextureFilamentEntry{}; + entry.dialog_index = (int)i; + entry.color_hex = texture_normalize_color_hex(compact_color_strs.back()); + entry.name = compact_names.back(); + compact_entries.push_back(entry); + } + + std::map old_to_new; + std::vector> compact_new_colors; + std::vector compact_new_preset_names; + compact_new_colors.reserve(used_virtual_indices.size()); + compact_new_preset_names.reserve(used_virtual_indices.size()); + + for (int old_idx : used_virtual_indices) { + old_to_new[old_idx] = (int)compact_colors.size(); + compact_colors.push_back(old_colors[old_idx]); + compact_color_strs.push_back(old_idx < (int)old_color_strs.size() ? old_color_strs[old_idx] : ""); + compact_names.push_back(old_idx < (int)old_names.size() ? old_names[old_idx] : DEFAULT_VIRTUAL_FILAMENT_NAME); + TextureFilamentEntry entry = old_idx < (int)old_entries.size() ? old_entries[old_idx] : TextureFilamentEntry{}; + entry.dialog_index = (int)compact_entries.size(); + entry.color_hex = texture_normalize_color_hex(compact_color_strs.back()); + entry.name = compact_names.back(); + if (entry.kind == TextureFilamentKind::NewPhysical) { + compact_new_colors.push_back(old_colors[old_idx]); + compact_new_preset_names.push_back(entry.preset_name.empty() ? m_default_virtual_filament_preset_name : entry.preset_name); + } + compact_entries.push_back(entry); + } + + m_filament_colors_rgba = std::move(compact_colors); + m_filament_color_strs = std::move(compact_color_strs); + m_filament_names = std::move(compact_names); + m_filament_entries = std::move(compact_entries); + m_new_filament_colors = std::move(compact_new_colors); + m_new_filament_preset_names = std::move(compact_new_preset_names); + m_new_mixed_filaments.clear(); + std::set invalid_compacted_mixed_indices; + for (auto& entry : m_filament_entries) { + if (entry.kind != TextureFilamentKind::NewMixed) + continue; + TextureNewMixedFilament mixed; + mixed.dialog_index = entry.dialog_index; + mixed.color_hex = entry.color_hex; + mixed.ratios = entry.mixed_ratios; + mixed.component_dialog_indices.reserve(entry.mixed_components.size()); + bool valid_components = entry.mixed_components.size() >= 2 && + entry.mixed_components.size() == entry.mixed_ratios.size(); + for (unsigned int comp : entry.mixed_components) { + int old_comp_idx = comp >= 1 ? (int)comp - 1 : -1; + if (old_comp_idx < 0) { + valid_components = false; + break; + } + auto remap_it = old_to_new.find(old_comp_idx); + int new_comp_idx = remap_it != old_to_new.end() ? remap_it->second : old_comp_idx; + if (new_comp_idx < 0 || new_comp_idx >= (int)m_filament_entries.size() || + !texture_entry_is_physical(m_filament_entries[new_comp_idx].kind)) { + valid_components = false; + break; + } + mixed.component_dialog_indices.push_back(new_comp_idx); + } + if (!valid_components) { + invalid_compacted_mixed_indices.insert(entry.dialog_index); + continue; + } + entry.mixed_components.clear(); + for (int comp_idx : mixed.component_dialog_indices) + entry.mixed_components.push_back((unsigned int)(comp_idx + 1)); + m_new_mixed_filaments.push_back(mixed); + } + + auto find_closest_physical_filament_index = [this](const std::array& color) { + int best_idx = -1; + double best_delta = std::numeric_limits::max(); + const size_t filament_count = std::min(m_filament_colors_rgba.size(), max_filament_count()); + for (size_t i = 0; i < filament_count && i < m_filament_entries.size(); ++i) { + if (!texture_entry_is_physical(m_filament_entries[i].kind)) + continue; + const double delta = Slic3r::compute_delta_e(color, m_filament_colors_rgba[i]); + if (delta < best_delta) { + best_delta = delta; + best_idx = (int)i; + } + } + return best_idx; + }; + + for (auto& m : m_current_matches) { + auto it = old_to_new.find(m.filament_index); + if (it != old_to_new.end()) { + m.filament_index = it->second; + } else if (m.filament_index >= (int)m_existing_filament_count) { + m.filament_index = find_closest_filament_index(m.cluster_color); + } + if (invalid_compacted_mixed_indices.count(m.filament_index) > 0) { + int fallback_idx = find_closest_physical_filament_index(m.cluster_color); + m.filament_index = fallback_idx >= 0 ? fallback_idx : find_closest_filament_index(m.cluster_color); + } + + if (m.filament_index >= 0 && m.filament_index < (int)m_filament_colors_rgba.size()) { + m.filament_color = m_filament_colors_rgba[m.filament_index]; + m.delta_e = Slic3r::compute_delta_e(m.cluster_color, m.filament_color); + if (m.filament_index >= (int)m_existing_filament_count) + m.delta_e = 0.0; + } + } +} + +std::vector TextureImportDialog::compute_display_numbers() const +{ + // Assigns each entry a 1-based display number in the order the sidebar will + // show after apply: ExistingPhysical, NewPhysical, ExistingMixed, NewMixed. + // This keeps the dialog's visible IDs in sync with the post-apply sidebar, + // instead of the raw dialog_index (which interleaves physicals and mixeds + // by processing order and causes e.g. CMYW to show 4,5,6,8 instead of 3,4,5,6). + // MUST mirror ordering in apply_textured_mesh_import_result (Plater.cpp:9896): + // - ExistingPhysical keeps its project_config_index + // - NewPhysical is inserted at existing_physical_count + new_order + // - ExistingMixed shifts to project_config_index + new_physical_count + // - NewMixed is appended after all existing mixeds + std::vector result(m_filament_entries.size(), 0); + int next = 1; + + auto assign_group = [&](TextureFilamentKind kind, bool by_project_config_index) { + if (by_project_config_index) { + std::vector group; + for (const auto& e : m_filament_entries) + if (e.kind == kind) + group.push_back(&e); + std::sort(group.begin(), group.end(), + [](const TextureFilamentEntry* a, const TextureFilamentEntry* b) { + return a->project_config_index < b->project_config_index; + }); + for (const auto* e : group) { + if (e->dialog_index >= 0 && e->dialog_index < (int)result.size()) + result[e->dialog_index] = next; + ++next; + } + } else { + for (const auto& e : m_filament_entries) { + if (e.kind != kind) + continue; + if (e.dialog_index >= 0 && e.dialog_index < (int)result.size()) + result[e.dialog_index] = next; + ++next; + } + } + }; + + assign_group(TextureFilamentKind::ExistingPhysical, true); + assign_group(TextureFilamentKind::NewPhysical, false); + assign_group(TextureFilamentKind::ExistingMixed, true); + assign_group(TextureFilamentKind::NewMixed, false); + return result; +} + +void TextureImportDialog::dismiss_filament_popup() +{ + if (!m_filament_popup) { + m_filament_popup_row = -1; + return; + } + + FilamentSelectPopup* popup = m_filament_popup; + m_filament_popup = nullptr; + m_filament_popup_row = -1; + if (popup->IsShown()) + popup->Dismiss(); + else + popup->Destroy(); +} + +void TextureImportDialog::show_auto_mix_popup() +{ + if (!m_btn_auto_mix || !m_btn_auto_mix->IsEnabled()) + return; + + if (m_auto_mix_popup && m_auto_mix_popup->IsShown()) + return; + dismiss_auto_mix_popup(); + + auto on_select = [this](TextureAutoMixMode mode) { + set_auto_mix_mode(mode); + }; + auto on_close = [this]() { + m_auto_mix_popup = nullptr; + }; + + auto* popup = new AutoMixSelectPopup(this, m_auto_mix_mode, m_btn_auto_mix->GetSize().x, + m_auto_mix_font_point_size, + on_select, on_close); + wxPoint pos = m_btn_auto_mix->ClientToScreen(wxPoint(0, m_btn_auto_mix->GetSize().y)); + wxRect display_rect; + int display_idx = wxDisplay::GetFromPoint(pos); + if (display_idx != wxNOT_FOUND) + display_rect = wxDisplay(display_idx).GetClientArea(); + else + display_rect = wxDisplay().GetClientArea(); + pos.x = std::clamp(pos.x, display_rect.GetLeft(), + std::max(display_rect.GetLeft(), display_rect.GetRight() - popup->GetSize().x)); + popup->Position(pos, wxSize(0, 0)); + popup->Bind(wxEVT_DESTROY, [this, popup](wxWindowDestroyEvent& e) { + e.Skip(); + if (m_auto_mix_popup == popup) + m_auto_mix_popup = nullptr; + }); + m_auto_mix_popup = popup; + popup->Popup(); +} + +void TextureImportDialog::dismiss_auto_mix_popup() +{ + if (!m_auto_mix_popup) + return; + + AutoMixSelectPopup* popup = m_auto_mix_popup; + m_auto_mix_popup = nullptr; + if (popup->IsShown()) + popup->Dismiss(); + else + popup->Destroy(); +} + +void TextureImportDialog::set_auto_mix_mode(TextureAutoMixMode mode) +{ + m_auto_mix_mode = mode; + if (m_btn_auto_mix) { + m_btn_auto_mix->SetLabel(auto_mix_mode_label(mode)); + m_btn_auto_mix->Refresh(); + } + apply_auto_standard_mix(mode); +} + +void TextureImportDialog::apply_auto_standard_mix(TextureAutoMixMode mode) +{ + if (m_mapping_rows.empty()) + return; + m_filaments_dropped = false; + + auto find_or_add_base_physical = [this](const std::string& color_hex) -> int { + const std::string normalized = texture_normalize_color_hex(color_hex); + for (const auto& entry : m_filament_entries) { + if (!texture_entry_is_physical(entry.kind)) + continue; + if (texture_normalize_color_hex(entry.color_hex) != normalized) + continue; + if (texture_entry_is_pla_basic(entry)) + return entry.dialog_index; + } + + std::array rgba = parse_color_string(normalized); + int idx = add_virtual_filament(rgba, normalized, m_default_virtual_filament_preset_name); + if (idx >= 0 && idx < (int)m_filament_entries.size()) { + m_filament_entries[idx].type = DEFAULT_VIRTUAL_FILAMENT_BASIC_TYPE; + m_filament_entries[idx].name = DEFAULT_VIRTUAL_FILAMENT_NAME; + } + return idx; + }; + + auto find_existing_mixed = [this](const std::vector& component_indices, const std::vector& ratios) -> int { + for (const auto& entry : m_filament_entries) { + if (!texture_entry_is_mixed(entry.kind) || entry.mixed_components.size() != component_indices.size() || + entry.mixed_ratios.size() != ratios.size()) + continue; + bool same = true; + for (size_t i = 0; i < component_indices.size(); ++i) { + if (entry.mixed_components[i] != (unsigned int)(component_indices[i] + 1) || + entry.mixed_ratios[i] != ratios[i]) { + same = false; + break; + } + } + if (same) + return entry.dialog_index; + } + return -1; + }; + + bool changed = false; + const auto recipe_mode = texture_recipe_mode(mode); + for (size_t row_index = 0; row_index < m_mapping_rows.size(); ++row_index) { + Slic3r::ColorDecomposeRgb target_rgb; + if (!Slic3r::color_decompose_hex_to_rgb(m_mapping_rows[row_index].source_hex, target_rgb)) + continue; + + auto recipe = Slic3r::lookup_standard_recipe(target_rgb, recipe_mode, DEFAULT_VIRTUAL_FILAMENT_BASIC_TYPE); + if (!recipe.valid || recipe.components.size() < 2) + continue; + + std::vector component_dialog_indices; + std::vector ratios; + for (const auto& comp : recipe.components) { + int component_idx = find_or_add_base_physical(comp.color_hex); + if (component_idx < 0) { + component_dialog_indices.clear(); + break; + } + component_dialog_indices.push_back(component_idx); + ratios.push_back(comp.ratio); + } + if (component_dialog_indices.size() < 2 || component_dialog_indices.size() != ratios.size()) + continue; + + int mixed_idx = find_existing_mixed(component_dialog_indices, ratios); + if (mixed_idx < 0) + mixed_idx = add_virtual_mixed_filament(recipe.matched_color_hex, component_dialog_indices, ratios); + if (mixed_idx < 0) + continue; + + m_mapping_rows[row_index].target_filament_idx = mixed_idx; + if (row_index < m_current_matches.size()) { + m_current_matches[row_index].filament_index = mixed_idx; + m_current_matches[row_index].filament_color = m_filament_colors_rgba[mixed_idx]; + m_current_matches[row_index].delta_e = Slic3r::compute_delta_e( + m_current_matches[row_index].cluster_color, m_current_matches[row_index].filament_color); + if (mixed_idx >= (int)m_existing_filament_count) + m_current_matches[row_index].delta_e = 0.0; + } + changed = true; + } + + if (!changed) + return; + + m_auto_mix_applied = true; + compact_used_virtual_filaments(); + update_filament_color_map(); + rebuild_mapping_rows(); + update_drop_warning_visibility(); + update_auto_mix_reset_visibility(); +} + +void TextureImportDialog::reset_auto_mix() +{ + if (m_state != TextureImportState::Ready || !m_auto_mix_applied) + return; + + dismiss_auto_mix_popup(); + + // Clear mixed filament references so the compact inside do_auto_match() + // removes them (and their exclusively-owned base physicals) from the + // filament arrays, giving the baseline matching a clean starting state. + for (auto& m : m_current_matches) { + if (m.filament_index >= 0 && m.filament_index < (int)m_filament_entries.size() && + texture_entry_is_mixed(m_filament_entries[m.filament_index].kind)) { + m.filament_index = -1; + } + } + + // Re-run the baseline auto-match (same flow as the auto-merge toggle) so the + // mapping reverts to the pre-mix state: every colour matches an existing + // physical filament or a virtual physical filament, with no mixed filaments. + const auto previous_matches = m_current_matches; + do_auto_match(); + restore_current_match_order(previous_matches); + compact_used_virtual_filaments(); + update_filament_color_map(); + rebuild_mapping_rows(); + update_drop_warning_visibility(); + update_auto_mix_reset_visibility(); +} + +void TextureImportDialog::update_auto_mix_reset_visibility() +{ + if (!m_btn_mix_reset) + return; + if (m_btn_mix_reset->Show(m_auto_mix_applied)) { + if (wxWindow* parent = m_btn_mix_reset->GetParent()) + parent->Layout(); + } +} + +bool TextureImportDialog::add_decomposed_mixed_filament(size_t row_index) +{ + if (row_index >= m_mapping_rows.size()) + return false; + + std::vector physical_colors; + std::vector physical_names; + std::vector physical_types; + std::vector physical_dialog_indices; + std::vector physical_config_indices; + auto& preset_bundle = *wxGetApp().preset_bundle; + for (const auto& entry : m_filament_entries) { + if (entry.kind != TextureFilamentKind::ExistingPhysical) + continue; + physical_colors.push_back(entry.color_hex); + physical_names.push_back(entry.name); + const size_t cfg_idx = entry.project_config_index; + Preset* preset = nullptr; + if (cfg_idx < preset_bundle.filament_presets.size()) + preset = preset_bundle.filaments.find_preset(preset_bundle.filament_presets[cfg_idx]); + physical_types.push_back(filament_type_for_color_decompose(preset)); + physical_dialog_indices.push_back(entry.dialog_index); + physical_config_indices.push_back(cfg_idx); + } + if (physical_colors.empty()) + return false; + + wxColour target(m_mapping_rows[row_index].source_hex); + ColorDecomposeDialog dlg(this, -1, target, physical_colors, physical_names, physical_types, + m_filament_entries.size(), max_filament_count(), + std::move(physical_config_indices)); + // Count "new physical filaments" with the exact reuse rule of the write-back + // loop below: a base color is only new if no existing OR virtual official + // Bambu Basic filament already carries that color. This keeps the dialog's + // filament-limit pre-check consistent with what add_decomposed_mixed_filament + // will actually create, so already-present virtual base colors are not + // double counted (which previously could wrongly disable OK). + dlg.set_missing_physical_calculator([this](const ColorDecomposeResult& result) -> size_t { + size_t missing = 0; + for (const DecomposeComponent& comp : result.components) { + if (comp.filament_index > 0) + continue; // reuses a physical slot passed to the dialog, no new filament + const std::string comp_hex = texture_normalize_color_hex( + comp.colour.GetAsString(wxC2S_HTML_SYNTAX).ToStdString()); + bool found = false; + for (const auto& entry : m_filament_entries) { + if (!texture_entry_is_physical(entry.kind)) + continue; + if (texture_normalize_color_hex(entry.color_hex) != comp_hex) + continue; + if (texture_entry_official_basic(entry)) { + found = true; + break; + } + } + if (!found) + ++missing; + } + return missing; + }); + if (dlg.ShowModal() != wxID_OK) + return false; + + ColorDecomposeResult result = dlg.get_result(); + std::vector component_dialog_indices; + std::vector ratios; + for (const DecomposeComponent& comp : result.components) { + ratios.push_back(comp.ratio); + if (comp.filament_index > 0) { + const size_t physical_idx = (size_t)(comp.filament_index - 1); + if (physical_idx >= physical_dialog_indices.size()) + return false; + component_dialog_indices.push_back(physical_dialog_indices[physical_idx]); + continue; + } + + const std::string comp_hex = texture_normalize_color_hex(comp.colour.GetAsString(wxC2S_HTML_SYNTAX).ToStdString()); + int existing_idx = -1; + for (const auto& entry : m_filament_entries) { + if (!texture_entry_is_physical(entry.kind)) + continue; + if (texture_normalize_color_hex(entry.color_hex) != comp_hex) + continue; + if (texture_entry_official_basic(entry)) { + existing_idx = entry.dialog_index; + break; + } + } + if (existing_idx < 0) { + std::array rgba = parse_color_string(comp_hex); + existing_idx = add_virtual_filament(rgba, comp_hex); + if (existing_idx < 0) + return false; + } + component_dialog_indices.push_back(existing_idx); + } + + if (component_dialog_indices.size() < 2 || component_dialog_indices.size() != ratios.size()) + return false; + + const std::string mixed_hex = texture_normalize_color_hex( + result.matched_color.GetAsString(wxC2S_HTML_SYNTAX).ToStdString()); + int mixed_idx = add_virtual_mixed_filament(mixed_hex, component_dialog_indices, ratios); + if (mixed_idx < 0) + return false; + + m_mapping_rows[row_index].target_filament_idx = mixed_idx; + if (row_index < m_current_matches.size()) + m_current_matches[row_index].filament_index = mixed_idx; + rebuild_mapping_rows(); + update_filament_color_map(); + return true; +} + +void TextureImportDialog::dismiss_filament_popup_on_wheel(wxMouseEvent& evt) +{ + dismiss_filament_popup(); + dismiss_auto_mix_popup(); + evt.Skip(); +} + +void TextureImportDialog::show_filament_popup(size_t row_index) +{ + if (row_index >= m_mapping_rows.size()) return; + + if (m_skip_next_filament_popup_row == (int)row_index) { + m_skip_next_filament_popup_row = -1; + return; + } + + if (m_filament_popup && m_filament_popup->IsShown()) { + if (m_filament_popup_row == (int)row_index) { + dismiss_filament_popup(); + return; + } + dismiss_filament_popup(); + } + + const auto display_numbers = compute_display_numbers(); + auto display_number = [display_numbers](int idx) -> int { + return (idx >= 0 && idx < (int)display_numbers.size() && display_numbers[idx] > 0) + ? display_numbers[idx] : idx + 1; + }; + + auto on_select = [this, row_index, display_number](int idx) { + if (row_index >= m_mapping_rows.size()) return; + m_mapping_rows[row_index].target_filament_idx = idx; + if (row_index < m_current_matches.size()) + m_current_matches[row_index].filament_index = idx; + if (m_mapping_rows[row_index].target_panel) { + wxString label = (idx >= 0 && idx < (int)m_filament_names.size()) + ? filament_name_to_wx_string(m_filament_names[idx]) + : wxString::Format("Filament %d", display_number(idx)); + m_mapping_rows[row_index].target_panel->SetToolTip(label); + m_mapping_rows[row_index].target_panel->Refresh(); + } + update_filament_color_map(); + }; + + auto on_add_filament = [this, row_index](wxColour clr) { + std::array rgba = {clr.Red() / 255.f, clr.Green() / 255.f, + clr.Blue() / 255.f, 1.0f}; + std::string hex = wxString::Format("#%02X%02X%02X", + clr.Red(), clr.Green(), clr.Blue()).ToStdString(); + int new_idx = add_virtual_filament(rgba, hex); + if (new_idx < 0) + return; + + if (row_index < m_mapping_rows.size()) { + m_mapping_rows[row_index].target_filament_idx = new_idx; + if (row_index < m_current_matches.size()) + m_current_matches[row_index].filament_index = new_idx; + } + rebuild_mapping_rows(); + update_filament_color_map(); + }; + + auto on_decompose_color = [this, row_index]() { + CallAfter([this, row_index]() { + add_decomposed_mixed_filament(row_index); + }); + }; + + wxPanel* tp = m_mapping_rows[row_index].target_panel; + if (!tp) return; + + auto on_close = [this, row_index](bool closed_by_action) { + if (m_filament_popup_row == (int)row_index) { + m_filament_popup = nullptr; + m_filament_popup_row = -1; + } + if (!closed_by_action) { + m_skip_next_filament_popup_row = (int)row_index; + CallAfter([this, row_index]() { + if (m_skip_next_filament_popup_row == (int)row_index) + m_skip_next_filament_popup_row = -1; + }); + } + }; + + auto* popup = new FilamentSelectPopup( + this, m_filament_entries, m_filament_colors_rgba, m_filament_names, + m_existing_filament_count, tp->GetSize().x, tp, on_select, on_add_filament, + on_decompose_color, + [this]() { return can_add_virtual_filament(); }, + on_close, + display_numbers); + + wxPoint pos = tp->ClientToScreen(wxPoint(0, tp->GetSize().y)); + wxRect display_rect; + int display_idx = wxDisplay::GetFromPoint(pos); + if (display_idx != wxNOT_FOUND) + display_rect = wxDisplay(display_idx).GetClientArea(); + else + display_rect = wxDisplay().GetClientArea(); + pos.x = std::clamp(pos.x, display_rect.GetLeft(), + std::max(display_rect.GetLeft(), display_rect.GetRight() - popup->GetSize().x)); + popup->Position(pos, wxSize(0, 0)); + popup->Bind(wxEVT_DESTROY, [this, popup](wxWindowDestroyEvent& e) { + e.Skip(); + if (m_filament_popup == popup) { + m_filament_popup = nullptr; + m_filament_popup_row = -1; + } + }); + m_filament_popup = popup; + m_filament_popup_row = (int)row_index; + popup->Popup(); +} + +void TextureImportDialog::do_auto_match() +{ + if (m_painted.cluster_colors.empty()) return; + + // do_auto_match() always rebuilds the baseline mapping without any mixed + // filaments, so it is the common entry for every "revert one-click mix" + // path. Clear the applied flag here; callers refresh the reset button. + m_auto_mix_applied = false; + + // Reset the "filaments were dropped" flag at the start of every run, so it + // strictly reflects what happens during *this* match (no historical + // accumulation). add_virtual_filament() will flip it back to true if and + // only if it hits the global filament cap below. + m_filaments_dropped = false; + + // Drop any virtual filaments left over from previous match runs that the + // current m_current_matches no longer references. Without this, the + // residual virtual filaments inflate m_filament_colors_rgba.size() at the + // entry of this match, which can make add_virtual_filament() fail (and + // wrongly flip m_filaments_dropped to true) even when the *real* count + // of needed virtual filaments for this run is well below the global cap. + // This is purely a state cleanup; it does not change any mapping rule. + compact_used_virtual_filaments(); + + const auto previous_matches = m_current_matches; + + std::map, int> previous_virtual_by_cluster; + for (const auto& match : previous_matches) { + if (match.filament_index >= (int)m_existing_filament_count && + match.filament_index < (int)m_filament_entries.size() && + texture_entry_is_physical(m_filament_entries[match.filament_index].kind)) { + previous_virtual_by_cluster[match.cluster_color] = match.filament_index; + } + } + + auto find_virtual_filament_by_color = [this](const std::array& color) -> int { + std::string hex = rgb_to_hex(color).ToStdString(); + for (size_t i = m_existing_filament_count; i < m_filament_color_strs.size(); ++i) { + if (m_filament_color_strs[i] == hex && + i < m_filament_entries.size() && texture_entry_is_physical(m_filament_entries[i].kind)) + return (int)i; + } + return -1; + }; + + auto get_or_add_virtual_filament = [this, &previous_virtual_by_cluster, &find_virtual_filament_by_color]( + const std::array& color) -> int { + auto previous_it = previous_virtual_by_cluster.find(color); + if (previous_it != previous_virtual_by_cluster.end() && + previous_it->second >= (int)m_existing_filament_count && + previous_it->second < (int)m_filament_colors_rgba.size()) { + return previous_it->second; + } + + int existing_idx = find_virtual_filament_by_color(color); + if (existing_idx >= 0) + return existing_idx; + + std::array rgba = { + color[0] / 255.f, + color[1] / 255.f, + color[2] / 255.f, + 1.f + }; + return add_virtual_filament(rgba, rgb_to_hex(color).ToStdString()); + }; + + if (m_auto_merge_cb && m_auto_merge_cb->GetValue()) { + // Match clusters to closest existing filaments + std::vector names; + for (size_t i = 0; i < m_existing_filament_count; ++i) + names.push_back(m_filament_names.size() > i ? m_filament_names[i] : "Filament " + std::to_string(i + 1)); + + std::vector> existing_filament_colors( + m_filament_colors_rgba.begin(), + m_filament_colors_rgba.begin() + std::min(m_existing_filament_count, m_filament_colors_rgba.size())); + + m_current_matches = Slic3r::match_clusters_to_filaments( + m_painted.cluster_colors, existing_filament_colors, names); + + // For clusters with poor match (CIEDE2000 ΔE > 5), create virtual filaments. + constexpr double NEW_FILAMENT_THRESHOLD = 5.0; + std::map, int> virtual_color_index; + + for (auto& m : m_current_matches) { + if (m.delta_e <= NEW_FILAMENT_THRESHOLD) + continue; + + auto it = virtual_color_index.find(m.cluster_color); + if (it != virtual_color_index.end()) { + m.filament_index = it->second; + } else { + int new_idx = get_or_add_virtual_filament(m.cluster_color); + if (new_idx >= 0) + virtual_color_index[m.cluster_color] = new_idx; + m.filament_index = new_idx >= 0 ? new_idx : find_closest_filament_index(m.cluster_color); + } + if (m.filament_index >= 0 && m.filament_index < (int)m_filament_colors_rgba.size()) { + m.filament_color = m_filament_colors_rgba[m.filament_index]; + m.delta_e = Slic3r::compute_delta_e(m.cluster_color, m.filament_color); + if (m.filament_index >= (int)m_existing_filament_count) + m.delta_e = 0.0; + } + } + } else { + // Keep all virtual filaments in this dialog; unused ones are pruned only on OK. + m_current_matches.clear(); + std::map, int> virtual_map; + + for (size_t i = 0; i < m_painted.cluster_colors.size(); ++i) { + const auto& cc = m_painted.cluster_colors[i]; + Slic3r::FilamentMatch fm; + fm.cluster_index = (int)i; + fm.cluster_color = cc; + + auto it = virtual_map.find(cc); + if (it != virtual_map.end()) { + fm.filament_index = it->second; + } else { + int idx = get_or_add_virtual_filament(cc); + if (idx >= 0) + virtual_map[cc] = idx; + fm.filament_index = idx >= 0 ? idx : find_closest_filament_index(cc); + } + if (fm.filament_index >= 0 && fm.filament_index < (int)m_filament_colors_rgba.size()) { + fm.filament_color = m_filament_colors_rgba[fm.filament_index]; + fm.delta_e = Slic3r::compute_delta_e(fm.cluster_color, fm.filament_color); + if (fm.filament_index >= (int)m_existing_filament_count) + fm.delta_e = 0.0; + } + m_current_matches.push_back(fm); + } + } + + update_filament_color_map(); +} + +void TextureImportDialog::rebuild_mapping_rows() +{ + m_mapping_scroll->Freeze(); + m_mapping_sizer->Clear(true); + m_mapping_rows.clear(); + + if (m_current_matches.empty()) { + m_mapping_scroll->FitInside(); + m_mapping_scroll->Thaw(); + return; + } + + auto get_target_wxcolor = [this](int idx) -> wxColour { + if (idx >= 0 && idx < (int)m_filament_colors_rgba.size()) { + const auto& c = m_filament_colors_rgba[idx]; + return wxColour((unsigned char)(c[0] * 255.f), + (unsigned char)(c[1] * 255.f), + (unsigned char)(c[2] * 255.f)); + } + return wxColour(128, 128, 128); + }; + + const auto display_numbers = compute_display_numbers(); + auto display_number = [display_numbers](int idx) -> int { + return (idx >= 0 && idx < (int)display_numbers.size() && display_numbers[idx] > 0) + ? display_numbers[idx] : idx + 1; + }; + + auto get_filament_label = [this, display_number](int idx) -> wxString { + if (idx >= 0 && idx < (int)m_filament_names.size()) + return filament_name_to_wx_string(m_filament_names[idx]); + return wxString::Format("Filament %d", display_number(idx)); + }; + + const wxColour dash_clr = StateColor::darkModeColorFor(wxColour("#ACACAC")); + const wxColour hex_fg = texture_import_text_colour(); + const wxColour card_bg = StateColor::darkModeColorFor(wxColour("#E8E8E8")); + const wxColour card_bd = StateColor::darkModeColorFor(wxColour("#DBDBDB")); + const wxColour name_fg = texture_import_text_colour(); + const wxColour chev_clr = StateColor::darkModeColorFor(wxColour("#6B6B6A")); + + m_mapping_rows.resize(m_current_matches.size()); + for (size_t ci = 0; ci < m_current_matches.size(); ++ci) { + auto& row = m_mapping_rows[ci]; + row.cluster_id = m_current_matches[ci].cluster_index; + row.source_color = m_current_matches[ci].cluster_color; + row.source_hex = rgb_to_hex(row.source_color).ToStdString(); + row.target_filament_idx = m_current_matches[ci].filament_index; + + wxColour src_wx_color( + (unsigned char)row.source_color[0], + (unsigned char)row.source_color[1], + (unsigned char)row.source_color[2]); + + // --- Row container --- + wxPanel* row_panel = new wxPanel(m_mapping_scroll, wxID_ANY); + row_panel->SetBackgroundColour(m_mapping_scroll->GetBackgroundColour()); + row_panel->Bind(wxEVT_MOUSEWHEEL, &TextureImportDialog::dismiss_filament_popup_on_wheel, this); + wxBoxSizer* row_sizer = new wxBoxSizer(wxHORIZONTAL); + + // --- Source card (dashed border, circle + hex) --- + const int src_w = FromDIP(138); + const int target_min_w = FromDIP(239); + const int row_h = FromDIP(44); + row.source_panel = new wxPanel(row_panel, wxID_ANY, wxDefaultPosition, wxSize(src_w, row_h), + wxTAB_TRAVERSAL | wxFULL_REPAINT_ON_RESIZE); + row.source_panel->SetMinSize(wxSize(src_w, row_h)); + row.source_panel->SetMaxSize(wxSize(src_w, row_h)); + row.source_panel->SetBackgroundStyle(wxBG_STYLE_PAINT); + + row.source_panel->Bind(wxEVT_PAINT, [this, ci, src_wx_color, dash_clr, hex_fg](wxPaintEvent& e) { + auto* p = static_cast(e.GetEventObject()); + wxAutoBufferedPaintDC dc(p); + wxSize sz = p->GetClientSize(); + + dc.SetBrush(wxBrush(p->GetParent()->GetBackgroundColour())); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.x, sz.y); + + wxPen dash_pen(dash_clr, 1, wxPENSTYLE_SHORT_DASH); + dc.SetPen(dash_pen); + dc.SetBrush(wxBrush(p->GetParent()->GetBackgroundColour())); + int r = p->FromDIP(8); + dc.DrawRoundedRectangle(0, 0, sz.x, sz.y, r); + + // Color circle 24px + int cd = p->FromDIP(24); + int cx = p->FromDIP(10); + int cy = (sz.y - cd) / 2; + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(src_wx_color)); + dc.DrawEllipse(cx, cy, cd, cd); + draw_filament_swatch_ellipse_border(dc, src_wx_color, cx, cy, cd, cd); + + if (ci < m_mapping_rows.size()) { + wxFont hex_font = p->GetFont(); + hex_font.SetPointSize(9); + dc.SetFont(hex_font); + dc.SetTextForeground(hex_fg); + wxString hex_str = wxString::Format("# %s", m_mapping_rows[ci].source_hex.substr(1)); + wxSize tsz = dc.GetTextExtent(hex_str); + dc.DrawText(hex_str, cx + cd + p->FromDIP(6), (sz.y - tsz.y) / 2); + } + }); + row.source_panel->Bind(wxEVT_SIZE, [](wxSizeEvent& e) { + e.Skip(); + static_cast(e.GetEventObject())->Refresh(); + }); + row.source_panel->Bind(wxEVT_MOUSEWHEEL, &TextureImportDialog::dismiss_filament_popup_on_wheel, this); + + row_sizer->Add(row.source_panel, 0, wxEXPAND); + + // --- Arrow panel (dashed arrow) --- + const int arrow_w = FromDIP(24); + wxPanel* arrow_panel = new wxPanel(row_panel, wxID_ANY, wxDefaultPosition, wxSize(arrow_w, row_h)); + arrow_panel->SetMinSize(wxSize(arrow_w, row_h)); + arrow_panel->SetMaxSize(wxSize(arrow_w, row_h)); + arrow_panel->SetBackgroundStyle(wxBG_STYLE_PAINT); + arrow_panel->Bind(wxEVT_MOUSEWHEEL, &TextureImportDialog::dismiss_filament_popup_on_wheel, this); + arrow_panel->Bind(wxEVT_PAINT, [dash_clr](wxPaintEvent& e) { + auto* p = static_cast(e.GetEventObject()); + wxAutoBufferedPaintDC dc(p); + wxSize sz = p->GetClientSize(); + + dc.SetBrush(wxBrush(p->GetParent()->GetBackgroundColour())); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.x, sz.y); + + int mid_y = sz.y / 2; + int margin = p->FromDIP(2); + int arrow_tip = sz.x - margin; + int arrow_start = margin; + + wxPen dash_pen(dash_clr, p->FromDIP(1), wxPENSTYLE_SHORT_DASH); + dc.SetPen(dash_pen); + dc.DrawLine(arrow_start, mid_y, arrow_tip - p->FromDIP(4), mid_y); + + int ah = p->FromDIP(4); + wxPoint tri[3] = { + {arrow_tip, mid_y}, + {arrow_tip - ah, mid_y - ah / 2}, + {arrow_tip - ah, mid_y + ah / 2} + }; + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(dash_clr)); + dc.DrawPolygon(3, tri); + }); + + row_sizer->Add(arrow_panel, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, FromDIP(4)); + + // --- Target card (numbered square + material name + chevron) --- + row.target_panel = new wxPanel(row_panel, wxID_ANY, wxDefaultPosition, wxSize(-1, row_h), + wxTAB_TRAVERSAL | wxFULL_REPAINT_ON_RESIZE); + row.target_panel->SetMinSize(wxSize(target_min_w, row_h)); + row.target_panel->SetToolTip(get_filament_label(row.target_filament_idx)); + row.target_panel->SetBackgroundStyle(wxBG_STYLE_PAINT); + row.target_panel->SetCursor(wxCursor(wxCURSOR_HAND)); + + row.target_panel->Bind(wxEVT_PAINT, [this, ci, get_target_wxcolor, get_filament_label, + display_number, card_bg, card_bd, name_fg, chev_clr](wxPaintEvent& e) { + auto* p = static_cast(e.GetEventObject()); + wxAutoBufferedPaintDC dc(p); + wxSize sz = p->GetClientSize(); + + dc.SetBrush(wxBrush(p->GetParent()->GetBackgroundColour())); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, sz.x, sz.y); + + if (ci >= m_mapping_rows.size()) return; + int fil_idx = m_mapping_rows[ci].target_filament_idx; + + int r = p->FromDIP(8); + dc.SetBrush(wxBrush(card_bg)); + dc.SetPen(wxPen(card_bd, 1)); + dc.DrawRoundedRectangle(0, 0, sz.x, sz.y, r); + + if (fil_idx >= 0 && fil_idx < (int)m_filament_entries.size() && + texture_entry_is_mixed(m_filament_entries[fil_idx].kind)) { + const TextureFilamentEntry& entry = m_filament_entries[fil_idx]; + wxFont mixed_font = p->GetFont(); + mixed_font.SetPointSize(10); + dc.SetFont(mixed_font); + + int x = p->FromDIP(10); + const int sw = p->FromDIP(28); + const int sw_r = p->FromDIP(6); + const int sw_y = (sz.y - sw) / 2; + for (size_t mi = 0; mi < entry.mixed_components.size() && mi < entry.mixed_ratios.size(); ++mi) { + if (mi > 0) { + dc.SetTextForeground(name_fg); + wxString plus = "+"; + wxSize psz = dc.GetTextExtent(plus); + dc.DrawText(plus, x, (sz.y - psz.y) / 2); + x += psz.x + p->FromDIP(4); + } + + const unsigned int comp_id = entry.mixed_components[mi]; + const int comp_idx = comp_id >= 1 ? (int)comp_id - 1 : -1; + wxColour comp_clr("#D9D9D9"); + if (comp_idx >= 0 && comp_idx < (int)m_filament_colors_rgba.size()) { + const auto& c = m_filament_colors_rgba[comp_idx]; + comp_clr = wxColour((unsigned char)(c[0] * 255.f), + (unsigned char)(c[1] * 255.f), + (unsigned char)(c[2] * 255.f)); + } + + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(comp_clr)); + dc.DrawRoundedRectangle(x, sw_y, sw, sw, sw_r); + draw_filament_swatch_border(dc, comp_clr, x, sw_y, sw, sw, sw_r); + + wxString num_str = wxString::Format("%d", display_number(comp_idx)); + wxSize nsz = dc.GetTextExtent(num_str); + dc.SetTextForeground(comp_clr.GetLuminance() < 0.6 ? *wxWHITE : texture_import_gray9000()); + dc.DrawText(num_str, x + (sw - nsz.x) / 2, sw_y + (sw - nsz.y) / 2); + x += sw + p->FromDIP(5); + + dc.SetTextForeground(name_fg); + wxString pct = wxString::Format("%d%%", entry.mixed_ratios[mi]); + wxSize pct_sz = dc.GetTextExtent(pct); + dc.DrawText(pct, x, (sz.y - pct_sz.y) / 2); + x += pct_sz.x + p->FromDIP(5); + if (x > sz.x - p->FromDIP(34)) + break; + } + + int chev_cx = sz.x - p->FromDIP(14); + int chev_cy = sz.y / 2; + int hw = p->FromDIP(3); + int hh = p->FromDIP(2); + dc.SetPen(wxPen(chev_clr, p->FromDIP(1) > 0 ? p->FromDIP(1) : 1)); + dc.DrawLine(chev_cx - hw, chev_cy - hh, chev_cx, chev_cy + hh); + dc.DrawLine(chev_cx, chev_cy + hh, chev_cx + hw, chev_cy - hh); + return; + } + + // Numbered color square 32x32, rounded 6px + int sq = p->FromDIP(32); + int sq_x = p->FromDIP(6); + int sq_y = (sz.y - sq) / 2; + int sq_r = p->FromDIP(6); + wxColour fil_clr = get_target_wxcolor(fil_idx); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(fil_clr)); + dc.DrawRoundedRectangle(sq_x, sq_y, sq, sq, sq_r); + draw_filament_swatch_border(dc, fil_clr, sq_x, sq_y, sq, sq, sq_r); + + { + wxFont num_font = p->GetFont(); + num_font.SetPointSize(10); + dc.SetFont(num_font); + dc.SetTextForeground(fil_clr.GetLuminance() < 0.6 ? *wxWHITE : texture_import_gray9000()); + wxString num_str = wxString::Format("%d", display_number(fil_idx)); + wxSize nsz = dc.GetTextExtent(num_str); + dc.DrawText(num_str, sq_x + (sq - nsz.x) / 2, sq_y + (sq - nsz.y) / 2); + } + + // Material name + { + wxFont name_font = p->GetFont(); + name_font.SetPointSize(9); + dc.SetFont(name_font); + dc.SetTextForeground(name_fg); + wxString name_str = get_filament_label(fil_idx); + int text_x = sq_x + sq + p->FromDIP(8); + int max_text_w = sz.x - text_x - p->FromDIP(24); + if (max_text_w > 0) { + name_str = ellipsize_text(dc, name_str, max_text_w); + wxSize tsz = dc.GetTextExtent(name_str); + dc.DrawText(name_str, text_x, (sz.y - tsz.y) / 2); + } + } + + // Dropdown chevron at right edge + { + int chev_cx = sz.x - p->FromDIP(14); + int chev_cy = sz.y / 2; + int hw = p->FromDIP(3); + int hh = p->FromDIP(2); + dc.SetPen(wxPen(chev_clr, p->FromDIP(1) > 0 ? p->FromDIP(1) : 1)); + dc.DrawLine(chev_cx - hw, chev_cy - hh, chev_cx, chev_cy + hh); + dc.DrawLine(chev_cx, chev_cy + hh, chev_cx + hw, chev_cy - hh); + } + }); + + row.target_panel->Bind(wxEVT_LEFT_DOWN, [this, ci](wxMouseEvent&) { + show_filament_popup(ci); + }); + row.target_panel->Bind(wxEVT_SIZE, [](wxSizeEvent& e) { + e.Skip(); + static_cast(e.GetEventObject())->Refresh(); + }); + row.target_panel->Bind(wxEVT_MOUSEWHEEL, &TextureImportDialog::dismiss_filament_popup_on_wheel, this); + + row_sizer->Add(row.target_panel, 1, wxEXPAND); + + row_panel->SetSizer(row_sizer); + m_mapping_sizer->Add(row_panel, 0, wxEXPAND | wxBOTTOM, FromDIP(12)); + } + + m_mapping_scroll->FitInside(); + m_mapping_scroll->Layout(); + m_mapping_scroll->Thaw(); +} + +std::vector TextureImportDialog::build_matches_from_rows() const +{ + std::vector matches(m_mapping_rows.size()); + for (size_t i = 0; i < m_mapping_rows.size(); ++i) { + auto& m = matches[i]; + m.cluster_index = m_mapping_rows[i].cluster_id; + m.cluster_color = m_mapping_rows[i].source_color; + + int sel = m_mapping_rows[i].target_filament_idx; + if (sel >= 0 && sel < (int)m_filament_colors_rgba.size()) { + m.filament_index = sel; + m.filament_color = m_filament_colors_rgba[sel]; + m.delta_e = Slic3r::compute_delta_e(m.cluster_color, m.filament_color); + } + } + return matches; +} + +// ---- Event handlers ---- + +void TextureImportDialog::update_color_count_preset_buttons() +{ + if (m_btn_color_4) m_btn_color_4->SetValue(m_param_color_count == 4); + if (m_btn_color_8) m_btn_color_8->SetValue(m_param_color_count == 8); + if (m_btn_color_16) m_btn_color_16->SetValue(m_param_color_count == 16); +} + +void TextureImportDialog::set_color_count_value(int value, bool update_spin) +{ + m_param_color_count = std::clamp(value, 1, (int)max_filament_count()); + m_color_slider->SetValue(m_param_color_count); + if (update_spin) + m_color_spin->SetValue(m_param_color_count); + update_color_count_preset_buttons(); + update_confirm_button_state(); +} + +void TextureImportDialog::set_smooth_value(int value, bool update_spin) +{ + m_param_smooth = std::clamp(value, 0, 10); + m_smooth_slider->SetValue(m_param_smooth); + if (update_spin) + m_smooth_spin->SetValue(m_param_smooth); + update_confirm_button_state(); +} + +void TextureImportDialog::preview_spin_text_value(SpinInput* spin, AccentSlider* slider, int& param, + int min_value, int max_value, const wxString& text, + std::function on_value_changed) +{ + long value; + if (!text.ToLong(&value)) + return; + + wxTextCtrl* tc = spin->GetTextCtrl(); + long parsed = value; + value = std::clamp((int)parsed, min_value, max_value); + + wxString normalized = text; + if (parsed > max_value || (text.length() > 1 && text[0] == '0')) + normalized = wxString::Format("%ld", value); + + if (normalized != text) { + long pos = tc->GetInsertionPoint(); + tc->ChangeValue(normalized); + if (parsed > max_value) + tc->SetInsertionPointEnd(); + else + tc->SetInsertionPoint(std::min(normalized.length(), std::max(0L, pos - 1))); + } + + param = (int)value; + slider->SetValue(param); + if (on_value_changed) + on_value_changed(); + update_confirm_button_state(); +} + +void TextureImportDialog::on_color_preset_clicked(wxCommandEvent& evt) +{ + int id = evt.GetId(); + int color_count = m_param_color_count; + if (id == ID_COLOR_4) { color_count = 4; } + if (id == ID_COLOR_8) { color_count = 8; } + if (id == ID_COLOR_16) { color_count = 16; } + + if (id == ID_COLOR_AUTO) { + start_computation(true); + return; + } + + set_color_count_value(color_count, true); +} + +void TextureImportDialog::on_color_slider_changed(wxCommandEvent&) +{ + set_color_count_value(m_color_slider->GetValue(), true); +} + +void TextureImportDialog::on_color_spin_changed(wxCommandEvent&) +{ + set_color_count_value(m_color_spin->GetValue(), true); +} + +void TextureImportDialog::on_color_spin_text_changed(wxCommandEvent& evt) +{ + preview_spin_text_value(m_color_spin, m_color_slider, m_param_color_count, + 1, (int)max_filament_count(), evt.GetString(), + [this]() { update_color_count_preset_buttons(); }); +} + +void TextureImportDialog::on_smooth_slider_changed(wxCommandEvent&) +{ + set_smooth_value(m_smooth_slider->GetValue(), true); +} + +void TextureImportDialog::on_smooth_spin_changed(wxCommandEvent&) +{ + set_smooth_value(m_smooth_spin->GetValue(), true); +} + +void TextureImportDialog::on_smooth_spin_text_changed(wxCommandEvent& evt) +{ + preview_spin_text_value(m_smooth_spin, m_smooth_slider, m_param_smooth, + 0, 10, evt.GetString()); +} + +void TextureImportDialog::on_apply_clicked(wxCommandEvent&) +{ + start_computation(); +} + +void TextureImportDialog::on_auto_merge_toggled(wxCommandEvent&) +{ + bool auto_merge_enabled = !m_auto_merge_cb || m_auto_merge_cb->GetValue(); + m_auto_merge_enabled = auto_merge_enabled; + + if (m_state == TextureImportState::Ready) { + const auto previous_matches = m_current_matches; + do_auto_match(); + restore_current_match_order(previous_matches); + compact_used_virtual_filaments(); + update_filament_color_map(); + rebuild_mapping_rows(); + update_drop_warning_visibility(); + update_auto_mix_reset_visibility(); + } +} + +void TextureImportDialog::highlight_view_button(int view_index) +{ + Button* btns[] = { m_btn_view_original, m_btn_view_multicolor }; + + // The inactive pill lies on m_tab_panel, which is preview_bg (#EEEEEE -> #4C4C55), and has to + // read as raised above that strip in both themes — so its fill steps away from the strip in + // opposite directions. gDarkColors pairs one light tone with one dark tone and cannot express + // an inversion, so the two are picked here the way filament_swatch_border_colour() does. + const bool dark_pill = is_dark(); + StateColor inactive_bg( + std::pair(dark_pill ? wxColour(0x5C, 0x5C, 0x64) : wxColour("#F4F4F4"), StateColor::Pressed), + std::pair(dark_pill ? wxColour(0x66, 0x66, 0x6E) : wxColour("#F8F8F8"), StateColor::Hovered), + std::pair(dark_pill ? wxColour(0x54, 0x54, 0x5B) : *wxWHITE, StateColor::Normal)); + const wxColour inactive_bd = dark_pill ? wxColour(0x54, 0x54, 0x5B) : *wxWHITE; + const wxColour inactive_text = wxColour("#6B6B6A"); + + for (int i = 0; i < 2; ++i) { + if (!btns[i]) continue; + if (i == view_index) { + apply_accent_button_colours(btns[i]); + } else { + btns[i]->SetBackgroundColor(inactive_bg); + btns[i]->SetBorderColor(inactive_bd); + btns[i]->SetTextColor(inactive_text); + } + btns[i]->Refresh(); + } +} + +void TextureImportDialog::on_skip_clicked(wxCommandEvent&) +{ + m_skipped = true; + m_new_filament_colors.clear(); + m_new_filament_preset_names.clear(); + m_new_mixed_filaments.clear(); + m_current_matches.clear(); + cancel_computation(); + EndModal(wxID_CANCEL); +} + +bool TextureImportDialog::has_valid_result() const +{ + if (m_painted.face_colors.empty() || m_current_matches.empty() || m_mapping_rows.empty()) + return false; + + if (m_mapping_rows.size() != m_current_matches.size()) + return false; + + const int filament_count = (int)std::min(m_filament_colors_rgba.size(), max_filament_count()); + for (const auto& row : m_mapping_rows) { + if (row.target_filament_idx < 0 || row.target_filament_idx >= filament_count) + return false; + } + return true; +} + +bool TextureImportDialog::is_params_dirty() const +{ + if (m_applied_color_count < 0) + return false; + return m_param_color_count != m_applied_color_count + || m_param_smooth != m_applied_smooth; +} + +void TextureImportDialog::update_drop_warning_visibility() +{ + if (!m_drop_warning_label) return; + // Show only when the most recent do_auto_match() ran into the filament + // cap AND we are in the Ready state. The flag is reset at every + // do_auto_match() entry, so any "clean" re-run automatically hides the + // warning even if a previous run had dropped clusters. + const bool show = (m_state == TextureImportState::Ready) && m_filaments_dropped; + if (m_drop_warning_label->IsShown() == show) return; + m_drop_warning_label->Show(show); + Layout(); +} + +void TextureImportDialog::update_confirm_button_state() +{ + if (m_state != TextureImportState::Ready) + return; + + if (!has_valid_result()) { + m_btn_ok->Enable(false); + if (m_hint_label) m_hint_label->Hide(); + m_btn_ok->Refresh(); + Layout(); + return; + } + + m_btn_ok->Enable(true); + style_confirm_button(is_params_dirty()); + + m_btn_ok->Refresh(); + Layout(); +} + +// Both state updaters land here: the Confirm button reads as accent only while it would apply +// exactly what the preview shows. +void TextureImportDialog::style_confirm_button(bool dirty) +{ + if (dirty) { + apply_muted_button_colours(m_btn_ok); + m_btn_ok->SetToolTip(_L("Reminder: parameters changed, click Apply to take effect")); + } else { + apply_accent_button_colours(m_btn_ok); + m_btn_ok->UnsetToolTip(); + } + if (m_hint_label) + m_hint_label->Show(dirty); +} + +void TextureImportDialog::on_ok_clicked(wxCommandEvent&) +{ + if (m_state != TextureImportState::Ready || !has_valid_result() || is_params_dirty()) + return; + + m_current_matches = build_matches_from_rows(); + if (m_current_matches.empty()) + return; + + compact_used_virtual_filaments(); + + EndModal(wxID_OK); +} + +// ---- Result accessors ---- + +Slic3r::PaintedMesh TextureImportDialog::get_painted_mesh() const +{ + return m_painted; +} + +std::vector TextureImportDialog::get_matches() const +{ + if (!m_current_matches.empty()) + return m_current_matches; + return build_matches_from_rows(); +} + +void TextureImportDialog::on_dpi_changed(const wxRect&) +{ + // All control sizes below are baked into persistent properties (min size, + // corner radius, fixed wxSize) using FromDIP() at build time. The base + // DPIAware::rescale() only rescales fonts; it does not recompute these + // stored pixel values. Re-apply them here so the layout stays consistent + // when the dialog is dragged to a screen with a different DPI. + SetMinSize(wxSize(FromDIP(800), FromDIP(500))); + + const int view_button_height = FromDIP(27); + for (Button* btn : {m_btn_view_original, m_btn_view_multicolor}) { + if (btn) { + btn->SetCornerRadius(view_button_height / 2); + btn->SetMinSize(wxSize(FromDIP(57), view_button_height)); + } + } + + for (Button* btn : {m_btn_color_4, m_btn_color_8, m_btn_color_16}) { + if (btn) { + btn->SetCornerRadius(FromDIP(12)); + btn->SetMinSize(wxSize(FromDIP(28), FromDIP(28))); + } + } + + if (m_btn_color_auto) { + m_btn_color_auto->SetCornerRadius(FromDIP(12)); + m_btn_color_auto->SetMinSize(wxSize(FromDIP(60), FromDIP(28))); + } + if (m_btn_apply) { + m_btn_apply->SetCornerRadius(FromDIP(12)); + m_btn_apply->SetMinSize(wxSize(FromDIP(60), FromDIP(28))); + } + if (m_btn_auto_mix) { + m_btn_auto_mix->SetCornerRadius(FromDIP(14)); + m_btn_auto_mix->SetMinSize(wxSize(FromDIP(178), FromDIP(28))); + } + if (m_btn_mix_reset) + m_btn_mix_reset->SetPaddingSize(wxSize(FromDIP(2), FromDIP(2))); + + if (m_color_spin) + m_color_spin->SetMinSize(wxSize(FromDIP(60), FromDIP(28))); + if (m_smooth_spin) + m_smooth_spin->SetMinSize(wxSize(FromDIP(60), FromDIP(28))); + + if (m_mapping_scroll) { + m_mapping_scroll->SetMinSize(wxSize(-1, FromDIP(300))); + m_mapping_scroll->SetScrollRate(0, FromDIP(10)); + } + + if (m_btn_skip) { + m_btn_skip->SetCornerRadius(FromDIP(20)); + m_btn_skip->SetMinSize(wxSize(FromDIP(136), FromDIP(40))); + } + if (m_btn_ok) { + m_btn_ok->SetCornerRadius(FromDIP(20)); + m_btn_ok->SetMinSize(wxSize(FromDIP(156), FromDIP(40))); + } + + // Mapping rows store their panel sizes (source/target/arrow/row height) + // as fixed FromDIP min/max sizes, so rebuild them to pick up the new DPI. + rebuild_mapping_rows(); + + if (wxSizer* sizer = GetSizer()) + sizer->Layout(); + Layout(); + Refresh(); + wxGetApp().UpdateDlgDarkUI(this); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/TextureImportDialog.hpp b/src/slic3r/GUI/TextureImportDialog.hpp new file mode 100644 index 0000000000..960bac6145 --- /dev/null +++ b/src/slic3r/GUI/TextureImportDialog.hpp @@ -0,0 +1,407 @@ +#pragma once + +#include "GUI_Utils.hpp" +#include "Widgets/ProgressDialog.hpp" +#include "libslic3r/TexturePainting.hpp" + +#include +#include +#include "Widgets/PopupWindow.hpp" +#include +#include +#include +#include "Widgets/SpinInput.hpp" +#include +#include +#include "Widgets/Button.hpp" +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class AccentSlider; + +namespace Slic3r { namespace GUI { + +wxDECLARE_EVENT(EVT_TEXTURE_COMPUTE_DONE, wxCommandEvent); +wxDECLARE_EVENT(EVT_TEXTURE_COMPUTE_PROGRESS, wxCommandEvent); +wxDECLARE_EVENT(EVT_TEXTURE_COMPUTE_ERROR, wxCommandEvent); +wxDECLARE_EVENT(EVT_TEXTURE_MESH_REPAIR_DECISION, wxCommandEvent); + +enum class TextureImportState { + Idle, + Computing, + Ready, + Error +}; + +enum class TextureAutoMixMode { + CMYW, + RYBW +}; + +enum class TextureFilamentKind { + ExistingPhysical, + ExistingMixed, + NewPhysical, + NewMixed +}; + +struct TextureFilamentEntry { + TextureFilamentKind kind{TextureFilamentKind::ExistingPhysical}; + int dialog_index{-1}; + size_t project_config_index{size_t(-1)}; + std::string color_hex; + std::string name; + std::string type; + std::string preset_name; + std::vector mixed_components; + std::vector mixed_ratios; +}; + +struct TextureNewMixedFilament { + int dialog_index{-1}; + std::string color_hex; + std::vector component_dialog_indices; + std::vector ratios; +}; + +struct FilamentMappingRow { + int cluster_id = -1; + std::array source_color = {0, 0, 0}; + std::string source_hex; + int target_filament_idx = 0; + wxPanel* source_panel = nullptr; + wxPanel* target_panel = nullptr; +}; + +class FilamentSelectPopup; +class AutoMixSelectPopup; +// Lightweight 3D preview panel using wxGLCanvas. +// Renders: original textured, multi-color, or filament-mapped. +class TexturePreviewCanvas : public wxGLCanvas +{ +public: + enum class RenderMode { Original, MultiColor, FilamentMap }; + + TexturePreviewCanvas(wxWindow* parent, const wxGLAttributes& attrs); + ~TexturePreviewCanvas(); + + void set_mesh_data( + const std::vector>& vertices, + const std::vector>& indices); + + void set_texture_data( + const std::vector>& uvs, + const unsigned char* tex_data, int tex_w, int tex_h, int tex_channels); + + void set_texture_render_data( + const std::vector>& tex_pixels_rgb, + const std::vector& tex_widths, + const std::vector& tex_heights, + const std::vector, 3>>& face_uvs, + const std::vector& face_tex_ids); + + void set_painted_mesh_data( + const std::vector>& vertices, + const std::vector>& indices); + void set_face_colors(const std::vector>& face_colors); + void set_original_face_colors(const std::vector>& face_colors); + void set_filament_color_map(const std::map, std::array>& color_map); + + void set_render_mode(RenderMode mode); + RenderMode get_render_mode() const { return m_mode; } + void set_computing_overlay(bool show); + void reset_view(); + +private: + void on_paint(wxPaintEvent& evt); + void on_size(wxSizeEvent& evt); + void on_mouse(wxMouseEvent& evt); + void ensure_gl_ready(); + void render(); + void render_mesh(); + void render_textured_original(); + void render_reset_overlay(const wxSize& logical_size, const wxSize& viewport_size); + void upload_reset_icon_textures(); + unsigned int upload_reset_icon_texture(const std::string& icon_name); + wxRect reset_overlay_rect() const; + bool handle_reset_overlay_mouse(wxMouseEvent& evt); + void upload_textures(); + void compute_smooth_normals(); + void update_bounding_box(); + + wxGLContext* m_context = nullptr; + bool m_gl_initialized = false; + RenderMode m_mode = RenderMode::Original; + + float m_zoom = 1.0f; + float m_rot_x = -30.0f; + float m_rot_y = 30.0f; + float m_pan_x = 0.0f; + float m_pan_y = 0.0f; + wxPoint m_last_mouse_pos; + enum class DragMode { None, Rotate, Pan }; + DragMode m_drag_mode = DragMode::None; + + std::vector> m_vertices; + std::vector> m_indices; + std::vector> m_uvs; + std::vector> m_painted_vertices; + std::vector> m_painted_indices; + std::vector> m_face_colors_rgb; + std::vector> m_original_face_colors_rgb; + std::vector> m_filament_colors_rgb; + std::map, std::array> m_color_map; + + unsigned int m_tex_id = 0; + int m_tex_w = 0; + int m_tex_h = 0; + int m_tex_channels = 3; + bool m_tex_dirty = false; + std::vector m_tex_data; + + std::vector m_gl_tex_ids; + std::vector> m_tex_pixels_rgb; + std::vector m_tex_widths; + std::vector m_tex_heights; + std::vector, 3>> m_face_uvs; + std::vector m_face_tex_ids; + bool m_multi_tex_dirty = false; + + std::vector> m_vertex_normals; + + std::array m_center = {0, 0, 0}; + float m_radius = 1.0f; + + unsigned int m_reset_icon_tex = 0; + unsigned int m_reset_icon_hover_tex = 0; + unsigned int m_reset_icon_dark_tex = 0; + unsigned int m_reset_icon_dark_hover_tex = 0; + bool m_reset_overlay_hovered = false; + bool m_reset_overlay_pressed = false; +}; + + +class TextureImportDialog : public DPIDialog +{ +public: + TextureImportDialog(wxWindow* parent, + const Slic3r::TexturedMesh& textured_mesh, + const std::vector& filament_entries, + std::function initial_cancel_callback = {}, + std::function initial_progress_callback = {}); + ~TextureImportDialog(); + + int ShowModal() override; + void on_dpi_changed(const wxRect& suggested_rect) override; + + Slic3r::PaintedMesh get_painted_mesh() const; + std::vector get_matches() const; + bool was_skipped() const { return m_skipped; } + bool fallback_to_geometry_only() const { return m_fallback_to_geometry_only; } + // Colors of virtual filaments that need to be created after dialog confirmation. + // Index i corresponds to filament index (m_existing_filament_count + i). + const std::vector>& get_new_filament_colors() const { return m_new_filament_colors; } + const std::vector& get_new_filament_preset_names() const { return m_new_filament_preset_names; } + const std::vector& get_new_mixed_filaments() const { return m_new_mixed_filaments; } + const std::vector& get_filament_entries() const { return m_filament_entries; } + size_t get_existing_filament_count() const { return m_existing_filament_count; } + +private: + void build_ui(); + void build_preview_panel(wxWindow* parent, wxSizer* sizer); + void build_params_panel(wxWindow* parent, wxSizer* sizer); + void build_mapping_panel(wxWindow* parent, wxSizer* sizer); + void build_bottom_buttons(wxSizer* sizer); + + void set_state(TextureImportState new_state); + void update_ui_for_state(); + + void start_computation(bool auto_color = false, bool initial = false); + void cancel_computation(); + void on_computation_complete(wxCommandEvent& evt); + void on_computation_progress(wxCommandEvent& evt); + void on_computation_error(wxCommandEvent& evt); + void on_mesh_repair_decision_required(wxCommandEvent& evt); + + void rebuild_mapping_rows(); + void do_auto_match(); + // Reorder m_current_matches into a canonical, predictable order (ascending + // filament_index, with unmapped entries pushed to the end). Used right + // after the initial computation so the first view the user sees has a + // stable, intuitive layout. + void sort_current_matches_by_filament_index(); + // Reorder m_current_matches so they appear in the same order as + // `previous_matches` (keyed by cluster_index). Entries whose cluster_index + // was not present before are appended at the end, preserving their current + // relative order. Used when the user toggles auto-merge so the rows do not + // visually jump around. Assumes each cluster_index appears at most once in + // both vectors (this invariant is currently guaranteed by do_auto_match, + // which produces one match per cluster). + void restore_current_match_order(const std::vector& previous_matches); + std::vector build_matches_from_rows() const; + void update_filament_color_map(); + void show_filament_popup(size_t row_index); + void dismiss_filament_popup(); + void dismiss_filament_popup_on_wheel(wxMouseEvent& evt); + void show_auto_mix_popup(); + void dismiss_auto_mix_popup(); + void set_auto_mix_mode(TextureAutoMixMode mode); + void apply_auto_standard_mix(TextureAutoMixMode mode); + void reset_auto_mix(); + void update_auto_mix_reset_visibility(); + bool add_decomposed_mixed_filament(size_t row_index); + int add_virtual_filament(const std::array& rgba, const std::string& hex, + const std::string& preset_name = std::string()); + int add_virtual_mixed_filament(const std::string& color_hex, + const std::vector& component_dialog_indices, + const std::vector& ratios); + size_t max_filament_count() const; + bool can_add_virtual_filament() const; + // Recomputes m_drop_warning_label visibility from m_filaments_dropped and + // m_state. Safe to call whether or not the label has been created yet. + // Visibility reflects ONLY the result of the most recent do_auto_match(): + // if the latest match did not drop any cluster, the label is hidden even + // if a previous match had dropped (no historical accumulation). + void update_drop_warning_visibility(); + void compact_used_virtual_filaments(); + int find_closest_filament_index(const std::array& color) const; + // Returns a vector indexed by dialog_index whose value is the 1-based + // display number that mirrors the final sidebar ordering produced by + // apply_textured_mesh_import_result (Plater.cpp): ExistingPhysical, + // NewPhysical, ExistingMixed, NewMixed. Used so the dialog shows the + // same IDs the sidebar will show after OK, instead of the raw + // dialog_index + 1 (which interleaves physicals and mixeds). + // MUST mirror ordering in apply_textured_mesh_import_result (Plater.cpp:9896). + std::vector compute_display_numbers() const; + + void on_color_preset_clicked(wxCommandEvent& evt); + void on_color_slider_changed(wxCommandEvent& evt); + void on_color_spin_changed(wxCommandEvent& evt); + void on_color_spin_text_changed(wxCommandEvent& evt); + void on_smooth_slider_changed(wxCommandEvent& evt); + void on_smooth_spin_changed(wxCommandEvent& evt); + void on_smooth_spin_text_changed(wxCommandEvent& evt); + void on_apply_clicked(wxCommandEvent& evt); + void on_auto_merge_toggled(wxCommandEvent& evt); + void highlight_view_button(int view_index); + void on_skip_clicked(wxCommandEvent& evt); + void on_ok_clicked(wxCommandEvent& evt); + + void set_color_count_value(int value, bool update_spin); + void set_smooth_value(int value, bool update_spin); + void preview_spin_text_value(SpinInput* spin, AccentSlider* slider, int& param, + int min_value, int max_value, const wxString& text, + std::function on_value_changed = {}); + void update_color_count_preset_buttons(); + + bool has_valid_result() const; + bool is_params_dirty() const; + void update_confirm_button_state(); + void style_confirm_button(bool dirty); + + Slic3r::TexturedMesh m_textured_mesh; + std::vector m_filament_color_strs; // existing + virtual + std::vector m_filament_names; // existing + virtual + std::vector> m_filament_colors_rgba; // existing + virtual + std::vector m_filament_entries; // aligned with m_filament_colors_rgba + size_t m_existing_filament_count = 0; + std::vector> m_new_filament_colors; // only virtual (to be created) + std::vector m_new_filament_preset_names; // only virtual, aligned with m_new_filament_colors + std::vector m_new_mixed_filaments; + std::string m_default_virtual_filament_preset_name; + + TextureImportState m_state = TextureImportState::Idle; + bool m_skipped = false; + bool m_fallback_to_geometry_only = false; + // True iff *the most recent* do_auto_match() ran into the global filament + // limit and had to drop one or more clusters. Reset to false on every + // do_auto_match() entry so it never accumulates across runs: a run that + // does not drop anything must observe false here, regardless of whether + // previous runs dropped. Drives the inline orange warning above the + // bottom buttons; never affects the mapping itself. + bool m_filaments_dropped = false; + bool m_auto_merge_enabled = true; + TextureAutoMixMode m_auto_mix_mode = TextureAutoMixMode::CMYW; + int m_auto_mix_font_point_size = 10; + + Slic3r::PaintedMesh m_painted; + std::vector m_current_matches; + + std::unique_ptr m_worker; + std::atomic m_cancel_flag{false}; + std::mutex m_result_mutex; + Slic3r::PaintedMesh m_pending_result; + std::function m_initial_cancel_callback; + std::function m_initial_progress_callback; + bool m_current_computation_initial = false; + bool m_initial_computation_pending = false; + bool m_initial_computation_cancelled = false; + bool m_initial_computation_failed = false; + bool m_initial_tooltips_set = false; + bool m_current_computation_auto_color = false; + Slic3r::TexturePaintingSettings::MeshRepairDecision m_mesh_repair_decision = + Slic3r::TexturePaintingSettings::MeshRepairDecision::Ask; + + Button* m_btn_color_4 = nullptr; + Button* m_btn_color_8 = nullptr; + Button* m_btn_color_16 = nullptr; + Button* m_btn_color_auto = nullptr; + AccentSlider* m_color_slider = nullptr; + SpinInput* m_color_spin = nullptr; + AccentSlider* m_smooth_slider = nullptr; + SpinInput* m_smooth_spin = nullptr; + Button* m_btn_apply = nullptr; + + wxCheckBox* m_auto_merge_cb = nullptr; + Button* m_btn_auto_mix = nullptr; + Button* m_btn_mix_reset = nullptr; + bool m_auto_mix_applied = false; + AutoMixSelectPopup* m_auto_mix_popup = nullptr; + wxScrolledWindow* m_mapping_scroll = nullptr; + wxBoxSizer* m_mapping_sizer = nullptr; + std::vector m_mapping_rows; + FilamentSelectPopup* m_filament_popup = nullptr; + int m_filament_popup_row = -1; + int m_skip_next_filament_popup_row = -1; + + TexturePreviewCanvas* m_preview_canvas = nullptr; + wxPanel* m_tab_panel = nullptr; + Button* m_btn_view_original = nullptr; + Button* m_btn_view_multicolor = nullptr; + + ProgressDialog* m_progress_dlg = nullptr; + + Button* m_btn_skip = nullptr; + Button* m_btn_ok = nullptr; + wxStaticText* m_drop_warning_label = nullptr; + + int m_param_color_count = 4; + int m_param_smooth = 5; + + int m_applied_color_count = -1; + int m_applied_smooth = -1; + wxStaticText* m_hint_label = nullptr; + + static const int ID_COLOR_4 = wxID_HIGHEST + 200; + static const int ID_COLOR_8 = wxID_HIGHEST + 201; + static const int ID_COLOR_16 = wxID_HIGHEST + 202; + static const int ID_COLOR_AUTO = wxID_HIGHEST + 203; + static const int ID_BTN_APPLY = wxID_HIGHEST + 204; + static const int ID_BTN_SKIP = wxID_HIGHEST + 205; + static const int ID_VIEW_ORIGINAL = wxID_HIGHEST + 206; + static const int ID_VIEW_MULTICOLOR = wxID_HIGHEST + 207; + + wxDECLARE_EVENT_TABLE(); +}; + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/UnsavedChangesDialog.hpp b/src/slic3r/GUI/UnsavedChangesDialog.hpp index b25e852c6b..fc6b8043f4 100644 --- a/src/slic3r/GUI/UnsavedChangesDialog.hpp +++ b/src/slic3r/GUI/UnsavedChangesDialog.hpp @@ -343,7 +343,7 @@ public: UnsavedChangesDialog(const wxString &caption, const wxString &header, DynamicConfig *config, int from, int to, bool left_to_right, NozzleVolumeType nozzle); ~UnsavedChangesDialog() override = default; - int ShowModal(); + int ShowModal() override; void build(Preset::Type type, PresetCollection *dependent_presets, const std::string &new_selected_preset, const wxString &header = ""); void update(Preset::Type type, PresetCollection* dependent_presets, const std::string& new_selected_preset, const wxString& header); diff --git a/src/slic3r/GUI/Widgets/AMSControl.cpp b/src/slic3r/GUI/Widgets/AMSControl.cpp index efcca12a05..41f14b6a11 100644 --- a/src/slic3r/GUI/Widgets/AMSControl.cpp +++ b/src/slic3r/GUI/Widgets/AMSControl.cpp @@ -984,7 +984,7 @@ void AMSControl::UpdateAms(const std::string &series_name, if (cans->get_ams_id() == std::to_string(VIRTUAL_TRAY_MAIN_ID) || cans->get_ams_id() == std::to_string(VIRTUAL_TRAY_DEPUTY_ID)) { for (auto ifo : m_ext_info) { if (ifo.ams_id == ams_id) { - cans->Update(ifo); + cans->UpdateInfo(ifo); cans->show_sn_value(m_ams_model == AMSModel::AMS_LITE ? false : true); } } @@ -992,7 +992,7 @@ void AMSControl::UpdateAms(const std::string &series_name, else{ for (auto ifo : m_ams_info) { if (ifo.ams_id == ams_id) { - cans->Update(ifo); + cans->UpdateInfo(ifo); cans->show_sn_value(m_ams_model == AMSModel::AMS_LITE ? false : true); } } @@ -1015,7 +1015,7 @@ void AMSControl::UpdateAms(const std::string &series_name, std::string id = ams_prv.second->get_ams_id(); auto item = m_ams_item_list.find(id); if (item != m_ams_item_list.end()) - { ams_prv.second->Update(item->second->get_ams_info()); + { ams_prv.second->UpdateInfo(item->second->get_ams_info()); } } } diff --git a/src/slic3r/GUI/Widgets/AMSItem.cpp b/src/slic3r/GUI/Widgets/AMSItem.cpp index b6f335b580..f99e5f49fd 100644 --- a/src/slic3r/GUI/Widgets/AMSItem.cpp +++ b/src/slic3r/GUI/Widgets/AMSItem.cpp @@ -325,7 +325,7 @@ AMSrefresh::AMSrefresh(wxWindow *parent, std::string ams_id, wxString can_id, Ca m_can_id = can_id.ToStdString(); create(parent, wxID_ANY, pos, size); - Update(ams_id, info); + UpdateInfo(ams_id, info); } AMSrefresh::AMSrefresh(wxWindow *parent, std::string ams_id, int can_id, Caninfo info, const wxPoint &pos, const wxSize &size) : AMSrefresh() @@ -333,7 +333,7 @@ AMSrefresh::AMSrefresh(wxWindow *parent, std::string ams_id, int can_id, Caninfo m_can_id = wxString::Format("%d", can_id).ToStdString(); create(parent, wxID_ANY, pos, size); - Update(ams_id, info); + UpdateInfo(ams_id, info); } AMSrefresh::~AMSrefresh() @@ -482,7 +482,7 @@ void AMSrefresh::paintEvent(wxPaintEvent &evt) dc.DrawText(m_refresh_id, pot); } -void AMSrefresh::Update(std::string ams_id, Caninfo info) +void AMSrefresh::UpdateInfo(std::string ams_id, Caninfo info) { if (m_ams_id == ams_id && m_info == info) { @@ -945,7 +945,7 @@ AMSLib::AMSLib(wxWindow *parent, std::string ams_idx, Caninfo info, AMSModelOrig Bind(wxEVT_LEAVE_WINDOW, &AMSLib::on_leave_window, this); Bind(wxEVT_LEFT_DOWN, &AMSLib::on_left_down, this); - Update(info, ams_idx, false); + UpdateInfo(info, ams_idx, false); } AMSLib::~AMSLib() @@ -1730,7 +1730,7 @@ void AMSLib::on_pass_road(bool pass) } } -void AMSLib::Update(Caninfo info, std::string ams_idx, bool refresh) +void AMSLib::UpdateInfo(Caninfo info, std::string ams_idx, bool refresh) { DeviceManager* dev = Slic3r::GUI::wxGetApp().getDeviceManager(); if (!dev) return; @@ -1868,7 +1868,7 @@ AMSRoad::AMSRoad(wxWindow *parent, wxWindowID id, Caninfo info, int canindex, in void AMSRoad::create(wxWindow *parent, wxWindowID id, const wxPoint &pos, const wxSize &size) { wxWindow::Create(parent, id, pos, size); } -void AMSRoad::Update(AMSinfo amsinfo, Caninfo info, int canindex, int maxcan) +void AMSRoad::UpdateInfo(AMSinfo amsinfo, Caninfo info, int canindex, int maxcan) { m_amsinfo = amsinfo; m_info = info; @@ -2083,9 +2083,6 @@ void AMSRoad::OnPassRoad(std::vector prord_list) } } -/* - - /************************************************* Description:AMSRoadUpPart **************************************************/ @@ -2124,7 +2121,7 @@ void AMSRoadUpPart::create(wxWindow* parent, wxWindowID id, const wxPoint& pos, Refresh(); } -void AMSRoadUpPart::Update(AMSinfo amsinfo) +void AMSRoadUpPart::UpdateInfo(AMSinfo amsinfo) { if (m_amsinfo != amsinfo) { @@ -2616,7 +2613,7 @@ void AMSPreview::Close() Hide(); } -void AMSPreview::Update(AMSinfo amsinfo) +void AMSPreview::UpdateInfo(AMSinfo amsinfo) { if (m_amsinfo == amsinfo) { @@ -2954,7 +2951,7 @@ AMSHumidity::AMSHumidity(wxWindow* parent, wxWindowID id, AMSinfo info, const wx } }); - Update(info); + UpdateInfo(info); } void AMSHumidity::create(wxWindow* parent, wxWindowID id, const wxPoint& pos, const wxSize& size) { @@ -2963,7 +2960,7 @@ void AMSHumidity::create(wxWindow* parent, wxWindowID id, const wxPoint& pos, co } -void AMSHumidity::Update(AMSinfo amsinfo) +void AMSHumidity::UpdateInfo(AMSinfo amsinfo) { if (m_amsinfo != amsinfo) { @@ -3380,7 +3377,7 @@ void AmsItem::AddLiteCan(Caninfo caninfo, int canindex, wxGridSizer* sizer) //m_can_road_list[caninfo.can_id] = m_panel_road; } -void AmsItem::Update(AMSinfo info) +void AmsItem::UpdateInfo(AMSinfo info) { if (m_info == info) { @@ -3392,7 +3389,7 @@ void AmsItem::Update(AMSinfo info) if (m_humidity) { - m_humidity->Update(m_info); + m_humidity->UpdateInfo(m_info); } for (int i = 0; i < m_can_count; i++) { @@ -3401,7 +3398,7 @@ void AmsItem::Update(AMSinfo info) auto refresh = it->second; if (refresh != nullptr){ - refresh->Update(info.ams_id, info.cans[i]); + refresh->UpdateInfo(info.ams_id, info.cans[i]); refresh->Show(); } } @@ -3410,7 +3407,7 @@ void AmsItem::Update(AMSinfo info) AMSLib* lib = m_can_lib_list[std::to_string(i)]; if (lib != nullptr){ if (i < m_can_count){ - lib->Update(info.cans[i], info.ams_id); + lib->UpdateInfo(info.cans[i], info.ams_id); lib->Show(); } else{ @@ -3419,12 +3416,7 @@ void AmsItem::Update(AMSinfo info) } } if (m_panel_road != nullptr){ - m_panel_road->Update(m_info); - } - - if (true || m_ams_model == AMSModel::GENERIC_AMS) { - /*m_panel_road->Update(m_info, info.cans[0]); - m_panel_road->Show();*/ + m_panel_road->UpdateInfo(m_info); } Layout(); diff --git a/src/slic3r/GUI/Widgets/AMSItem.hpp b/src/slic3r/GUI/Widgets/AMSItem.hpp index bed57e7d39..d7dc26a741 100644 --- a/src/slic3r/GUI/Widgets/AMSItem.hpp +++ b/src/slic3r/GUI/Widgets/AMSItem.hpp @@ -312,7 +312,7 @@ public: ~AMSrefresh(); public: - void Update(std::string ams_id, Caninfo info); + void UpdateInfo(std::string ams_id, Caninfo info); std::string GetCanId() const { return m_info.can_id; }; @@ -492,7 +492,7 @@ public: AMSModel m_ams_model; AMSModelOriginType m_ext_type = { AMSModelOriginType::GENERIC_EXT }; - void Update(Caninfo info, std::string ams_idx, bool refresh = true); + void UpdateInfo(Caninfo info, std::string ams_idx, bool refresh = true); void UnableSelected() { m_unable_selected = true; }; void EableSelected() { m_unable_selected = false; }; void OnSelected(); @@ -581,7 +581,7 @@ public: double m_radius = {4}; wxColour m_road_def_color; wxColour m_road_color; - void Update(AMSinfo amsinfo, Caninfo info, int canindex, int maxcan); + void UpdateInfo(AMSinfo amsinfo, Caninfo info, int canindex, int maxcan); std::vector ams_humidity_img; @@ -614,7 +614,7 @@ public: void create(wxWindow* parent, wxWindowID id = wxID_ANY, const wxPoint& pos = wxDefaultPosition, const wxSize& size = wxDefaultSize); public: - void Update(AMSinfo amsinfo); + void UpdateInfo(AMSinfo amsinfo); void OnVamsLoading(bool load, wxColour col = AMS_CONTROL_GRAY500); void SetPassRoadColour(wxColour col); @@ -715,7 +715,7 @@ public: void Open(); void Close(); - void Update(AMSinfo amsinfo); + void UpdateInfo(AMSinfo amsinfo); void create(wxWindow *parent, wxWindowID id, const wxPoint &pos, const wxSize &size); void OnEnterWindow(wxMouseEvent &evt); void OnLeaveWindow(wxMouseEvent &evt); @@ -768,7 +768,7 @@ public: int m_canindex = { 0 }; bool m_selected = { false }; double m_radius = { 12 }; - void Update(AMSinfo amsinfo); + void UpdateInfo(AMSinfo amsinfo); std::vector ams_humidity_imgs; std::vector ams_humidity_dark_imgs; @@ -801,7 +801,7 @@ public: AmsItem(wxWindow *parent, AMSinfo info, AMSModel model, AMSPanelPos pos); ~AmsItem(); - void Update(AMSinfo info); + void UpdateInfo(AMSinfo info); void create(wxWindow *parent); void AddCan(Caninfo caninfo, int canindex, int maxcan, wxBoxSizer* sizer); void AddLiteCan(Caninfo caninfo, int canindex, wxGridSizer* sizer); diff --git a/src/slic3r/GUI/Widgets/AxisCtrlButton.cpp b/src/slic3r/GUI/Widgets/AxisCtrlButton.cpp index ee0608ffbe..535abfcaf2 100644 --- a/src/slic3r/GUI/Widgets/AxisCtrlButton.cpp +++ b/src/slic3r/GUI/Widgets/AxisCtrlButton.cpp @@ -124,7 +124,7 @@ void AxisCtrlButton::SetInnerBackgroundColor(StateColor const& color) void AxisCtrlButton::SetBitmap(ScalableBitmap &bmp) { - if (&bmp && (& bmp.bmp()) && (bmp.bmp().IsOk())) { + if (bmp.bmp().IsOk()) { m_icon = bmp; } } diff --git a/src/slic3r/GUI/Widgets/ComboBox.cpp b/src/slic3r/GUI/Widgets/ComboBox.cpp index 783e1caadf..08687f3cf6 100644 --- a/src/slic3r/GUI/Widgets/ComboBox.cpp +++ b/src/slic3r/GUI/Widgets/ComboBox.cpp @@ -87,10 +87,18 @@ void ComboBox::SetSelection(int n) return; drop.SetSelection(n); SetLabel(drop.GetValue()); - if (drop.selection >= 0 && drop.iconSize.y > 0 && items[drop.selection].icon_textctrl.IsOk()) - SetIcon(items[drop.selection].icon_textctrl); - else + if (drop.selection >= 0 && drop.iconSize.y > 0 && items[drop.selection].icon_textctrl.IsOk()) { + if (m_keep_drop_arrow) { + SetIcon("drop_down"); + SetIcon_1(items[drop.selection].icon_textctrl); + } else { + SetIcon(items[drop.selection].icon_textctrl); + } + } else { SetIcon("drop_down"); + if (m_keep_drop_arrow) + SetIcon_1(wxNullBitmap); + } if (drop.selection >= 0) { SetStaticTips(items[drop.selection].text_static_tips, wxNullBitmap); @@ -120,10 +128,18 @@ void ComboBox::SetValue(const wxString &value) { drop.SetValue(value); SetLabel(value); - if (drop.selection >= 0 && drop.iconSize.y > 0 && items[drop.selection].icon_textctrl.IsOk()) - SetIcon(items[drop.selection].icon_textctrl); - else + if (drop.selection >= 0 && drop.iconSize.y > 0 && items[drop.selection].icon_textctrl.IsOk()) { + if (m_keep_drop_arrow) { + SetIcon("drop_down"); + SetIcon_1(items[drop.selection].icon_textctrl); + } else { + SetIcon(items[drop.selection].icon_textctrl); + } + } else { SetIcon("drop_down"); + if (m_keep_drop_arrow) + SetIcon_1(wxNullBitmap); + } if (drop.selection >= 0) { SetStaticTips(items[drop.selection].text_static_tips, wxNullBitmap); @@ -192,7 +208,7 @@ bool ComboBox::SetFont(wxFont const& font) int ComboBox::Append(const wxString &item, const wxBitmap &bitmap, int style) { - if (&bitmap && bitmap.IsOk()) { + if (bitmap.IsOk()) { return Append(item, bitmap, nullptr, style); } return Append(item, wxNullBitmap, nullptr, style); @@ -203,7 +219,7 @@ int ComboBox::Append(const wxString &text, void * clientData, int style) { - if (&bitmap && bitmap.IsOk()) { + if (bitmap.IsOk()) { return Append(text, bitmap, wxString{}, clientData, style); } return Append(text, wxNullBitmap, wxString{}, clientData, style); @@ -221,7 +237,7 @@ int ComboBox::Append(const wxString &text, void *clientData, int style) { - auto valid_bit_map = (&bitmap && bitmap.IsOk()) ? bitmap : wxNullBitmap; + auto valid_bit_map = bitmap.IsOk() ? bitmap : wxNullBitmap; Item item{text, wxEmptyString, valid_bit_map, valid_bit_map, clientData, group_key, group_label}; item.style = style; items.push_back(item); @@ -317,7 +333,7 @@ wxBitmap ComboBox::GetItemBitmap(unsigned int n) { return items[n].icon; } void ComboBox::SetItemBitmap(unsigned int n, wxBitmap const &bitmap) { if (n >= items.size()) return; - items[n].icon = (&bitmap && bitmap.IsOk()) ? bitmap : wxNullBitmap; + items[n].icon = bitmap.IsOk() ? bitmap : wxNullBitmap; drop.Invalidate(); } diff --git a/src/slic3r/GUI/Widgets/ComboBox.hpp b/src/slic3r/GUI/Widgets/ComboBox.hpp index 552909b477..91c34d53aa 100644 --- a/src/slic3r/GUI/Widgets/ComboBox.hpp +++ b/src/slic3r/GUI/Widgets/ComboBox.hpp @@ -16,6 +16,7 @@ class ComboBox : public wxWindowWithItems bool drop_down = false; bool text_off = false; bool is_replace_text_to_image = false; + bool m_keep_drop_arrow = false; // When true, item icon goes to icon_1, keeping drop_down arrow wxString replace_text; wxString image_for_text; @@ -31,6 +32,11 @@ public: DropDown & GetDropDown() { return drop; } + // When true, item icon is shown as icon_1 (secondary), preserving drop_down arrow. + // Note: item bitmaps are set via raw wxBitmap (not ScalableBitmap), so they won't + // auto-rescale on DPI change. Caller should recreate items after DPI change. + void SetKeepDropArrow(bool keep) { m_keep_drop_arrow = keep; } + virtual bool SetFont(wxFont const & font) override; public: diff --git a/src/slic3r/GUI/Widgets/DropDown.cpp b/src/slic3r/GUI/Widgets/DropDown.cpp index 973113d0ac..cd4d5edff8 100644 --- a/src/slic3r/GUI/Widgets/DropDown.cpp +++ b/src/slic3r/GUI/Widgets/DropDown.cpp @@ -427,7 +427,10 @@ void DropDown::render(wxDC &dc) } pt.y += (rcContent.height - textSize.y) / 2; dc.SetFont(GetFont()); - dc.SetTextForeground(text_color.colorForStates(states2)); + // Dimmed items stay selectable, so they only borrow the disabled text tone rather + // than taking the disabled state itself. + const int text_states = (item.style & DD_ITEM_STYLE_DIMMED) ? (states2 & ~StateColor::Enabled) : states2; + dc.SetTextForeground(text_color.colorForStates(text_states)); dc.DrawText(text, pt); if (group.IsEmpty() && !item.group_key.IsEmpty()) { auto szBmp = arrow_bitmap.GetBmpSize(); diff --git a/src/slic3r/GUI/Widgets/DropDown.hpp b/src/slic3r/GUI/Widgets/DropDown.hpp index 09041e3dc0..bcd0a58c41 100644 --- a/src/slic3r/GUI/Widgets/DropDown.hpp +++ b/src/slic3r/GUI/Widgets/DropDown.hpp @@ -13,6 +13,7 @@ #define DD_ITEM_STYLE_SPLIT_ITEM 0x0001 // ----text----, text with horizontal line arounds #define DD_ITEM_STYLE_DISABLED 0x0002 // ----text----, text with horizontal line arounds +#define DD_ITEM_STYLE_DIMMED 0x0004 // gray text, but still selectable wxDECLARE_EVENT(EVT_DISMISS, wxCommandEvent); diff --git a/src/slic3r/GUI/Widgets/LabeledStaticBox.cpp b/src/slic3r/GUI/Widgets/LabeledStaticBox.cpp index c8e054593f..a11839a8b8 100644 --- a/src/slic3r/GUI/Widgets/LabeledStaticBox.cpp +++ b/src/slic3r/GUI/Widgets/LabeledStaticBox.cpp @@ -98,7 +98,7 @@ void LabeledStaticBox::SetBorderColor(StateColor const &color) Refresh(); } -void LabeledStaticBox::SetFont(wxFont set_font) +bool LabeledStaticBox::SetFont(const wxFont &set_font) { m_font = set_font; @@ -109,6 +109,7 @@ void LabeledStaticBox::SetFont(wxFont set_font) m_label_width = tW; Refresh(); + return true; } bool LabeledStaticBox::Enable(bool enable) diff --git a/src/slic3r/GUI/Widgets/LabeledStaticBox.hpp b/src/slic3r/GUI/Widgets/LabeledStaticBox.hpp index f42175ae05..d3e7f2efce 100644 --- a/src/slic3r/GUI/Widgets/LabeledStaticBox.hpp +++ b/src/slic3r/GUI/Widgets/LabeledStaticBox.hpp @@ -42,7 +42,7 @@ public: void SetBorderColor(StateColor const &color); - void SetFont(wxFont set_font); + bool SetFont(const wxFont &set_font) override; bool Enable(bool enable) override; diff --git a/src/slic3r/GUI/Widgets/MultiNozzleSync.cpp b/src/slic3r/GUI/Widgets/MultiNozzleSync.cpp index 05857c6d0d..518bda1d19 100644 --- a/src/slic3r/GUI/Widgets/MultiNozzleSync.cpp +++ b/src/slic3r/GUI/Widgets/MultiNozzleSync.cpp @@ -630,7 +630,7 @@ NozzleListTable::NozzleListTable(wxWindow* parent) : wxPanel(parent,wxID_ANY,wxD SetSizer(sizer); Layout(); - m_web_view->Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, [this,sizer](wxWebViewEvent& evt) { + m_web_view->Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, [this](wxWebViewEvent& evt) { std::string message = evt.GetString().ToStdString(); BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << "Received message: " << message; try { @@ -1168,8 +1168,8 @@ void MultiNozzleSyncDialog::UpdateButton(std::weak_ptr rack, bool m_cancel_btn->SetLabel(_L("Ignore")); m_confirm_btn->SetLabel(_L("Refresh")); - m_cancel_btn->Bind(wxEVT_LEFT_DOWN, [this, rack, ignore_opt](auto& e) {ignore_opt(); }); - m_confirm_btn->Bind(wxEVT_LEFT_DOWN, [this, rack, refresh_cmd](auto& e) {refresh_cmd(); }); + m_cancel_btn->Bind(wxEVT_LEFT_DOWN, [rack, ignore_opt](auto& e) {ignore_opt(); }); + m_confirm_btn->Bind(wxEVT_LEFT_DOWN, [rack, refresh_cmd](auto& e) {refresh_cmd(); }); } else if (has_unknown) { m_cancel_btn->Show(); @@ -1178,8 +1178,8 @@ void MultiNozzleSyncDialog::UpdateButton(std::weak_ptr rack, bool m_cancel_btn->SetLabel(_L("Ignore")); m_confirm_btn->SetLabel(_L("Refresh")); - m_cancel_btn->Bind(wxEVT_LEFT_DOWN, [this, rack, ignore_opt](auto& e) {ignore_opt(); }); - m_confirm_btn->Bind(wxEVT_LEFT_DOWN, [this, rack, refresh_cmd](auto& e) {refresh_cmd(); }); + m_cancel_btn->Bind(wxEVT_LEFT_DOWN, [rack, ignore_opt](auto& e) {ignore_opt(); }); + m_confirm_btn->Bind(wxEVT_LEFT_DOWN, [rack, refresh_cmd](auto& e) {refresh_cmd(); }); } else if (has_unreliable) { m_cancel_btn->Show(); @@ -1188,8 +1188,8 @@ void MultiNozzleSyncDialog::UpdateButton(std::weak_ptr rack, bool m_cancel_btn->SetLabel(_L("Refresh")); m_confirm_btn->SetLabel(_L("Confirm")); - m_cancel_btn->Bind(wxEVT_LEFT_DOWN, [this, rack, refresh_cmd](auto& e) {refresh_cmd(); }); - m_confirm_btn->Bind(wxEVT_LEFT_DOWN, [this, rack, trust_cmd](auto& e) {trust_cmd(); }); + m_cancel_btn->Bind(wxEVT_LEFT_DOWN, [rack, refresh_cmd](auto& e) {refresh_cmd(); }); + m_confirm_btn->Bind(wxEVT_LEFT_DOWN, [rack, trust_cmd](auto& e) {trust_cmd(); }); } else { diff --git a/src/slic3r/GUI/Widgets/MultiNozzleSync.hpp b/src/slic3r/GUI/Widgets/MultiNozzleSync.hpp index ab56663928..3af524c2fc 100644 --- a/src/slic3r/GUI/Widgets/MultiNozzleSync.hpp +++ b/src/slic3r/GUI/Widgets/MultiNozzleSync.hpp @@ -167,7 +167,7 @@ class MultiNozzleSyncDialog : public DPIDialog { public: MultiNozzleSyncDialog(wxWindow* parent, std::weak_ptr rack); - virtual void on_dpi_changed(const wxRect& suggested_rect) {}; + virtual void on_dpi_changed(const wxRect& suggested_rect) override {}; std::vector GetNozzleOptions(const std::vector& group_infos); std::optional GetSelectedOption() { diff --git a/src/slic3r/GUI/Widgets/ProgressBar.hpp b/src/slic3r/GUI/Widgets/ProgressBar.hpp index 38dda6c8d2..40ddb8e4be 100644 --- a/src/slic3r/GUI/Widgets/ProgressBar.hpp +++ b/src/slic3r/GUI/Widgets/ProgressBar.hpp @@ -56,7 +56,7 @@ protected: void paintEvent(wxPaintEvent &evt); void render(wxDC &dc); void doRender(wxDC &dc); - virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO); + virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO) override; diff --git a/src/slic3r/GUI/Widgets/ProgressDialog.hpp b/src/slic3r/GUI/Widgets/ProgressDialog.hpp index 597ec7f802..bb770298a9 100644 --- a/src/slic3r/GUI/Widgets/ProgressDialog.hpp +++ b/src/slic3r/GUI/Widgets/ProgressDialog.hpp @@ -33,7 +33,7 @@ public: void OnPaint(wxPaintEvent &evt); virtual ~ProgressDialog(); - virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO); + virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO) override; bool Create(const wxString &title, const wxString &message, int maximum = 100, wxWindow *parent = NULL, int style = wxPD_APP_MODAL | wxPD_AUTO_HIDE); virtual bool Update(int value, const wxString &newmsg = wxEmptyString, bool *skip = NULL); diff --git a/src/slic3r/GUI/Widgets/ScrolledWindow.cpp b/src/slic3r/GUI/Widgets/ScrolledWindow.cpp index d570b9f764..90922f93f1 100644 --- a/src/slic3r/GUI/Widgets/ScrolledWindow.cpp +++ b/src/slic3r/GUI/Widgets/ScrolledWindow.cpp @@ -21,6 +21,8 @@ ScrolledWindow::ScrolledWindow(wxWindow *parent, wxWindowID id, wxPoint position m_bottomScrollbar = NULL; m_verticalSplitter = NULL; m_horizontalSplitter = NULL; + m_userPanel = NULL; + m_scroll_win = NULL; m_marginWidth = marginWidth; @@ -110,23 +112,13 @@ void ScrolledWindow::SetTipColor(wxColour color) if (m_bottomScrollbar) m_bottomScrollbar->SetTipColor(color); } -void ScrolledWindow::Refresh() +bool ScrolledWindow::SetBackgroundColour(const wxColour &color) { - // m_rightScrollbar->SetViewStart(0); - // m_rightScrollbar->Refresh(); - // m_rightScrollbar->Update(); - // m_userPanel->Refresh(); - // m_bottomScrollbar->SetViewStart(0); - // m_rightScrollbar->Refresh(); - // m_bottomScrollbar->Refresh(); -} - -void ScrolledWindow::SetBackgroundColour(wxColour color) -{ - wxWindow::SetBackgroundColour(color); + const bool result = wxWindow::SetBackgroundColour(color); m_verticalSplitter->SetBackgroundColour(color); m_userPanel->SetBackgroundColour(color); m_scroll_win->SetBackgroundColour(color); + return result; } void ScrolledWindow::SetMarginColor(wxColour color) diff --git a/src/slic3r/GUI/Widgets/ScrolledWindow.hpp b/src/slic3r/GUI/Widgets/ScrolledWindow.hpp index 56d54aade3..5c2bc2f9e5 100644 --- a/src/slic3r/GUI/Widgets/ScrolledWindow.hpp +++ b/src/slic3r/GUI/Widgets/ScrolledWindow.hpp @@ -15,8 +15,7 @@ public: ScrolledWindow(wxWindow *parent, wxWindowID id, wxPoint position, wxSize size, long style, int marginWidth = 0, int scrollbarWidth = 4, int tipLength = 0); void OnMouseWheel(wxMouseEvent &event); void SetTipColor(wxColour color); - void Refresh(); - void SetBackgroundColour(wxColour color); + bool SetBackgroundColour(const wxColour &color) override; void SetMarginColor(wxColour color); void SetScrollbarColor(wxColour color); @@ -27,7 +26,7 @@ public: // wxSplitterWindow* GetVerticalSplitter() { return m_verticalSplitter; } // wxSplitterWindow* GetHorizontalSplitter() { return m_horizontalSplitter; } bool IsBothDirections() { return m_bothDirections; } - virtual void SetScrollbars(int pixelsPerUnitX, int pixelsPerUnitY, int noUnitsX, int noUnitsY, int xPos = 0, int yPos = 0, bool noRefresh = false); + virtual void SetScrollbars(int pixelsPerUnitX, int pixelsPerUnitY, int noUnitsX, int noUnitsY, int xPos = 0, int yPos = 0, bool noRefresh = false) override; private: wxPanel * m_userPanel; // the panel targeted by the scrolled window diff --git a/src/slic3r/GUI/Widgets/SideButton.hpp b/src/slic3r/GUI/Widgets/SideButton.hpp index 4f8d893f93..894a7d8727 100644 --- a/src/slic3r/GUI/Widgets/SideButton.hpp +++ b/src/slic3r/GUI/Widgets/SideButton.hpp @@ -31,7 +31,7 @@ public: void SetLayoutStyle(int style); - void SetLabel(const wxString& label); + void SetLabel(const wxString& label) override; bool SetForegroundColour(wxColour const & colour) override; @@ -47,7 +47,7 @@ public: void SetBackgroundColor(StateColor const &color); - bool Enable(bool enable = true); + bool Enable(bool enable = true) override; void Rescale(); diff --git a/src/slic3r/GUI/Widgets/SpinInput.cpp b/src/slic3r/GUI/Widgets/SpinInput.cpp index fba5a45233..c91fd41543 100644 --- a/src/slic3r/GUI/Widgets/SpinInput.cpp +++ b/src/slic3r/GUI/Widgets/SpinInput.cpp @@ -9,6 +9,8 @@ #include "../GUI_Utils.hpp" #endif +wxDEFINE_EVENT(EVT_SPINCTRL_TEXT, wxCommandEvent); + BEGIN_EVENT_TABLE(SpinInput, StaticBox) EVT_KEY_DOWN(SpinInput::keyPressed) @@ -74,8 +76,9 @@ void SpinInput::Create(wxWindow *parent, state_handler.attach_child(text_ctrl); text_ctrl->Bind(wxEVT_KILL_FOCUS, &SpinInput::onTextLostFocus, this); text_ctrl->Bind(wxEVT_TEXT_ENTER, &SpinInput::onTextEnter, this); + text_ctrl->Bind(wxEVT_TEXT, &SpinInput::onTextChanged, this); text_ctrl->Bind(wxEVT_KEY_DOWN, &SpinInput::keyPressed, this); - text_ctrl->Bind(wxEVT_RIGHT_DOWN, [this](auto &e) {}); // disable context menu + text_ctrl->Bind(wxEVT_RIGHT_DOWN, [](auto &e) {}); // disable context menu button_inc = createButton(true); button_dec = createButton(false); delta = 0; @@ -300,6 +303,19 @@ void SpinInput::onTextEnter(wxCommandEvent &event) ProcessEventLocally(event); } +void SpinInput::onTextChanged(wxCommandEvent &event) +{ + long value; + if (text_ctrl->GetValue().ToLong(&value)) { + wxCommandEvent e(EVT_SPINCTRL_TEXT, GetId()); + e.SetEventObject(this); + e.SetInt((int) value); + e.SetString(text_ctrl->GetValue()); + GetEventHandler()->ProcessEvent(e); + } + event.Skip(); +} + void SpinInput::mouseWheelMoved(wxMouseEvent &event) { auto delta = event.GetWheelRotation() < 0 ? 1 : -1; diff --git a/src/slic3r/GUI/Widgets/SpinInput.hpp b/src/slic3r/GUI/Widgets/SpinInput.hpp index 275d42a95d..caf2bf3843 100644 --- a/src/slic3r/GUI/Widgets/SpinInput.hpp +++ b/src/slic3r/GUI/Widgets/SpinInput.hpp @@ -9,6 +9,10 @@ class Button; +// Fired on every keystroke that leaves a parseable integer in the field, so callers can +// react live rather than only on commit (wxEVT_SPINCTRL) or Enter. Ported from BambuStudio. +wxDECLARE_EVENT(EVT_SPINCTRL_TEXT, wxCommandEvent); + class SpinInput : public wxNavigationEnabled { wxSize labelSize; @@ -98,6 +102,7 @@ private: void keyPressed(wxKeyEvent& event); void onTimer(wxTimerEvent &evnet); void onTextLostFocus(wxEvent &event); + void onTextChanged(wxCommandEvent &event); void onTextEnter(wxCommandEvent &event); void sendSpinEvent(); diff --git a/src/slic3r/GUI/Widgets/TabCtrl.cpp b/src/slic3r/GUI/Widgets/TabCtrl.cpp index 40e291d5ad..882d2f6897 100644 --- a/src/slic3r/GUI/Widgets/TabCtrl.cpp +++ b/src/slic3r/GUI/Widgets/TabCtrl.cpp @@ -111,7 +111,7 @@ int TabCtrl::AppendItem(const wxString &item, btns.push_back(btn); if (btns.size() > 1) sizer->GetItem(sizer->GetItemCount() - 1)->SetMinSize({0, 0}); - sizer->Add(btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, TAB_BUTTON_SPACE * 2); + sizer->Add(btn, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, TAB_BUTTON_SPACE); sizer->AddStretchSpacer(1); relayout(); return btns.size() - 1; @@ -225,8 +225,9 @@ bool TabCtrl::IsVisible(unsigned int item) const void TabCtrl::DoSetSize(int x, int y, int width, int height, int sizeFlags) { + auto size = GetSize(); wxWindow::DoSetSize(x, y, width, height, sizeFlags); - if (sizeFlags & wxSIZE_USE_EXISTING) return; + if (size == GetSize()) return; relayout(); } diff --git a/src/slic3r/GUI/Widgets/TabCtrl.hpp b/src/slic3r/GUI/Widgets/TabCtrl.hpp index a25f332fb3..04b5b8e24e 100644 --- a/src/slic3r/GUI/Widgets/TabCtrl.hpp +++ b/src/slic3r/GUI/Widgets/TabCtrl.hpp @@ -63,7 +63,7 @@ public: bool IsVisible(unsigned int item) const; private: - virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO); + virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO) override; #ifdef __WIN32__ WXLRESULT MSWWindowProc(WXUINT nMsg, WXWPARAM wParam, WXLPARAM lParam) override; diff --git a/src/slic3r/GUI/Widgets/TempInput.cpp b/src/slic3r/GUI/Widgets/TempInput.cpp index 6a9809252a..6705378dac 100644 --- a/src/slic3r/GUI/Widgets/TempInput.cpp +++ b/src/slic3r/GUI/Widgets/TempInput.cpp @@ -134,7 +134,7 @@ void TempInput::Create(wxWindow *parent, wxString text, wxString label, wxString } } }); - text_ctrl->Bind(wxEVT_RIGHT_DOWN, [this](auto &e) {}); // disable context menu + text_ctrl->Bind(wxEVT_RIGHT_DOWN, [](auto &e) {}); // disable context menu text_ctrl->Bind(wxEVT_LEFT_DOWN, [this](auto &e) { if (m_read_only) { return; diff --git a/src/slic3r/GUI/Widgets/TempInput.hpp b/src/slic3r/GUI/Widgets/TempInput.hpp index c306ba59cc..f281a1ea6e 100644 --- a/src/slic3r/GUI/Widgets/TempInput.hpp +++ b/src/slic3r/GUI/Widgets/TempInput.hpp @@ -107,7 +107,7 @@ public: wxString GetTagTemp() { return text_ctrl->GetValue(); } wxString GetCurrTemp() { return GetLabel(); } int get_max_temp() { return max_temp; } - void SetLabel(const wxString &label); + void SetLabel(const wxString &label) override; void SetTextColor(StateColor const &color); @@ -128,7 +128,7 @@ public: void ReSetOnChanging() { m_on_changing = false; } protected: - virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO); + virtual void DoSetSize(int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO) override; void DoSetToolTipText(wxString const &tip) override; diff --git a/src/slic3r/GUI/Widgets/TextInput.cpp b/src/slic3r/GUI/Widgets/TextInput.cpp index 49605e048d..23f55d155c 100644 --- a/src/slic3r/GUI/Widgets/TextInput.cpp +++ b/src/slic3r/GUI/Widgets/TextInput.cpp @@ -85,7 +85,7 @@ void TextInput::Create(wxWindow * parent, e.SetId(GetId()); ProcessEventLocally(e); }); - text_ctrl->Bind(wxEVT_RIGHT_DOWN, [this](auto &e) {}); // disable context menu + text_ctrl->Bind(wxEVT_RIGHT_DOWN, [](auto &e) {}); // disable context menu if (!icon.IsEmpty()) { this->icon = ScalableBitmap(this, icon.ToStdString(), 16); } @@ -139,6 +139,15 @@ void TextInput::SetIcon_1(const wxString &icon) { Rescale(); } +// Set icon_1 from a raw bitmap. Note: won't auto-rescale on DPI change +// since ScalableBitmap::name() will be empty. Caller should re-set after DPI change. +void TextInput::SetIcon_1(const wxBitmap &icon) { + this->icon_1 = ScalableBitmap(); + if (icon.IsOk()) + this->icon_1.bmp() = icon; + Rescale(); +} + void TextInput::SetLabelColor(StateColor const &color) { label_color = color; diff --git a/src/slic3r/GUI/Widgets/TextInput.hpp b/src/slic3r/GUI/Widgets/TextInput.hpp index 9aca7037c4..bbd38b0c00 100644 --- a/src/slic3r/GUI/Widgets/TextInput.hpp +++ b/src/slic3r/GUI/Widgets/TextInput.hpp @@ -46,7 +46,7 @@ public: // Only meant to be used by inspector, not public API int GetCornerRadius() const { return static_cast(radius); } - void SetLabel(const wxString& label); + void SetLabel(const wxString& label) override; void SetStaticTips(const wxString& tips, const wxBitmap& bitmap); @@ -54,6 +54,7 @@ public: void SetIcon(const wxString & icon); void SetIcon_1(const wxString &icon); + void SetIcon_1(const wxBitmap &icon); void SetLabelColor(StateColor const &color); @@ -73,7 +74,7 @@ protected: virtual void OnEdit() {} virtual void DoSetSize( - int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO); + int x, int y, int width, int height, int sizeFlags = wxSIZE_AUTO) override; void DoSetToolTipText(wxString const &tip) override; diff --git a/src/slic3r/GUI/Widgets/WebView.cpp b/src/slic3r/GUI/Widgets/WebView.cpp index 36800dcf47..e281d97407 100644 --- a/src/slic3r/GUI/Widgets/WebView.cpp +++ b/src/slic3r/GUI/Widgets/WebView.cpp @@ -104,7 +104,7 @@ DWORD DownloadAndInstallWV2RT() { class WebViewEdge : public wxWebViewEdge { public: - bool SetUserAgent(const wxString &userAgent) + bool SetUserAgent(const wxString &userAgent) override { bool dark = userAgent.Contains("dark"); SetColorScheme(dark ? COREWEBVIEW2_PREFERRED_COLOR_SCHEME_DARK : COREWEBVIEW2_PREFERRED_COLOR_SCHEME_LIGHT); diff --git a/src/slic3r/GUI/WipeTowerDialog.cpp b/src/slic3r/GUI/WipeTowerDialog.cpp index abf8baf086..d4fbcc6fe3 100644 --- a/src/slic3r/GUI/WipeTowerDialog.cpp +++ b/src/slic3r/GUI/WipeTowerDialog.cpp @@ -204,6 +204,10 @@ bool is_flush_config_modified() const auto &project_config = wxGetApp().preset_bundle->project_config; const std::vector &config_matrix = (project_config.option("flush_volumes_matrix"))->values; const std::vector &config_multiplier = (project_config.option("flush_multiplier"))->values; + // The config matrix is N x N per nozzle over every slot, while CalcFlushingVolumes is p x p + // over the physical slots (mixed slots never flush): map each default cell to its config index. + const auto physical_indices = wxGetApp().preset_bundle->physical_filament_config_indices(); + const size_t full_n = project_config.option("filament_colour")->values.size(); bool has_modify = false; for (int i = 0; i < config_multiplier.size(); i++) { @@ -212,11 +216,12 @@ bool is_flush_config_modified() break; } std::vector> default_matrix = WipingDialog::CalcFlushingVolumes(i); - int len = default_matrix.size(); - for (int m = 0; m < len; m++) { - for (int n = 0; n < len; n++) { - int idx = i * len * len + m * len + n; - if (config_matrix[idx] != default_matrix[m][n] * config_multiplier[i]) { + size_t p_len = default_matrix.size(); + size_t nozzle_offset = i * full_n * full_n; + for (size_t m = 0; m < p_len; m++) { + for (size_t n = 0; n < p_len; n++) { + size_t cfg_idx = nozzle_offset + physical_indices[m] * full_n + physical_indices[n]; + if (cfg_idx < config_matrix.size() && config_matrix[cfg_idx] != default_matrix[m][n] * config_multiplier[i]) { has_modify = true; break; } @@ -256,6 +261,40 @@ static std::vector MatrixFlatten(const WipingDialog::VolumeMatrix& matrix return vec; } +// Mixed-color slots are virtual and have no flushing volumes, so the dialog shows only the +// physical filaments. That means converting between the full config matrix (indexed by config +// slot) and a dense physical sub-matrix (indexed by row/column in the table). +static std::vector extract_physical_sub_matrix( + const std::vector& full_matrix, size_t full_n, + const std::vector& indices) +{ + size_t p = indices.size(); + std::vector sub(p * p, 0.0); + if (full_matrix.size() < full_n * full_n) + return sub; + for (size_t pi = 0; pi < p; ++pi) + for (size_t pj = 0; pj < p; ++pj) + sub[pi * p + pj] = full_matrix[indices[pi] * full_n + indices[pj]]; + return sub; +} + +// Write the edited physical sub-matrix back into a copy of the full matrix, leaving the +// entries that belong to mixed slots untouched. +static std::vector expand_physical_to_full_matrix( + const std::vector& sub_matrix, + const std::vector& indices, size_t full_n, + const std::vector& original_matrix) +{ + std::vector full = original_matrix; + if (full.size() < full_n * full_n) + return full; + size_t p = indices.size(); + for (size_t pi = 0; pi < p; ++pi) + for (size_t pj = 0; pj < p; ++pj) + full[indices[pi] * full_n + indices[pj]] = sub_matrix[pi * p + pj]; + return full; +} + wxString WipingDialog::BuildTableObjStr() { auto full_config = wxGetApp().preset_bundle->full_config(); @@ -265,9 +304,22 @@ wxString WipingDialog::BuildTableObjStr() auto raw_matrix_data = full_config.option("flush_volumes_matrix")->values; auto nozzle_flush_dataset = full_config.option("nozzle_flush_dataset")->values; + // Restrict the table to physical filaments; mixed slots have no flushing volumes. + m_physical_indices = wxGetApp().preset_bundle->physical_filament_config_indices(); + const size_t full_n = filament_colors.size(); + { + std::vector physical_colors; + physical_colors.reserve(m_physical_indices.size()); + for (size_t i : m_physical_indices) + if (i < filament_colors.size()) + physical_colors.push_back(filament_colors[i]); + filament_colors = std::move(physical_colors); + } + std::vector> flush_matrixs; for (int idx = 0; idx < nozzle_num; ++idx) { - flush_matrixs.emplace_back(get_flush_volumes_matrix(raw_matrix_data, idx, nozzle_num)); + auto fm = get_flush_volumes_matrix(raw_matrix_data, idx, nozzle_num); + flush_matrixs.emplace_back(extract_physical_sub_matrix(fm, full_n, m_physical_indices)); } flush_multiplier.resize(nozzle_num, 1); @@ -372,7 +424,7 @@ WipingDialog::WipingDialog(wxWindow* parent, const int max_flush_volume) : wxBoxSizer* main_sizer = new wxBoxSizer(wxVERTICAL); this->SetSizer(main_sizer); this->SetBackgroundColour(*wxWHITE); - auto filament_count = wxGetApp().preset_bundle->project_config.option("filament_colour")->values.size(); + auto filament_count = wxGetApp().preset_bundle->physical_filament_config_indices().size(); // Estimate table scroll area size based on filament count // Each table cell is ~60x25 DIP, plus headers and borders @@ -523,55 +575,51 @@ WipingDialog::VolumeMatrix WipingDialog::CalcFlushingVolumes(int extruder_id) auto& preset_bundle = wxGetApp().preset_bundle; auto full_config = preset_bundle->full_config(); auto& ams_multi_color_filament = preset_bundle->ams_multi_color_filment; + // Mixed-colour slots are virtual and never flushed: compute a p x p matrix over the physical + // slots only, laid out like the table; row/column k belongs to config slot physical_indices[k]. + auto physical_indices = preset_bundle->physical_filament_config_indices(); - std::vector filament_color_strs = full_config.option("filament_colour")->values; - std::vector> multi_colors; - std::vector filament_colors; - for (auto color_str : filament_color_strs) - filament_colors.emplace_back(color_str); - + std::vector all_color_strs = full_config.option("filament_colour")->values; int flush_dataset_value = full_config.option("nozzle_flush_dataset")->values[extruder_id]; + const std::vector min_flush_volumes = get_min_flush_volumes(full_config, extruder_id); + // Support for multi-color filament - for (int i = 0; i < filament_colors.size(); ++i) { + std::vector> multi_colors; + for (size_t cfg_idx : physical_indices) { std::vector single_filament; - if (i < ams_multi_color_filament.size()) { - if (!ams_multi_color_filament[i].empty()) { - std::vector colors = ams_multi_color_filament[i]; - for (int j = 0; j < colors.size(); ++j) { - single_filament.push_back(wxColour(colors[j])); - } - multi_colors.push_back(single_filament); - continue; - } + if (cfg_idx < ams_multi_color_filament.size() && !ams_multi_color_filament[cfg_idx].empty()) { + for (const auto& c : ams_multi_color_filament[cfg_idx]) + single_filament.push_back(wxColour(c)); + } else if (cfg_idx < all_color_strs.size()) { + single_filament.push_back(wxColour(all_color_strs[cfg_idx])); } - single_filament.push_back(wxColour(filament_colors[i])); multi_colors.push_back(single_filament); } VolumeMatrix matrix; - const std::vector min_flush_volumes = get_min_flush_volumes(full_config, extruder_id); - - for (int from_idx = 0; from_idx < multi_colors.size(); ++from_idx) { - bool is_from_support = is_support_filament(from_idx); + for (size_t pi = 0; pi < physical_indices.size(); ++pi) { + int from_cfg = (int)physical_indices[pi]; + bool is_from_support = is_support_filament(from_cfg); matrix.emplace_back(); - for (int to_idx = 0; to_idx < multi_colors.size(); ++to_idx) { - if (from_idx == to_idx) { + for (size_t pj = 0; pj < physical_indices.size(); ++pj) { + int to_cfg = (int)physical_indices[pj]; + if (from_cfg == to_cfg) { matrix.back().emplace_back(0); continue; } - bool is_to_support = is_support_filament(to_idx); - + bool is_to_support = is_support_filament(to_cfg); int flushing_volume = 0; if (is_to_support) { flushing_volume = Slic3r::g_flush_volume_to_support; } else { - for (int i = 0; i < multi_colors[from_idx].size(); ++i) { - const wxColour& from = multi_colors[from_idx][i]; - for (int j = 0; j < multi_colors[to_idx].size(); ++j) { - const wxColour& to = multi_colors[to_idx][j]; - int volume = CalcFlushingVolume(from, to, min_flush_volumes[from_idx], flush_dataset_value); + int min_flush_from = (from_cfg < (int)min_flush_volumes.size()) ? min_flush_volumes[from_cfg] : 0; + for (size_t i = 0; i < multi_colors[pi].size(); ++i) { + const wxColour& from = multi_colors[pi][i]; + for (size_t j = 0; j < multi_colors[pj].size(); ++j) { + const wxColour& to = multi_colors[pj][j]; + int volume = CalcFlushingVolume(from, to, min_flush_from, flush_dataset_value); flushing_volume = std::max(flushing_volume, volume); } } @@ -592,11 +640,29 @@ void WipingDialog::StoreFlushData(int extruder_num, const std::vector WipingDialog::ExpandToFullMatrix(const std::vector& sub_matrix, int nozzle_idx) const +{ + const auto& project_config = wxGetApp().preset_bundle->project_config; + const size_t full_n = project_config.option("filament_colour")->values.size(); + if (m_physical_indices.size() == full_n) + return sub_matrix; // no mixed slots: sub-matrix already is the full matrix + + auto raw = project_config.option("flush_volumes_matrix")->values; + int nozzle_num = (int)wxGetApp().preset_bundle->project_config.option("flush_multiplier")->values.size(); + if (nozzle_num < 1) nozzle_num = 1; + auto original = get_flush_volumes_matrix(raw, nozzle_idx, nozzle_num); + return expand_physical_to_full_matrix(sub_matrix, m_physical_indices, full_n, original); +} + std::vector WipingDialog::GetFlattenMatrix()const { std::vector ret; - for (auto& matrix : m_raw_matrixs) { - ret.insert(ret.end(), matrix.begin(), matrix.end()); + for (size_t idx = 0; idx < m_raw_matrixs.size(); ++idx) { + auto full = ExpandToFullMatrix(m_raw_matrixs[idx], (int)idx); + ret.insert(ret.end(), full.begin(), full.end()); } return ret; } diff --git a/src/slic3r/GUI/WipeTowerDialog.hpp b/src/slic3r/GUI/WipeTowerDialog.hpp index 64e5758534..91944cfc78 100644 --- a/src/slic3r/GUI/WipeTowerDialog.hpp +++ b/src/slic3r/GUI/WipeTowerDialog.hpp @@ -58,12 +58,16 @@ private: wxString BuildTableObjStr(); wxString BuildTextObjStr(bool multi_language = true); void StoreFlushData(int extruder_num, const std::vector>& flush_volume_vecs, const std::vector& flush_multipliers); + // Maps the physical-only matrix shown in the table back onto the full config-indexed matrix. + std::vector ExpandToFullMatrix(const std::vector& sub_matrix, int nozzle_idx) const; wxWebView* m_webview; int m_max_flush_volume; VolumeMatrix m_raw_matrixs; std::vector m_flush_multipliers; + // Config indices of the physical (non-mixed) filaments, in table order. + std::vector m_physical_indices; bool m_submit_flag{ false }; }; diff --git a/src/slic3r/GUI/wxExtensions.cpp b/src/slic3r/GUI/wxExtensions.cpp index 2ca8f3cfbd..88e2df31c0 100644 --- a/src/slic3r/GUI/wxExtensions.cpp +++ b/src/slic3r/GUI/wxExtensions.cpp @@ -555,14 +555,20 @@ std::vector get_extruder_color_icons(bool thin_icon/* = false*/) const int icon_width = lround((thin_icon ? 2 : 4.4) * em); const int icon_height = lround(2 * em); + // A gradient mixed filament fades over the model's height, so it gets the same + // curve-sampled ramp the editor previews instead of a fade between two endpoints. + const auto& gradient_ramps = Slic3r::GUI::wxGetApp().plater()->get_filament_gradient_ramps(); + int index = 0; for (const auto &colors : readable_color_info) { auto label = std::to_string(++index); - bool is_gradient = ctype[index-1] == "0"; - if (colors.size() == 1) { + const size_t slot = index - 1; + bool is_gradient = ctype[slot] == "0"; + const std::vector* ramp = (slot < gradient_ramps.size() && !gradient_ramps[slot].empty()) ? &gradient_ramps[slot] : nullptr; + if (ramp == nullptr && colors.size() == 1) { bmps.push_back(get_extruder_color_icon(colors[0], label, icon_width, icon_height)); } else { - bmps.push_back(get_extruder_color_icon(colors, is_gradient, label, icon_width, icon_height)); + bmps.push_back(get_extruder_color_icon(colors, is_gradient, label, icon_width, icon_height, ramp)); } } } else { @@ -630,14 +636,27 @@ wxColourData show_sys_picker_dialog(wxWindow *parent, const wxColourData &clr_da return data; } -wxBitmap *get_extruder_color_icon(std::vector colors, bool is_gradient, std::string label, int icon_width, int icon_height){ +wxBitmap *get_extruder_color_icon(std::vector colors, bool is_gradient, std::string label, int icon_width, int icon_height, + const std::vector *ramp){ static Slic3r::GUI::BitmapCache bmp_cache; - // build cache key, include all color info + // build cache key, include all color info. A ramp already encodes its slot's components, + // colours and curve, so keying on it rebuilds the icon whenever any of them change. std::string bitmap_key = ""; - for (const auto& color : colors) { - bitmap_key += color + "_"; + if (ramp != nullptr) { + static const char hex_digits[] = "0123456789ABCDEF"; + bitmap_key = "grad_"; + for (const wxColour &c : *ramp) + for (unsigned char v : {c.Red(), c.Green(), c.Blue()}) { + bitmap_key += hex_digits[v >> 4]; + bitmap_key += hex_digits[v & 0x0F]; + } + bitmap_key += "_"; + } else { + for (const auto& color : colors) { + bitmap_key += color + "_"; + } } bitmap_key += "h" + std::to_string(icon_height) + "-w" + std::to_string(icon_width) + "-i" + label; @@ -647,16 +666,21 @@ wxBitmap *get_extruder_color_icon(std::vector colors, bool is_gradi #endif if (bitmap == nullptr) { - std::vector wx_colors; - for (const auto& color_str : colors) { - wx_colors.push_back(wxColour(color_str)); - } - if (wx_colors.empty()) { - wx_colors.push_back(wxColour("#636363")); // default color if no colors provided - } + wxBitmap base_bitmap; + if (ramp != nullptr) { + base_bitmap = Slic3r::GUI::create_gradient_ramp_bitmap(*ramp, wxSize(icon_width, icon_height)); + } else { + std::vector wx_colors; + for (const auto& color_str : colors) { + wx_colors.push_back(wxColour(color_str)); + } + if (wx_colors.empty()) { + wx_colors.push_back(wxColour("#636363")); // default color if no colors provided + } - // create filament bitmap in multi color - wxBitmap base_bitmap = Slic3r::GUI::create_filament_bitmap(wx_colors, wxSize(icon_width, icon_height), is_gradient); + // create filament bitmap in multi color + base_bitmap = Slic3r::GUI::create_filament_bitmap(wx_colors, wxSize(icon_width, icon_height), is_gradient); + } if (!base_bitmap.IsOk()) { // if create failed, return nullptr diff --git a/src/slic3r/GUI/wxExtensions.hpp b/src/slic3r/GUI/wxExtensions.hpp index 502614eb92..2754b3e5ba 100644 --- a/src/slic3r/GUI/wxExtensions.hpp +++ b/src/slic3r/GUI/wxExtensions.hpp @@ -75,7 +75,10 @@ wxBitmap create_scaled_bitmap(const std::string& bmp_name, wxWindow *win = nullp wxBitmap* get_default_extruder_color_icon(bool thin_icon = false); std::vector get_extruder_color_icons(bool thin_icon = false); wxBitmap * get_extruder_color_icon(std::string color, std::string label, int icon_width, int icon_height); -wxBitmap * get_extruder_color_icon(std::vector colors, bool is_gradient, std::string label, int icon_width, int icon_height); +// A non-null ramp draws the slot as a gradient mixed filament instead: it holds the colours the +// slot actually prints, bottom entry first, and is drawn bottom to top rather than from colors. +wxBitmap * get_extruder_color_icon(std::vector colors, bool is_gradient, std::string label, int icon_width, int icon_height, + const std::vector *ramp = nullptr); std::vector> read_color_pack(std::vector color_pack); wxColourData show_sys_picker_dialog(wxWindow *parent, const wxColourData &clr_data); diff --git a/src/slic3r/GUI/wxMediaCtrl2.cpp b/src/slic3r/GUI/wxMediaCtrl2.cpp deleted file mode 100644 index 957efefd3b..0000000000 --- a/src/slic3r/GUI/wxMediaCtrl2.cpp +++ /dev/null @@ -1,647 +0,0 @@ -#include "wxMediaCtrl2.h" -#include "libslic3r/Time.hpp" -#include "I18N.hpp" -#include "GUI_App.hpp" -#include "LinuxDisplayBackend.hpp" -#include -#include -#ifdef __WIN32__ -#include -#include -#include -#include -#endif - -#ifdef __LINUX__ -#include "Printer/gstbambusrc.h" -#include // main gstreamer header -#endif - -#if defined(__LINUX__) && defined(__WXGTK__) -#include -#include - -namespace { -bool ensure_gstreamer_initialized_for_liveview() -{ - GError* error = nullptr; - if (!gst_init_check(nullptr, nullptr, &error)) { - BOOST_LOG_TRIVIAL(error) << "wxMediaCtrl2: gst_init_check failed before native Wayland liveview setup" - << (error ? std::string(": ") + error->message : std::string()); - if (error) - g_error_free(error); - return false; - } - - return true; -} - -bool is_gstreamer_feature_available(const char* feature) -{ - if (!ensure_gstreamer_initialized_for_liveview()) - return false; - - GstElementFactory* factory = gst_element_factory_find(feature); - if (!factory) - return false; - - gst_object_unref(factory); - return true; -} - -void set_gstreamer_feature_rank(const char* feature, guint rank) -{ - GstElementFactory* factory = gst_element_factory_find(feature); - if (!factory) - return; - - gst_plugin_feature_set_rank(GST_PLUGIN_FEATURE(factory), rank); - gst_object_unref(factory); -} - -void configure_wayland_gstreamer_liveview_path() -{ - static bool configured = false; - if (configured) - return; - configured = true; - - if (!ensure_gstreamer_initialized_for_liveview()) - return; - - // Prefer software decode for Bambu liveview on Wayland/NVIDIA, where - // zero-copy GL/DMABUF display paths can be fragile. Keep hardware - // decoders available as lower-ranked fallbacks for VAAPI/NVDEC/V4L2-only - // installations instead of passing preflight and then blocking autoplug. - set_gstreamer_feature_rank("avdec_h264", GST_RANK_PRIMARY + 300); - set_gstreamer_feature_rank("openh264dec", GST_RANK_PRIMARY + 100); - set_gstreamer_feature_rank("nvh264dec", GST_RANK_MARGINAL); - set_gstreamer_feature_rank("vaapih264dec", GST_RANK_MARGINAL); - set_gstreamer_feature_rank("vah264dec", GST_RANK_MARGINAL); - set_gstreamer_feature_rank("v4l2h264dec", GST_RANK_MARGINAL); -} -} - -#endif // defined(__LINUX__) && defined(__WXGTK__) - -#ifdef __LINUX__ -extern "C" int gst_bambu_last_error; - -class WXDLLIMPEXP_MEDIA - wxGStreamerMediaBackend : public wxMediaBackendCommonBase -{ -public: - GstElement *m_playbin; // GStreamer media element -}; -#endif - -wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); - -wxMediaCtrl2::wxMediaCtrl2(wxWindow *parent) -{ -#if defined(__LINUX__) && defined(__WXGTK__) - m_native_wayland = Slic3r::GUI::is_running_on_wayland(); - if (m_native_wayland && is_gstreamer_feature_available("gtksink")) - configure_wayland_gstreamer_liveview_path(); - else if (m_native_wayland) { - m_gtk_sink_error = _L("Native Wayland liveview requires the GStreamer GTK video sink. Please install the gtksink plugin for GStreamer, then restart OrcaSlicer."); - BOOST_LOG_TRIVIAL(warning) << "wxMediaCtrl2: native Wayland liveview disabled because GStreamer gtksink is unavailable"; - } -#endif -#ifdef __WIN32__ - auto hModExe = GetModuleHandle(NULL); - // BOOST_LOG_TRIVIAL(info) << "wxMediaCtrl2: GetModuleHandle " << hModExe; - auto NvOptimusEnablement = (DWORD *) GetProcAddress(hModExe, "NvOptimusEnablement"); - auto AmdPowerXpressRequestHighPerformance = (int *) GetProcAddress(hModExe, "AmdPowerXpressRequestHighPerformance"); - if (NvOptimusEnablement) { - // BOOST_LOG_TRIVIAL(info) << "wxMediaCtrl2: NvOptimusEnablement " << *NvOptimusEnablement; - *NvOptimusEnablement = 0; - } - if (AmdPowerXpressRequestHighPerformance) { - // BOOST_LOG_TRIVIAL(info) << "wxMediaCtrl2: AmdPowerXpressRequestHighPerformance " << *AmdPowerXpressRequestHighPerformance; - *AmdPowerXpressRequestHighPerformance = 0; - } -#endif -#if defined(__LINUX__) && defined(__WXGTK__) - if (m_native_wayland) - wxControl::Create(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize); - else -#endif - wxMediaCtrl::Create(parent, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxMEDIACTRLPLAYERCONTROLS_NONE); -#ifdef __LINUX__ - gstbambusrc_register(); -#ifdef __WXGTK__ - if (m_native_wayland && m_gtk_sink_error.empty()) - m_use_gtk_sink = CreateGtkSinkPlayer(); - if (m_native_wayland && !m_use_gtk_sink && m_gtk_sink_error.empty()) - m_gtk_sink_error = _L("Failed to initialize the native Wayland GStreamer video sink. Please check your GStreamer GTK plugin installation."); -#endif - if (!m_use_gtk_sink && m_imp) { - auto playbin = reinterpret_cast(m_imp)->m_playbin; - g_object_set(G_OBJECT(playbin), - "audio-sink", nullptr, - nullptr); - } else if (!m_use_gtk_sink) { - BOOST_LOG_TRIVIAL(warning) << "wxMediaCtrl2: wxMediaCtrl backend is unavailable"; - } - Bind(wxEVT_MEDIA_LOADED, [this](auto & e) { - m_loaded = true; - wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); - event.SetId(0); - event.SetEventObject(this); - wxPostEvent(this, event); - }); -#endif -} - -wxMediaCtrl2::~wxMediaCtrl2() -{ -#if defined(__LINUX__) && defined(__WXGTK__) - DestroyGtkSinkPlayer(); -#endif -} - -#if defined(__LINUX__) && defined(__WXGTK__) -bool wxMediaCtrl2::CreateGtkSinkPlayer() -{ - GstElement *playbin = gst_element_factory_make("playbin", "orca-wayland-gtk-playbin"); - if (!playbin) - return false; - - GError *error = nullptr; - GstElement *video_sink = gst_parse_bin_from_description( - "videoconvert ! videoscale ! video/x-raw,format=BGRx ! gtksink name=orca_wayland_gtksink sync=false", - TRUE, - &error); - if (!video_sink) { - BOOST_LOG_TRIVIAL(warning) << "wxMediaCtrl2: failed to create gtksink video bin" - << (error ? std::string(": ") + error->message : std::string()); - if (error) - g_error_free(error); - gst_object_unref(playbin); - return false; - } - - GstElement *gtk_sink = gst_bin_get_by_name(GST_BIN(video_sink), "orca_wayland_gtksink"); - if (!gtk_sink) { - BOOST_LOG_TRIVIAL(warning) << "wxMediaCtrl2: failed to find gtksink in video bin"; - gst_object_unref(video_sink); - gst_object_unref(playbin); - return false; - } - - GtkWidget *gtk_widget = nullptr; - g_object_get(G_OBJECT(gtk_sink), "widget", >k_widget, nullptr); - if (!gtk_widget) { - BOOST_LOG_TRIVIAL(warning) << "wxMediaCtrl2: gtksink did not expose a GtkWidget"; - gst_object_unref(gtk_sink); - gst_object_unref(video_sink); - gst_object_unref(playbin); - return false; - } - - gtk_widget_show(gtk_widget); - m_gtk_video_window = new wxNativeWindow(this, wxID_ANY, gtk_widget); - m_gtk_video_window->Show(); - g_object_unref(gtk_widget); - - g_object_set(G_OBJECT(playbin), - "video-sink", video_sink, - "audio-sink", nullptr, - nullptr); - gst_object_unref(video_sink); - - m_gtk_playbin = playbin; - m_gtk_sink = gtk_sink; - - GstBus *bus = gst_element_get_bus(playbin); - m_gtk_bus_watch_id = gst_bus_add_watch(bus, [](GstBus *, GstMessage *message, gpointer data) -> gboolean { - auto *self = static_cast(data); - if (!self || !self->m_gtk_playbin) - return G_SOURCE_REMOVE; - - switch (GST_MESSAGE_TYPE(message)) { - case GST_MESSAGE_ERROR: - { - GError *error = nullptr; - gchar *debug = nullptr; - gst_message_parse_error(message, &error, &debug); - BOOST_LOG_TRIVIAL(warning) << "wxMediaCtrl2: gtksink pipeline error" - << (error ? std::string(": ") + error->message : std::string()) - << (debug ? std::string(" debug: ") + debug : std::string()); - if (error) - g_error_free(error); - if (debug) - g_free(debug); - - self->m_error = gst_bambu_last_error ? gst_bambu_last_error : 2; - self->m_loaded = false; - self->m_gtk_state = wxMEDIASTATE_STOPPED; - self->PostGtkSinkStateEvent(self->GetId()); - break; - } - case GST_MESSAGE_EOS: - self->m_loaded = false; - self->m_gtk_state = wxMEDIASTATE_STOPPED; - self->PostGtkSinkStateEvent(self->GetId()); - break; - case GST_MESSAGE_STATE_CHANGED: - if (GST_MESSAGE_SRC(message) == GST_OBJECT(self->m_gtk_playbin)) { - GstState old_state; - GstState new_state; - GstState pending_state; - gst_message_parse_state_changed(message, &old_state, &new_state, &pending_state); - - if (new_state == GST_STATE_PLAYING) { - self->m_loaded = true; - self->m_gtk_state = wxMEDIASTATE_PLAYING; - self->PostGtkSinkStateEvent(); - } else if (new_state == GST_STATE_PAUSED && old_state < GST_STATE_PAUSED) { - // Treat only upward READY/NULL -> PAUSED as load completion. - // PLAYING -> PAUSED is a normal teardown step before NULL. - self->m_loaded = true; - self->m_gtk_state = wxMEDIASTATE_PAUSED; - self->PostGtkSinkStateEvent(); - } else if (new_state <= GST_STATE_READY && old_state >= GST_STATE_PAUSED) { - self->m_loaded = false; - self->m_gtk_state = wxMEDIASTATE_STOPPED; - self->PostGtkSinkStateEvent(); - } - } - break; - default: - break; - } - - return G_SOURCE_CONTINUE; - }, this); - gst_object_unref(bus); - - BOOST_LOG_TRIVIAL(info) << "wxMediaCtrl2: using GTK native Wayland video sink"; - return true; -} - -void wxMediaCtrl2::DestroyGtkSinkPlayer() -{ - if (m_gtk_bus_watch_id) { - g_source_remove(m_gtk_bus_watch_id); - m_gtk_bus_watch_id = 0; - } - - if (m_gtk_playbin) { - gst_element_set_state(m_gtk_playbin, GST_STATE_NULL); - } - - if (m_gtk_video_window) { - m_gtk_video_window->Destroy(); - m_gtk_video_window = nullptr; - } - - if (m_gtk_playbin) { - gst_object_unref(m_gtk_playbin); - m_gtk_playbin = nullptr; - } - - if (m_gtk_sink) { - gst_object_unref(m_gtk_sink); - m_gtk_sink = nullptr; - } - -} - -void wxMediaCtrl2::PostGtkSinkStateEvent(int id) -{ - wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); - event.SetId(id); - event.SetEventObject(this); - wxPostEvent(this, event); -} -#endif // defined(__LINUX__) && defined(__WXGTK__) - -#define CLSID_BAMBU_SOURCE L"{233E64FB-2041-4A6C-AFAB-FF9BCF83E7AA}" - -void wxMediaCtrl2::Load(wxURI url) -{ - const wxString scheme = url.GetScheme(); - const bool generic_rtsp = scheme.CmpNoCase("rtsp") == 0 || scheme.CmpNoCase("rtsps") == 0; - -#ifdef __WIN32__ - InvalidateBestSize(); - if (m_imp == nullptr) { - static bool notified = false; - if (!notified) CallAfter([] { - auto res = wxMessageBox(_L("Windows Media Player is required for this task! Do you want to enable 'Windows Media Player' for your operation system?"), _L("Error"), wxOK | wxCANCEL); - if (res == wxOK) { - wxString url = IsWindows10OrGreater() - ? "ms-settings:optionalfeatures?activationSource=SMC-Article-14209" - : "https://support.microsoft.com/en-au/windows/get-windows-media-player-81718e0d-cfce-25b1-aee3-94596b658287"; - wxExecute("cmd /c start " + url, wxEXEC_HIDE_CONSOLE); - } - }); - m_error = 100; - wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); - event.SetId(GetId()); - event.SetEventObject(this); - wxPostEvent(this, event); - return; - } - // BambuSource handles the proprietary bambu:// transport. Generic RTSP - // streams must go directly to the native Windows media backend. - if (!generic_rtsp) { - wxRegKey key11(wxRegKey::HKCU, L"SOFTWARE\\Classes\\CLSID\\" CLSID_BAMBU_SOURCE L"\\InProcServer32"); - wxRegKey key12(wxRegKey::HKCR, L"CLSID\\" CLSID_BAMBU_SOURCE L"\\InProcServer32"); - wxString path = key11.Exists() ? key11.QueryDefaultValue() - : key12.Exists() ? key12.QueryDefaultValue() : wxString{}; - wxRegKey key2(wxRegKey::HKCR, "bambu"); - wxString clsid; - if (key2.Exists()) - key2.QueryRawValue("Source Filter", clsid); - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": clsid %1% path %2%") % clsid % path; - - std::string data_dir_str = Slic3r::data_dir(); - boost::filesystem::path data_dir_path(data_dir_str); - auto dll_path = data_dir_path / "plugins" / "BambuSource.dll"; - if (path.empty() || !wxFile::Exists(path) || clsid != CLSID_BAMBU_SOURCE) { - if (boost::filesystem::exists(dll_path)) { - CallAfter( - [dll_path] { - int res = wxMessageBox(_L("BambuSource has not correctly been registered for media playing! Press Yes to re-register it. You will be promoted twice"), _L("Error"), wxYES_NO); - if (res == wxYES) { - std::string regContent = R"(Windows Registry Editor Version 5.00 - [HKEY_CLASSES_ROOT\bambu] - "Source Filter"="{233E64FB-2041-4A6C-AFAB-FF9BCF83E7AA}" - )"; - - auto reg_path = (fs::temp_directory_path() / fs::unique_path()).replace_extension(".reg"); - std::ofstream temp_reg_file(reg_path.c_str()); - if (!temp_reg_file) { - return false; - } - temp_reg_file << regContent; - temp_reg_file.close(); - auto sei_params = L"/q /s " + reg_path.wstring(); - SHELLEXECUTEINFO sei{sizeof(sei), SEE_MASK_NOCLOSEPROCESS, NULL, L"open", - L"regedit", sei_params.c_str(),SW_HIDE,SW_HIDE}; - ::ShellExecuteEx(&sei); - - wstring quoted_dll_path = L"\"" + dll_path.wstring() + L"\""; - SHELLEXECUTEINFO info{sizeof(info), 0, NULL, L"runas", L"regsvr32", quoted_dll_path.c_str(), SW_HIDE }; - ::ShellExecuteEx(&info); - fs::remove(reg_path); - } - return true; - }); - } else { - CallAfter([] { - wxMessageBox(_L("Missing BambuSource component registered for media playing! Please re-install OrcaSlicer or seek community help."), _L("Error"), wxOK); - }); - } - m_error = clsid != CLSID_BAMBU_SOURCE ? 101 : path.empty() ? 102 : 103; - wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); - event.SetId(GetId()); - event.SetEventObject(this); - wxPostEvent(this, event); - return; - } - if (path != dll_path) { - static bool notified = false; - if (!notified) CallAfter([dll_path] { - int res = wxMessageBox(_L("Using a BambuSource from a different install, video play may not work correctly! Press Yes to fix it."), _L("Warning"), wxYES_NO | wxICON_WARNING); - if (res == wxYES) { - auto path = dll_path.wstring(); - if (path.find(L' ') != std::wstring::npos) - path = L"\"" + path + L"\""; - SHELLEXECUTEINFO info{sizeof(info), 0, NULL, L"open", L"regsvr32", path.c_str(), SW_HIDE}; - ::ShellExecuteEx(&info); - } - }); - notified = true; - } - wxRegKey keyWmp(wxRegKey::HKCU, "SOFTWARE\\Microsoft\\MediaPlayer\\Player\\Extensions\\."); - keyWmp.Create(); - long permissions = 0; - if (keyWmp.HasValue("Permissions")) - keyWmp.QueryValue("Permissions", &permissions); - if ((permissions & 32) == 0) { - permissions |= 32; - keyWmp.SetValue("Permissions", permissions); - } - } - if (!generic_rtsp) { - url = wxURI(url.BuildURI().append("&hwnd=").append(boost::lexical_cast(GetHandle())).append("&tid=").append( - boost::lexical_cast(GetCurrentThreadId()))); - } -#endif -#ifdef __WXGTK3__ - GstElementFactory *factory; - int hasplugins = 1; - - factory = gst_element_factory_find("h264parse"); - if (!factory) { - hasplugins = 0; - } else { - gst_object_unref(factory); - } - - factory = gst_element_factory_find("openh264dec"); - if (!factory) { - factory = gst_element_factory_find("avdec_h264"); - } - if (!factory) { - factory = gst_element_factory_find("vaapih264dec"); - } - if (!factory) { - factory = gst_element_factory_find("vah264dec"); - } - if (!factory) { - factory = gst_element_factory_find("nvh264dec"); - } - if (!factory) { - factory = gst_element_factory_find("v4l2h264dec"); - } - if (!factory) { - hasplugins = 0; - } else { - gst_object_unref(factory); - } - - // Generic RTSP streams may use codecs other than H.264; let GStreamer - // select the decoder from the stream caps instead of applying the Bambu - // liveview H.264 preflight. - if (!generic_rtsp && !hasplugins) { - CallAfter([] { - wxMessageBox(_L("Your system is missing H.264 codecs for GStreamer, which are required to play video. (Try installing the gstreamer1.0-plugins-bad or gstreamer1.0-libav packages, then restart Orca Slicer?)"), _L("Error"), wxOK); - }); - m_error = 101; - wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); - event.SetId(GetId()); - event.SetEventObject(this); - wxPostEvent(this, event); - return; - } - wxLog::EnableLogging(false); -#endif - m_error = 0; - m_loaded = false; -#ifdef __LINUX__ - if (generic_rtsp) - gst_bambu_last_error = 0; -#endif -#if defined(__LINUX__) && defined(__WXGTK__) - if (m_use_gtk_sink && m_gtk_playbin) { - const std::string uri = std::string(url.BuildURI().ToUTF8().data()); - gst_element_set_state(m_gtk_playbin, GST_STATE_NULL); - g_object_set(G_OBJECT(m_gtk_playbin), "uri", uri.c_str(), nullptr); - m_gtk_state = wxMEDIASTATE_STOPPED; - GstStateChangeReturn state = gst_element_set_state(m_gtk_playbin, GST_STATE_PAUSED); - if (state == GST_STATE_CHANGE_FAILURE) { - m_error = gst_bambu_last_error ? gst_bambu_last_error : 2; - PostGtkSinkStateEvent(GetId()); - } - return; - } - if (!m_imp) { - m_error = m_native_wayland && !m_gtk_sink_error.empty() ? 104 : 100; - m_loaded = false; - if (m_native_wayland && !m_gtk_sink_error.empty() && !m_gtk_sink_error_notified) { - m_gtk_sink_error_notified = true; - const wxString message = m_gtk_sink_error; - CallAfter([message] { - wxMessageBox(message, _L("Error"), wxOK); - }); - } - wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); - event.SetId(GetId()); - event.SetEventObject(this); - wxPostEvent(this, event); - return; - } -#endif - wxMediaCtrl::Load(url); -} - -void wxMediaCtrl2::Play() -{ -#if defined(__LINUX__) && defined(__WXGTK__) - if (m_use_gtk_sink && m_gtk_playbin) { - GstStateChangeReturn state = gst_element_set_state(m_gtk_playbin, GST_STATE_PLAYING); - if (state == GST_STATE_CHANGE_FAILURE) { - m_error = gst_bambu_last_error ? gst_bambu_last_error : 2; - m_gtk_state = wxMEDIASTATE_STOPPED; - PostGtkSinkStateEvent(GetId()); - } - return; - } - if (!m_imp) { - m_error = m_native_wayland && !m_gtk_sink_error.empty() ? 104 : 100; - if (m_native_wayland && !m_gtk_sink_error.empty() && !m_gtk_sink_error_notified) { - m_gtk_sink_error_notified = true; - const wxString message = m_gtk_sink_error; - CallAfter([message] { - wxMessageBox(message, _L("Error"), wxOK); - }); - } - PostGtkSinkStateEvent(GetId()); - return; - } -#endif - wxMediaCtrl::Play(); -} - -void wxMediaCtrl2::Stop() -{ -#if defined(__LINUX__) && defined(__WXGTK__) - if (m_use_gtk_sink && m_gtk_playbin) { - gst_element_set_state(m_gtk_playbin, GST_STATE_NULL); - m_gtk_state = wxMEDIASTATE_STOPPED; - m_loaded = false; - PostGtkSinkStateEvent(0); - return; - } - if (!m_imp) - return; -#endif - wxMediaCtrl::Stop(); -} - -wxMediaState wxMediaCtrl2::GetState() -{ -#if defined(__LINUX__) && defined(__WXGTK__) - if (m_use_gtk_sink && m_gtk_playbin) - return m_gtk_state; - if (!m_imp) - return wxMEDIASTATE_STOPPED; -#endif - return wxMediaCtrl::GetState(); -} - -int wxMediaCtrl2::GetLastError() const -{ -#ifdef __LINUX__ -#ifdef __WXGTK__ - if (m_use_gtk_sink && m_error) - return m_error; -#endif - if (m_error) - return m_error; - return gst_bambu_last_error; -#else - return m_error; -#endif -} - -wxSize wxMediaCtrl2::GetVideoSize() const -{ -#ifdef __LINUX__ - // Gstreamer doesn't give us a VideoSize until we're playing, which - // confuses the MediaPlayCtrl into claiming that it is stuck - // "Loading...". Fake it out for now. - return m_loaded ? wxSize(1280, 720) : wxSize{}; -#else - wxSize size = m_imp ? m_imp->GetVideoSize() : wxSize(0, 0); - if (size.GetWidth() > 0) - const_cast(m_video_size) = size; - return size; -#endif -} - -wxSize wxMediaCtrl2::DoGetBestSize() const -{ - return {-1, -1}; -} - -#ifdef __WIN32__ - -WXLRESULT wxMediaCtrl2::MSWWindowProc(WXUINT nMsg, - WXWPARAM wParam, - WXLPARAM lParam) -{ - // The stream source sends WM_USER+1000 with a synchronous SendMessage from its own threads, - // so this runs re-entrantly on the UI thread at whatever message-retrieval point the player - // happens to be in - often nested inside an Orca log statement. Never BOOST_LOG_TRIVIAL here: - // boost::log is not re-entrant on one thread, and doing so corrupted its per-thread record - // state, crashing later in unrelated places (the player, the log filter, a plug-in heap free). - // Post the string out (as the stat branch does) and log it on a clean stack instead. - if (nMsg == WM_USER + 1000) { - wxString msg((wchar_t const *) lParam); - if (wParam == 1) { - if (msg.EndsWith("]")) { - int n = msg.find_last_of('['); - if (n != wxString::npos) { - long val = 0; - if (msg.SubString(n + 1, msg.Length() - 2).ToLong(&val)) - m_error = (int) val; - } - } else if (msg.Contains("stat_log")) { - wxCommandEvent evt(EVT_MEDIA_CTRL_STAT); - evt.SetEventObject(this); - evt.SetString(msg.Mid(msg.Find(' ') + 1)); - wxPostEvent(this, evt); - } - } - return 0; - } - return wxMediaCtrl::MSWWindowProc(nMsg, wParam, lParam); -} - -#endif diff --git a/src/slic3r/GUI/wxMediaCtrl2.h b/src/slic3r/GUI/wxMediaCtrl2.h deleted file mode 100644 index d5f5092d80..0000000000 --- a/src/slic3r/GUI/wxMediaCtrl2.h +++ /dev/null @@ -1,115 +0,0 @@ -// -// wxMediaCtrl2.h -// libslic3r_gui -// -// Created by cmguo on 2021/12/7. -// - -#ifndef wxMediaCtrl2_h -#define wxMediaCtrl2_h - -#include "wx/uri.h" -#include "wx/mediactrl.h" - -#include - -wxDECLARE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); - -#if defined(__LINUX__) && defined(__WXGTK__) -typedef struct _GstElement GstElement; -#endif - -#ifdef __WXMAC__ - -class wxMediaCtrl2 : public wxWindow, public Slic3r::GUI::IMediaController -{ -public: - wxMediaCtrl2(wxWindow * parent); - - ~wxMediaCtrl2(); - - void Load(wxURI url) override; - - void Play() override; - - void Stop() override; - - wxMediaState GetState() override; - - int GetLastError() const override { return m_error; } - - wxSize GetVideoSize() const override; - - static inline const wxMediaState MEDIASTATE_BUFFERING = static_cast(6); - -protected: - void DoSetSize(int x, int y, int width, int height, int sizeFlags) override; - - static void bambu_log(void const * ctx, int level, char const * msg); - - void NotifyStopped(); - -private: - void create_player(); - void * m_player = nullptr; - wxMediaState m_state = wxMEDIASTATE_STOPPED; - int m_error = 0; - wxSize m_video_size{16, 9}; -}; - -#else - -class wxMediaCtrl2 : public wxMediaCtrl, public Slic3r::GUI::IMediaController -{ -public: - wxMediaCtrl2(wxWindow *parent); - ~wxMediaCtrl2(); - - void Load(wxURI url) override; - - void Play() override; - - void Stop() override; - - wxMediaState GetState() override; - - int GetLastError() const override; - - wxSize GetVideoSize() const override; - -protected: - wxSize DoGetBestSize() const override; - - void DoSetSize(int x, int y, int width, int height, int sizeFlags) override; - -#ifdef __WIN32__ - WXLRESULT MSWWindowProc(WXUINT nMsg, - WXWPARAM wParam, - WXLPARAM lParam) override; -#endif - -private: -#if defined(__LINUX__) && defined(__WXGTK__) - bool CreateGtkSinkPlayer(); - void DestroyGtkSinkPlayer(); - void PostGtkSinkStateEvent(int id = 0); - - bool m_native_wayland = false; - bool m_use_gtk_sink = false; - wxString m_gtk_sink_error; - bool m_gtk_sink_error_notified = false; - GstElement *m_gtk_playbin = nullptr; - GstElement *m_gtk_sink = nullptr; - unsigned int m_gtk_bus_watch_id = 0; - wxWindow *m_gtk_video_window = nullptr; - wxMediaState m_gtk_state = wxMEDIASTATE_STOPPED; -#endif - wxString m_idle_image; - int m_error = 0; - bool m_loaded = false; - wxSize m_video_size{16, 9}; -}; - -#endif - -#endif /* wxMediaCtrl2_h */ diff --git a/src/slic3r/GUI/wxMediaCtrl2.mm b/src/slic3r/GUI/wxMediaCtrl2.mm deleted file mode 100644 index 8814d6417f..0000000000 --- a/src/slic3r/GUI/wxMediaCtrl2.mm +++ /dev/null @@ -1,170 +0,0 @@ -// -// wxMediaCtrl2.m -// OrcaSlicer -// -// Created by cmguo on 2021/12/7. -// - -#import "wxMediaCtrl2.h" -#import "wx/mediactrl.h" -#include - -#import -#import "BambuPlayer/BambuPlayer.h" -#import "../Utils/NetworkAgent.hpp" - -#include -#include - -wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); - -#define BAMBU_DYNAMIC - -void wxMediaCtrl2::bambu_log(void const * ctx, int level, char const * msg) -{ - if (level == 1) { - wxString msg2(msg); - if (msg2.EndsWith("]")) { - int n = msg2.find_last_of('['); - if (n != wxString::npos) { - long val = 0; - wxMediaCtrl2 * ctrl = (wxMediaCtrl2 *) ctx; - if (msg2.SubString(n + 1, msg2.Length() - 2).ToLong(&val)) - ctrl->m_error = (int) val; - } - } else if (strstr(msg, "stat_log")) { - wxMediaCtrl2 * ctrl = (wxMediaCtrl2 *) ctx; - wxCommandEvent evt(EVT_MEDIA_CTRL_STAT); - evt.SetEventObject(ctrl); - evt.SetString(strchr(msg, ' ') + 1); - wxPostEvent(ctrl, evt); - } - } else if (level < 0) { - wxMediaCtrl2 * ctrl = (wxMediaCtrl2 *) ctx; - ctrl->NotifyStopped(); - } - BOOST_LOG_TRIVIAL(info) << msg; -} - -wxMediaCtrl2::wxMediaCtrl2(wxWindow * parent) - : wxWindow(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize) -{ - NSView * imageView = (NSView *) GetHandle(); - imageView.layer = [[CALayer alloc] init]; - CGColorRef color = CGColorCreateGenericRGB(0, 0, 0, 1.0f); - imageView.layer.backgroundColor = color; - CGColorRelease(color); - imageView.wantsLayer = YES; - create_player(); -} - -wxMediaCtrl2::~wxMediaCtrl2() -{ - BambuPlayer * player = (BambuPlayer *) m_player; - [player dealloc]; -} - -void wxMediaCtrl2::create_player() -{ - auto module = Slic3r::NetworkAgent::get_bambu_source_entry(); - if (!module) { - //not ready yet - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << "Network plugin not ready currently!"; - return; - } - Class cls = (__bridge Class) dlsym(module, "OBJC_CLASS_$_BambuPlayer"); - if (cls == nullptr) { - m_error = -2; - return; - } - NSView * imageView = (NSView *) GetHandle(); - BambuPlayer * player = [cls alloc]; - [player initWithImageView: imageView]; - [player setLogger: bambu_log withContext: this]; - m_player = player; -} - -void wxMediaCtrl2::Load(wxURI url) -{ - if (!m_player) { - create_player(); - if (!m_player) { - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": create_player failed currently!"; - return; - } - } - - BambuPlayer * player = (BambuPlayer *) m_player; - if (player) { - [player close]; - m_error = 0; - m_error = [player open: url.BuildURI().ToUTF8()]; - } - wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); - event.SetId(GetId()); - event.SetEventObject(this); - wxPostEvent(this, event); -} - -void wxMediaCtrl2::Play() -{ - if (!m_player) { - create_player(); - if (!m_player) { - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": create_player failed currently!"; - return; - } - } - BambuPlayer * player2 = (BambuPlayer *) m_player; - [player2 play]; - if (m_state != wxMEDIASTATE_PLAYING) { - m_state = wxMEDIASTATE_PLAYING; - wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); - event.SetId(GetId()); - event.SetEventObject(this); - wxPostEvent(this, event); - } -} - -void wxMediaCtrl2::Stop() -{ - if (!m_player) { - create_player(); - if (!m_player) { - BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": create_player failed currently!"; - return; - } - } - BambuPlayer * player2 = (BambuPlayer *) m_player; - [player2 close]; - NotifyStopped(); -} - -void wxMediaCtrl2::NotifyStopped() -{ - if (m_state != wxMEDIASTATE_STOPPED) { - m_state = wxMEDIASTATE_STOPPED; - wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); - event.SetId(GetId()); - event.SetEventObject(this); - wxPostEvent(this, event); - } -} - -wxMediaState wxMediaCtrl2::GetState() -{ - return m_state; -} - -wxSize wxMediaCtrl2::GetVideoSize() const -{ - BambuPlayer * player2 = (BambuPlayer *) m_player; - if (player2) { - NSSize size = [player2 videoSize]; - if (size.width > 0) - const_cast(m_video_size) = {(int) size.width, (int) size.height}; - return {(int) size.width, (int) size.height}; - } else { - return {0, 0}; - } -} diff --git a/src/slic3r/GUI/wxMediaCtrl3.cpp b/src/slic3r/GUI/wxMediaCtrl3.cpp new file mode 100644 index 0000000000..098db68808 --- /dev/null +++ b/src/slic3r/GUI/wxMediaCtrl3.cpp @@ -0,0 +1,313 @@ +#include "wxMediaCtrl3.h" +#include "AVVideoDecoder.hpp" +#include "I18N.hpp" +#include "libslic3r/Utils.hpp" +#include +#include +#ifdef __WIN32__ +#include +#include +#include +#endif + +wxDEFINE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); + +BEGIN_EVENT_TABLE(wxMediaCtrl3, wxWindow) + +// catch paint events +EVT_PAINT(wxMediaCtrl3::paintEvent) + +END_EVENT_TABLE() + +struct StaticBambuLib : BambuLib +{ + static StaticBambuLib &get(BambuLib *); +}; + +wxMediaCtrl3::wxMediaCtrl3(wxWindow *parent) + : wxWindow(parent, wxID_ANY) + , BambuLib(StaticBambuLib::get(this)) + , m_thread([this] { PlayThread(); }) +{ + SetBackgroundColour("#000001ff"); +} + +wxMediaCtrl3::~wxMediaCtrl3() +{ + { + std::unique_lock lk(m_mutex); + m_url.reset(new wxURI); + m_frame = wxImage(m_idle_image); + m_cond.notify_all(); + } + m_thread.join(); +} + +void wxMediaCtrl3::Load(wxURI url) +{ + std::unique_lock lk(m_mutex); + m_video_size = wxDefaultSize; + m_error = 0; + m_url.reset(new wxURI(url)); + m_cond.notify_all(); +} + +void wxMediaCtrl3::Play() +{ + std::unique_lock lk(m_mutex); + if (m_state != wxMEDIASTATE_PLAYING) { + m_state = wxMEDIASTATE_PLAYING; + wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); + event.SetId(GetId()); + event.SetEventObject(this); + wxPostEvent(this, event); + } +} + +void wxMediaCtrl3::Stop() +{ + std::unique_lock lk(m_mutex); + m_url.reset(); + m_frame = wxImage(m_idle_image); + NotifyStopped(); + m_cond.notify_all(); + Refresh(); +} + +void wxMediaCtrl3::SetIdleImage(wxString const &image) +{ + if (m_idle_image == image) + return; + m_idle_image = image; + if (m_url == nullptr) { + std::unique_lock lk(m_mutex); + m_frame = wxImage(m_idle_image); + assert(m_frame.IsOk()); + Refresh(); + } +} + +wxMediaState wxMediaCtrl3::GetState() +{ + std::unique_lock lk(m_mutex); + return m_state; +} + +int wxMediaCtrl3::GetLastError() +{ + std::unique_lock lk(m_mutex); + return m_error; +} + +wxSize wxMediaCtrl3::GetVideoSize() +{ + std::unique_lock lk(m_mutex); + return m_video_size; +} + +wxSize wxMediaCtrl3::DoGetBestSize() const +{ + return {-1, -1}; +} + +static void adjust_frame_size(wxSize & frame, wxSize const & video, wxSize const & window) +{ + if (video.x * window.y < video.y * window.x) + frame = { video.x * window.y / video.y, window.y }; + else + frame = { window.x, video.y * window.x / video.x }; +} + +void wxMediaCtrl3::paintEvent(wxPaintEvent &evt) +{ + wxPaintDC dc(this); + auto size = GetSize(); + if (size.x <= 0 || size.y <= 0) + return; + std::unique_lock lk(m_mutex); + if (!m_frame.IsOk()) + return; + auto size2 = m_frame.GetSize(); + if (size2.x != m_frame_size.x && size2.y == m_frame_size.y) + size2.x = m_frame_size.x; + auto size3 = (size - size2) / 2; + if (size2.x != size.x && size2.y != size.y) { + double scale = 1.; + if (size.x * size2.y > size.y * size2.x) { + size3 = {size.x * size2.y / size.y, size2.y}; + scale = double(size.y) / size2.y; + } else { + size3 = {size2.x, size.y * size2.x / size.x}; + scale = double(size.x) / size2.x; + } + dc.SetUserScale(scale, scale); + size3 = (size3 - size2) / 2; + } + dc.DrawBitmap(m_frame, size3.x, size3.y); +} + +void wxMediaCtrl3::DoSetSize(int x, int y, int width, int height, int sizeFlags) +{ + wxWindow::DoSetSize(x, y, width, height, sizeFlags); + if (sizeFlags == wxSIZE_USE_EXISTING) return; + wxMediaCtrl_OnSize(this, m_video_size, width, height); + std::unique_lock lk(m_mutex); + adjust_frame_size(m_frame_size, m_video_size, GetSize()); + Refresh(); +} + +void wxMediaCtrl3::bambu_log(void *ctx, int level, tchar const *msg2) +{ +#ifdef _WIN32 + wxString msg(msg2); +#else + wxString msg = wxString::FromUTF8(msg2); +#endif + if (level == 1) { + if (msg.EndsWith("]")) { + int n = msg.find_last_of('['); + if (n != wxString::npos) { + long val = 0; + wxMediaCtrl3 *ctrl = (wxMediaCtrl3 *) ctx; + if (msg.SubString(n + 1, msg.Length() - 2).ToLong(&val)) { + std::unique_lock lk(ctrl->m_mutex); + ctrl->m_error = (int) val; + } + } + } else if (msg.Contains("stat_log")) { + wxCommandEvent evt(EVT_MEDIA_CTRL_STAT); + wxMediaCtrl3 *ctrl = (wxMediaCtrl3 *) ctx; + evt.SetEventObject(ctrl); + evt.SetString(msg.Mid(msg.Find(' ') + 1)); + wxPostEvent(ctrl, evt); + } + } + BOOST_LOG_TRIVIAL(info) << msg.ToUTF8().data(); +} + +void wxMediaCtrl3::PlayThread() +{ + using namespace std::chrono_literals; + std::shared_ptr url; + std::unique_lock lk(m_mutex); + while (true) { + m_cond.wait(lk, [this, &url] { return m_url != url; }); + url = m_url; + if (url == nullptr) + continue; + if (!url->HasScheme()) + break; + lk.unlock(); + Bambu_Tunnel tunnel = nullptr; + int error = Bambu_Create(&tunnel, m_url->BuildURI().ToUTF8()); + if (error == 0) { + Bambu_SetLogger(tunnel, &wxMediaCtrl3::bambu_log, this); + error = Bambu_Open(tunnel); + if (error == 0) + error = Bambu_would_block; + } + lk.lock(); + while (error == int(Bambu_would_block)) { + m_cond.wait_for(lk, 100ms); + if (m_url != url) { + error = 1; + break; + } + lk.unlock(); + error = Bambu_StartStream(tunnel, true); + lk.lock(); + } + Bambu_StreamInfo info; + if (error == 0) + error = Bambu_GetStreamInfo(tunnel, 0, &info); + AVVideoDecoder decoder; + int minFrameDuration = 0; + if (error == 0) { + decoder.open(info); + m_video_size = { info.format.video.width, info.format.video.height }; + adjust_frame_size(m_frame_size, m_video_size, GetSize()); + minFrameDuration = 800 / info.format.video.frame_rate; // 80% + NotifyStopped(); + } + Bambu_Sample sample; + while (error == 0) { + lk.unlock(); + error = Bambu_ReadSample(tunnel, &sample); + lk.lock(); + while (error == int(Bambu_would_block)) { + m_cond.wait_for(lk, 100ms); + if (m_url != url) { + error = 1; + break; + } + lk.unlock(); + error = Bambu_ReadSample(tunnel, &sample); + lk.lock(); + } + if (error == 0) { + auto frame_size = m_frame_size; + lk.unlock(); + decoder.decode(sample); +#ifdef _WIN32 + wxBitmap bm; + decoder.toWxBitmap(bm, frame_size); +#else + wxImage bm; + decoder.toWxImage(bm, frame_size); +#endif + lk.lock(); + if (m_url != url) { + error = 1; + break; + } + if (bm.IsOk()) { + auto now = std::chrono::system_clock::now(); + if (m_last_PTS && (sample.decode_time - m_last_PTS) < 30000000ULL) { // 3s + auto next_PTS_expected = m_last_PTS_expected + std::chrono::milliseconds((sample.decode_time - m_last_PTS) / 10000ULL); + // The frame is late, catch up a little + auto next_PTS_practical = m_last_PTS_practical + std::chrono::milliseconds(minFrameDuration); + auto next_PTS = std::max(next_PTS_expected, next_PTS_practical); + if(now < next_PTS) + std::this_thread::sleep_until(next_PTS); + else + next_PTS = now; + //auto text = wxString::Format(L"wxMediaCtrl3 pts diff %ld\n", std::chrono::duration_cast(next_PTS - next_PTS_expected).count()); + //OutputDebugString(text); + m_last_PTS = sample.decode_time; + m_last_PTS_expected = next_PTS_expected; + m_last_PTS_practical = next_PTS; + } else { + // Resync + m_last_PTS = sample.decode_time; + m_last_PTS_expected = now; + m_last_PTS_practical = now; + } + m_frame = bm; + } + CallAfter([this] { Refresh(); }); + } + } + if (tunnel) { + lk.unlock(); + Bambu_Close(tunnel); + Bambu_Destroy(tunnel); + tunnel = nullptr; + lk.lock(); + } + if (m_url == url) + m_error = error; + m_frame_size = wxDefaultSize; + m_video_size = wxDefaultSize; + NotifyStopped(); + } + +} + +void wxMediaCtrl3::NotifyStopped() +{ + m_state = wxMEDIASTATE_STOPPED; + wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); + event.SetId(GetId()); + event.SetEventObject(this); + wxPostEvent(this, event); +} diff --git a/src/slic3r/GUI/wxMediaCtrl3.h b/src/slic3r/GUI/wxMediaCtrl3.h new file mode 100644 index 0000000000..1d64955ffd --- /dev/null +++ b/src/slic3r/GUI/wxMediaCtrl3.h @@ -0,0 +1,85 @@ +// +// wxMediaCtrl3.h +// libslic3r_gui +// +// Created by cmguo on 2024/6/22. +// + +#ifndef wxMediaCtrl3_h +#define wxMediaCtrl3_h + +#include "wx/uri.h" +#include "wx/mediactrl.h" + +wxDECLARE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); + +void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, int height); + +#define BAMBU_DYNAMIC +#include +#include +#ifndef _WIN32 +#include +#endif +#include "Printer/BambuTunnel.h" + +class AVVideoDecoder; + +class wxMediaCtrl3 : public wxWindow, BambuLib +{ +public: + wxMediaCtrl3(wxWindow *parent); + + ~wxMediaCtrl3(); + + void Load(wxURI url); + + void Play(); + + void Stop(); + + void SetIdleImage(wxString const & image); + + wxMediaState GetState(); + + int GetLastError(); + + wxSize GetVideoSize(); + +protected: + DECLARE_EVENT_TABLE() + + void paintEvent(wxPaintEvent &evt); + + wxSize DoGetBestSize() const override; + + void DoSetSize(int x, int y, int width, int height, int sizeFlags) override; + + static void bambu_log(void *ctx, int level, tchar const *msg); + + void PlayThread(); + + void NotifyStopped(); + +private: + wxString m_idle_image; + wxMediaState m_state = wxMEDIASTATE_STOPPED; + int m_error = 0; + wxSize m_video_size = wxDefaultSize; + wxSize m_frame_size = wxDefaultSize; +#ifdef _WIN32 + wxBitmap m_frame; +#else + wxImage m_frame; +#endif + + std::shared_ptr m_url; + std::uint64_t m_last_PTS{0}; + std::chrono::system_clock::time_point m_last_PTS_expected; + std::chrono::system_clock::time_point m_last_PTS_practical; + std::mutex m_mutex; + std::condition_variable m_cond; + std::thread m_thread; +}; + +#endif /* wxMediaCtrl3_h */ diff --git a/src/slic3r/Utils/CrealityPrint.hpp b/src/slic3r/Utils/CrealityPrint.hpp index ddb2054420..3b5287f382 100644 --- a/src/slic3r/Utils/CrealityPrint.hpp +++ b/src/slic3r/Utils/CrealityPrint.hpp @@ -21,14 +21,14 @@ public: ~CrealityPrint() override = default; const char* get_name() const override; - virtual bool can_test() const { return true; }; + virtual bool can_test() const override { return true; }; std::string get_host() const override; bool has_auto_discovery() const override { return true; } wxString get_test_ok_msg() const override; wxString get_test_failed_msg(wxString& msg) const override; virtual bool test(wxString& curl_msg) const override; - PrintHostPostUploadActions get_post_upload_actions() const; + PrintHostPostUploadActions get_post_upload_actions() const override; bool upload(PrintHostUpload upload_data, ProgressFn prorgess_fn, ErrorFn error_fn, InfoFn info_fn) const override; bool supports_multi_color_print() const; std::string query_boxes_info() const; diff --git a/src/slic3r/Utils/ElegooLink.hpp b/src/slic3r/Utils/ElegooLink.hpp index eb1ca7ba26..a60d2de1b3 100644 --- a/src/slic3r/Utils/ElegooLink.hpp +++ b/src/slic3r/Utils/ElegooLink.hpp @@ -32,10 +32,10 @@ public: PrintHostPostUploadActions get_post_upload_actions() const override; protected: #ifdef WIN32 - virtual bool upload_inner_with_resolved_ip(PrintHostUpload upload_data, ProgressFn prorgess_fn, ErrorFn error_fn, InfoFn info_fn, const boost::asio::ip::address& resolved_addr) const; + virtual bool upload_inner_with_resolved_ip(PrintHostUpload upload_data, ProgressFn prorgess_fn, ErrorFn error_fn, InfoFn info_fn, const boost::asio::ip::address& resolved_addr) const override; #endif - virtual bool validate_version_text(const boost::optional &version_text) const; - virtual bool upload_inner_with_host(PrintHostUpload upload_data, ProgressFn prorgess_fn, ErrorFn error_fn, InfoFn info_fn) const; + virtual bool validate_version_text(const boost::optional &version_text) const override; + virtual bool upload_inner_with_host(PrintHostUpload upload_data, ProgressFn prorgess_fn, ErrorFn error_fn, InfoFn info_fn) const override; #ifdef WIN32 virtual bool test_with_resolved_ip(wxString& curl_msg) const override; diff --git a/src/slic3r/Utils/Obico.hpp b/src/slic3r/Utils/Obico.hpp index 9fd3d50f6b..f262d204bd 100644 --- a/src/slic3r/Utils/Obico.hpp +++ b/src/slic3r/Utils/Obico.hpp @@ -20,7 +20,7 @@ public: ~Obico() override = default; const char* get_name() const override; - virtual bool can_test() const { return true; }; + virtual bool can_test() const override { return true; }; bool has_auto_discovery() const override { return false; } bool is_cloud() const override { return true; } bool get_login_url(wxString& auth_url) const override; @@ -30,7 +30,7 @@ public: wxString get_test_failed_msg(wxString& msg) const override; virtual bool test(wxString& curl_msg) const override; bool get_printers(wxArrayString& printers) const override; - PrintHostPostUploadActions get_post_upload_actions() const; + PrintHostPostUploadActions get_post_upload_actions() const override; bool upload(PrintHostUpload upload_data, ProgressFn prorgess_fn, ErrorFn error_fn, InfoFn info_fn) const override; protected: diff --git a/src/slic3r/Utils/PresetUpdater.cpp b/src/slic3r/Utils/PresetUpdater.cpp index 06808e253d..56a2b66b49 100644 --- a/src/slic3r/Utils/PresetUpdater.cpp +++ b/src/slic3r/Utils/PresetUpdater.cpp @@ -1198,7 +1198,7 @@ Updates PresetUpdater::priv::get_config_updates(const Semver &old_slic3r_version version.config_version = cache_ver; version.comment = description; // Orca: update vendor.json - updates.updates.emplace_back(std::move(file_path), std::move(path_in_vendor.string()), std::move(version), vendor_name, changelog, "", force_update, false); + updates.updates.emplace_back(std::move(file_path), path_in_vendor.string(), std::move(version), vendor_name, changelog, "", force_update, false); //Orca: update vendor folder updates.updates.emplace_back(cache_profile_path / vendor_name, vendor_path / vendor_name, Version(), vendor_name, "", "", force_update, true); } else { diff --git a/src/slic3r/plugin/PluginFsUtils.cpp b/src/slic3r/plugin/PluginFsUtils.cpp index 8f93f7aaef..4d3758508e 100644 --- a/src/slic3r/plugin/PluginFsUtils.cpp +++ b/src/slic3r/plugin/PluginFsUtils.cpp @@ -631,7 +631,7 @@ void parse_metadata_rfc822(const std::string& content, bool is_ignored_plugin_directory(const boost::filesystem::path& path) { const std::string name = path.filename().string(); - return name.empty() || name[0] == '.' || name.rfind("__", 0) == 0 || name == PLUGIN_SUBSCRIBED_DIR; + return name.empty() || name[0] == '.' || name.rfind("__", 0) == 0 || name == PLUGIN_SUBSCRIBED_DIR || name == PLUGIN_DATA_DIR; } bool is_safe_relative_path(const boost::filesystem::path& path) diff --git a/src/slic3r/plugin/PluginFsUtils.hpp b/src/slic3r/plugin/PluginFsUtils.hpp index 7922946b1c..5f57dbf807 100644 --- a/src/slic3r/plugin/PluginFsUtils.hpp +++ b/src/slic3r/plugin/PluginFsUtils.hpp @@ -12,6 +12,7 @@ #include #define PLUGIN_SUBSCRIBED_DIR "_subscribed" +#define PLUGIN_DATA_DIR "plugin_data" namespace Slic3r { diff --git a/src/slic3r/plugin/PluginManager.cpp b/src/slic3r/plugin/PluginManager.cpp index 3587c0a824..2bae29611f 100644 --- a/src/slic3r/plugin/PluginManager.cpp +++ b/src/slic3r/plugin/PluginManager.cpp @@ -522,6 +522,38 @@ bool PluginManager::try_get_plugin_descriptor_for_capability(const std::string& return false; } +std::string PluginManager::get_storage_dir(const std::string& plugin_key) const +{ + namespace fs = boost::filesystem; + + PluginDescriptor descriptor; + if (!try_get_plugin_descriptor(plugin_key, descriptor)) + throw std::runtime_error("The current plugin is not registered"); + + const fs::path base_storage_dir = fs::path(get_orca_plugins_dir()) / PLUGIN_DATA_DIR; + + if (!descriptor.is_cloud_plugin()) { + const fs::path local_storage_dir = base_storage_dir / plugin_key; + fs::create_directories(local_storage_dir); + return local_storage_dir.string(); + } + + auto agent = m_cloud_service.get_cloud_agent(); + if (!agent) + throw std::runtime_error("Cloud plugin storage is unavailable before networking is initialized"); + + const std::string user_id = agent->get_user_id(); + if (user_id.empty()) + throw std::runtime_error("Cloud plugin storage is unavailable without a logged-in user"); + + if (!is_valid_plugin_id(plugin_key)) + throw std::runtime_error("The current cloud plugin key is not a valid folder name"); + + const fs::path cloud_storage_dir = base_storage_dir / PLUGIN_SUBSCRIBED_DIR / user_id / plugin_key; + fs::create_directories(cloud_storage_dir); + return cloud_storage_dir.string(); +} + // ── Capability instances ──────────────────────────────────────────────────────────────────── std::vector> PluginManager::get_plugin_capabilities(const std::string& plugin_key, diff --git a/src/slic3r/plugin/PluginManager.hpp b/src/slic3r/plugin/PluginManager.hpp index ddb0bf9dce..b5044d6dba 100644 --- a/src/slic3r/plugin/PluginManager.hpp +++ b/src/slic3r/plugin/PluginManager.hpp @@ -143,6 +143,10 @@ public: bool try_get_plugin_descriptor_for_capability(const std::string& capability_name, PluginCapabilityType type, PluginDescriptor& out) const; + // Per-plugin storage directory under orca_plugins/plugin_data, created if missing. Throws + // std::runtime_error if the plugin is unregistered, the key is invalid, or (cloud plugins) + // no user is logged in yet. + std::string get_storage_dir(const std::string& plugin_key) const; std::vector> get_plugin_capabilities( const std::string& plugin_key = "", // "" => all plugins diff --git a/src/slic3r/plugin/host/PluginHost.cpp b/src/slic3r/plugin/host/PluginHost.cpp index 524f07f12c..2830d6f276 100644 --- a/src/slic3r/plugin/host/PluginHost.cpp +++ b/src/slic3r/plugin/host/PluginHost.cpp @@ -1,9 +1,31 @@ #include "PluginHost.hpp" #include "PluginHostBindings.hpp" #include "PluginHostUi.hpp" +#include +#include + +#include namespace Slic3r { +namespace host_bindings { +void register_plugin(pybind11::module_& host) +{ + auto plugin_host = host.def_submodule("plugin", "Plugin host API"); + + plugin_host.def( + "storage", + []() -> std::string { + const std::string plugin_key = PluginAuditManager::instance().current_plugin(); + if (plugin_key.empty()) + throw std::runtime_error("plugin.storage() must be called from a plugin callback"); + + return PluginManager::instance().get_storage_dir(plugin_key); + }, + "Return the installed folder of the current plugin."); +} +} // namespace host_bindings + void PluginHost::RegisterBindings(pybind11::module_& module) { auto host = module.def_submodule("host", "Host application API"); @@ -15,6 +37,7 @@ void PluginHost::RegisterBindings(pybind11::module_& module) host_bindings::register_presets(host); host_bindings::register_model(host); host_bindings::register_app(host); + host_bindings::register_plugin(host); // UI: native dialogs and interactive HTML windows for plugins. PluginHostUi::RegisterBindings(host); diff --git a/src/slic3r/plugin/host/PluginHostBindings.hpp b/src/slic3r/plugin/host/PluginHostBindings.hpp index 0f206d5992..94601ad99c 100644 --- a/src/slic3r/plugin/host/PluginHostBindings.hpp +++ b/src/slic3r/plugin/host/PluginHostBindings.hpp @@ -12,5 +12,5 @@ void register_presets(pybind11::module_& host); // PluginHostPresets.cpp void register_model(pybind11::module_& host); // PluginHostModel.cpp void register_app(pybind11::module_& host); // PluginHostApp.cpp void register_slicing(pybind11::module_& host); // PluginHostSlicing.cpp - +void register_plugin(pybind11::module_& host); // PluginHost.cpp } // namespace Slic3r::host_bindings diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3cbdc25f5f..c403152c4e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -30,25 +30,47 @@ if (APPLE) target_link_libraries(test_common INTERFACE "-liconv -framework IOKit" "-framework CoreFoundation" -lc++) endif() -# Copies runtime DLLs next to each test executable. Handles both single-config -# generators (CMAKE_BUILD_TYPE set) and multi-config generators (Ninja -# Multi-Config, Visual Studio) where CMAKE_BUILD_TYPE is empty and DLLs must -# land in every per-config output directory. +# Copies runtime shared libraries next to each test executable. Handles both +# single-config generators (CMAKE_BUILD_TYPE set) and multi-config generators +# (Ninja Multi-Config, Visual Studio) where CMAKE_BUILD_TYPE is empty and DLLs +# must land in every per-config output directory. On Windows the loader finds +# DLLs in the executable's directory; the Linux branch below does the same for +# the deps-built FFmpeg libraries and adds an $ORIGIN rpath, since the ELF +# loader does not search the executable's directory and the CI unit-test runner +# only receives the tests artifact (no deps install). function(orcaslicer_copy_test_dlls) - if (NOT WIN32) - return() - endif() - set(_configs ${CMAKE_CONFIGURATION_TYPES}) - if (NOT _configs) - set(_configs "${CMAKE_BUILD_TYPE}") - endif() - foreach(_cfg IN LISTS _configs) - if (_cfg STREQUAL "Debug") - orcaslicer_copy_dlls(COPY_DLLS "Debug" "d" _unused_dlls) - else() - orcaslicer_copy_dlls(COPY_DLLS "${_cfg}" "" _unused_dlls) + if (WIN32) + set(_configs ${CMAKE_CONFIGURATION_TYPES}) + if (NOT _configs) + set(_configs "${CMAKE_BUILD_TYPE}") endif() - endforeach() + foreach(_cfg IN LISTS _configs) + if (_cfg STREQUAL "Debug") + orcaslicer_copy_dlls(COPY_DLLS "Debug" "d" _unused_dlls) + else() + orcaslicer_copy_dlls(COPY_DLLS "${_cfg}" "" _unused_dlls) + endif() + endforeach() + elseif (UNIX AND NOT APPLE) + # Only test executables that link libslic3r_gui pull in the FFmpeg + # shared libraries (src/slic3r/CMakeLists.txt links PkgConfig::LIBAV + # into it). Copy them next to the executable and give it an $ORIGIN + # rpath so the loader finds them when the tests run on the CI unit-test + # runner, which only receives this build/tests tree. + get_target_property(_linked_libs ${_TEST_NAME}_tests LINK_LIBRARIES) + if (NOT "libslic3r_gui" IN_LIST _linked_libs) + return() + endif() + + set_property(TARGET ${_TEST_NAME}_tests PROPERTY BUILD_RPATH "$ORIGIN") + set(_configs ${CMAKE_CONFIGURATION_TYPES}) + if (NOT _configs) + set(_configs "${CMAKE_BUILD_TYPE}") + endif() + foreach(_cfg IN LISTS _configs) + orcaslicer_copy_sos(${_TEST_NAME}_tests "${_cfg}" "" _unused_sos) + endforeach() + endif() endfunction() # Register Catch2 tags as CTest labels so `ctest -L`/`-LE` can filter by tag. diff --git a/tests/data/utf8_part_names.step b/tests/data/utf8_part_names.step new file mode 100644 index 0000000000..c6d41898ad --- /dev/null +++ b/tests/data/utf8_part_names.step @@ -0,0 +1,1207 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('Open CASCADE Model'),'2;1'); +FILE_NAME('Open CASCADE Shape Model','2026-08-27T11:50:16',('Author'),( + 'Open CASCADE'),'Open CASCADE STEP processor 7.6','Open CASCADE 7.6' + ,'Unknown'); +FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }')); +ENDSEC; +DATA; +#1 = APPLICATION_PROTOCOL_DEFINITION('international standard', + 'automotive_design',2000,#2); +#2 = APPLICATION_CONTEXT( + 'core data for automotive mechanical design processes'); +#3 = SHAPE_DEFINITION_REPRESENTATION(#4,#10); +#4 = PRODUCT_DEFINITION_SHAPE('','',#5); +#5 = PRODUCT_DEFINITION('design','',#6,#9); +#6 = PRODUCT_DEFINITION_FORMATION('','',#7); +#7 = PRODUCT('pièce','pièce','',(#8)); +#8 = PRODUCT_CONTEXT('',#2,'mechanical'); +#9 = PRODUCT_DEFINITION_CONTEXT('part definition',#2,'design'); +#10 = ADVANCED_BREP_SHAPE_REPRESENTATION('',(#11,#15),#345); +#11 = AXIS2_PLACEMENT_3D('',#12,#13,#14); +#12 = CARTESIAN_POINT('',(0.,0.,0.)); +#13 = DIRECTION('',(0.,0.,1.)); +#14 = DIRECTION('',(1.,0.,-0.)); +#15 = MANIFOLD_SOLID_BREP('',#16); +#16 = CLOSED_SHELL('',(#17,#137,#237,#284,#331,#338)); +#17 = ADVANCED_FACE('',(#18),#32,.F.); +#18 = FACE_BOUND('',#19,.F.); +#19 = EDGE_LOOP('',(#20,#55,#83,#111)); +#20 = ORIENTED_EDGE('',*,*,#21,.F.); +#21 = EDGE_CURVE('',#22,#24,#26,.T.); +#22 = VERTEX_POINT('',#23); +#23 = CARTESIAN_POINT('',(0.,0.,0.)); +#24 = VERTEX_POINT('',#25); +#25 = CARTESIAN_POINT('',(0.,0.,6.)); +#26 = SURFACE_CURVE('',#27,(#31,#43),.PCURVE_S1.); +#27 = LINE('',#28,#29); +#28 = CARTESIAN_POINT('',(0.,0.,0.)); +#29 = VECTOR('',#30,1.); +#30 = DIRECTION('',(0.,0.,1.)); +#31 = PCURVE('',#32,#37); +#32 = PLANE('',#33); +#33 = AXIS2_PLACEMENT_3D('',#34,#35,#36); +#34 = CARTESIAN_POINT('',(0.,0.,0.)); +#35 = DIRECTION('',(1.,0.,-0.)); +#36 = DIRECTION('',(0.,0.,1.)); +#37 = DEFINITIONAL_REPRESENTATION('',(#38),#42); +#38 = LINE('',#39,#40); +#39 = CARTESIAN_POINT('',(0.,0.)); +#40 = VECTOR('',#41,1.); +#41 = DIRECTION('',(1.,0.)); +#42 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#43 = PCURVE('',#44,#49); +#44 = PLANE('',#45); +#45 = AXIS2_PLACEMENT_3D('',#46,#47,#48); +#46 = CARTESIAN_POINT('',(0.,0.,0.)); +#47 = DIRECTION('',(-0.,1.,0.)); +#48 = DIRECTION('',(0.,0.,1.)); +#49 = DEFINITIONAL_REPRESENTATION('',(#50),#54); +#50 = LINE('',#51,#52); +#51 = CARTESIAN_POINT('',(0.,0.)); +#52 = VECTOR('',#53,1.); +#53 = DIRECTION('',(1.,0.)); +#54 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#55 = ORIENTED_EDGE('',*,*,#56,.T.); +#56 = EDGE_CURVE('',#22,#57,#59,.T.); +#57 = VERTEX_POINT('',#58); +#58 = CARTESIAN_POINT('',(0.,12.,0.)); +#59 = SURFACE_CURVE('',#60,(#64,#71),.PCURVE_S1.); +#60 = LINE('',#61,#62); +#61 = CARTESIAN_POINT('',(0.,0.,0.)); +#62 = VECTOR('',#63,1.); +#63 = DIRECTION('',(-0.,1.,0.)); +#64 = PCURVE('',#32,#65); +#65 = DEFINITIONAL_REPRESENTATION('',(#66),#70); +#66 = LINE('',#67,#68); +#67 = CARTESIAN_POINT('',(0.,0.)); +#68 = VECTOR('',#69,1.); +#69 = DIRECTION('',(0.,-1.)); +#70 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#71 = PCURVE('',#72,#77); +#72 = PLANE('',#73); +#73 = AXIS2_PLACEMENT_3D('',#74,#75,#76); +#74 = CARTESIAN_POINT('',(0.,0.,0.)); +#75 = DIRECTION('',(0.,0.,1.)); +#76 = DIRECTION('',(1.,0.,-0.)); +#77 = DEFINITIONAL_REPRESENTATION('',(#78),#82); +#78 = LINE('',#79,#80); +#79 = CARTESIAN_POINT('',(0.,0.)); +#80 = VECTOR('',#81,1.); +#81 = DIRECTION('',(0.,1.)); +#82 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#83 = ORIENTED_EDGE('',*,*,#84,.T.); +#84 = EDGE_CURVE('',#57,#85,#87,.T.); +#85 = VERTEX_POINT('',#86); +#86 = CARTESIAN_POINT('',(0.,12.,6.)); +#87 = SURFACE_CURVE('',#88,(#92,#99),.PCURVE_S1.); +#88 = LINE('',#89,#90); +#89 = CARTESIAN_POINT('',(0.,12.,0.)); +#90 = VECTOR('',#91,1.); +#91 = DIRECTION('',(0.,0.,1.)); +#92 = PCURVE('',#32,#93); +#93 = DEFINITIONAL_REPRESENTATION('',(#94),#98); +#94 = LINE('',#95,#96); +#95 = CARTESIAN_POINT('',(0.,-12.)); +#96 = VECTOR('',#97,1.); +#97 = DIRECTION('',(1.,0.)); +#98 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#99 = PCURVE('',#100,#105); +#100 = PLANE('',#101); +#101 = AXIS2_PLACEMENT_3D('',#102,#103,#104); +#102 = CARTESIAN_POINT('',(0.,12.,0.)); +#103 = DIRECTION('',(-0.,1.,0.)); +#104 = DIRECTION('',(0.,0.,1.)); +#105 = DEFINITIONAL_REPRESENTATION('',(#106),#110); +#106 = LINE('',#107,#108); +#107 = CARTESIAN_POINT('',(0.,0.)); +#108 = VECTOR('',#109,1.); +#109 = DIRECTION('',(1.,0.)); +#110 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#111 = ORIENTED_EDGE('',*,*,#112,.F.); +#112 = EDGE_CURVE('',#24,#85,#113,.T.); +#113 = SURFACE_CURVE('',#114,(#118,#125),.PCURVE_S1.); +#114 = LINE('',#115,#116); +#115 = CARTESIAN_POINT('',(0.,0.,6.)); +#116 = VECTOR('',#117,1.); +#117 = DIRECTION('',(-0.,1.,0.)); +#118 = PCURVE('',#32,#119); +#119 = DEFINITIONAL_REPRESENTATION('',(#120),#124); +#120 = LINE('',#121,#122); +#121 = CARTESIAN_POINT('',(6.,0.)); +#122 = VECTOR('',#123,1.); +#123 = DIRECTION('',(0.,-1.)); +#124 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#125 = PCURVE('',#126,#131); +#126 = PLANE('',#127); +#127 = AXIS2_PLACEMENT_3D('',#128,#129,#130); +#128 = CARTESIAN_POINT('',(0.,0.,6.)); +#129 = DIRECTION('',(0.,0.,1.)); +#130 = DIRECTION('',(1.,0.,-0.)); +#131 = DEFINITIONAL_REPRESENTATION('',(#132),#136); +#132 = LINE('',#133,#134); +#133 = CARTESIAN_POINT('',(0.,0.)); +#134 = VECTOR('',#135,1.); +#135 = DIRECTION('',(0.,1.)); +#136 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#137 = ADVANCED_FACE('',(#138),#152,.T.); +#138 = FACE_BOUND('',#139,.T.); +#139 = EDGE_LOOP('',(#140,#170,#193,#216)); +#140 = ORIENTED_EDGE('',*,*,#141,.F.); +#141 = EDGE_CURVE('',#142,#144,#146,.T.); +#142 = VERTEX_POINT('',#143); +#143 = CARTESIAN_POINT('',(20.,0.,0.)); +#144 = VERTEX_POINT('',#145); +#145 = CARTESIAN_POINT('',(20.,0.,6.)); +#146 = SURFACE_CURVE('',#147,(#151,#163),.PCURVE_S1.); +#147 = LINE('',#148,#149); +#148 = CARTESIAN_POINT('',(20.,0.,0.)); +#149 = VECTOR('',#150,1.); +#150 = DIRECTION('',(0.,0.,1.)); +#151 = PCURVE('',#152,#157); +#152 = PLANE('',#153); +#153 = AXIS2_PLACEMENT_3D('',#154,#155,#156); +#154 = CARTESIAN_POINT('',(20.,0.,0.)); +#155 = DIRECTION('',(1.,0.,-0.)); +#156 = DIRECTION('',(0.,0.,1.)); +#157 = DEFINITIONAL_REPRESENTATION('',(#158),#162); +#158 = LINE('',#159,#160); +#159 = CARTESIAN_POINT('',(0.,0.)); +#160 = VECTOR('',#161,1.); +#161 = DIRECTION('',(1.,0.)); +#162 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#163 = PCURVE('',#44,#164); +#164 = DEFINITIONAL_REPRESENTATION('',(#165),#169); +#165 = LINE('',#166,#167); +#166 = CARTESIAN_POINT('',(0.,20.)); +#167 = VECTOR('',#168,1.); +#168 = DIRECTION('',(1.,0.)); +#169 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#170 = ORIENTED_EDGE('',*,*,#171,.T.); +#171 = EDGE_CURVE('',#142,#172,#174,.T.); +#172 = VERTEX_POINT('',#173); +#173 = CARTESIAN_POINT('',(20.,12.,0.)); +#174 = SURFACE_CURVE('',#175,(#179,#186),.PCURVE_S1.); +#175 = LINE('',#176,#177); +#176 = CARTESIAN_POINT('',(20.,0.,0.)); +#177 = VECTOR('',#178,1.); +#178 = DIRECTION('',(-0.,1.,0.)); +#179 = PCURVE('',#152,#180); +#180 = DEFINITIONAL_REPRESENTATION('',(#181),#185); +#181 = LINE('',#182,#183); +#182 = CARTESIAN_POINT('',(0.,0.)); +#183 = VECTOR('',#184,1.); +#184 = DIRECTION('',(0.,-1.)); +#185 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#186 = PCURVE('',#72,#187); +#187 = DEFINITIONAL_REPRESENTATION('',(#188),#192); +#188 = LINE('',#189,#190); +#189 = CARTESIAN_POINT('',(20.,0.)); +#190 = VECTOR('',#191,1.); +#191 = DIRECTION('',(0.,1.)); +#192 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#193 = ORIENTED_EDGE('',*,*,#194,.T.); +#194 = EDGE_CURVE('',#172,#195,#197,.T.); +#195 = VERTEX_POINT('',#196); +#196 = CARTESIAN_POINT('',(20.,12.,6.)); +#197 = SURFACE_CURVE('',#198,(#202,#209),.PCURVE_S1.); +#198 = LINE('',#199,#200); +#199 = CARTESIAN_POINT('',(20.,12.,0.)); +#200 = VECTOR('',#201,1.); +#201 = DIRECTION('',(0.,0.,1.)); +#202 = PCURVE('',#152,#203); +#203 = DEFINITIONAL_REPRESENTATION('',(#204),#208); +#204 = LINE('',#205,#206); +#205 = CARTESIAN_POINT('',(0.,-12.)); +#206 = VECTOR('',#207,1.); +#207 = DIRECTION('',(1.,0.)); +#208 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#209 = PCURVE('',#100,#210); +#210 = DEFINITIONAL_REPRESENTATION('',(#211),#215); +#211 = LINE('',#212,#213); +#212 = CARTESIAN_POINT('',(0.,20.)); +#213 = VECTOR('',#214,1.); +#214 = DIRECTION('',(1.,0.)); +#215 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#216 = ORIENTED_EDGE('',*,*,#217,.F.); +#217 = EDGE_CURVE('',#144,#195,#218,.T.); +#218 = SURFACE_CURVE('',#219,(#223,#230),.PCURVE_S1.); +#219 = LINE('',#220,#221); +#220 = CARTESIAN_POINT('',(20.,0.,6.)); +#221 = VECTOR('',#222,1.); +#222 = DIRECTION('',(-0.,1.,0.)); +#223 = PCURVE('',#152,#224); +#224 = DEFINITIONAL_REPRESENTATION('',(#225),#229); +#225 = LINE('',#226,#227); +#226 = CARTESIAN_POINT('',(6.,0.)); +#227 = VECTOR('',#228,1.); +#228 = DIRECTION('',(0.,-1.)); +#229 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#230 = PCURVE('',#126,#231); +#231 = DEFINITIONAL_REPRESENTATION('',(#232),#236); +#232 = LINE('',#233,#234); +#233 = CARTESIAN_POINT('',(20.,0.)); +#234 = VECTOR('',#235,1.); +#235 = DIRECTION('',(0.,1.)); +#236 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#237 = ADVANCED_FACE('',(#238),#44,.F.); +#238 = FACE_BOUND('',#239,.F.); +#239 = EDGE_LOOP('',(#240,#261,#262,#283)); +#240 = ORIENTED_EDGE('',*,*,#241,.F.); +#241 = EDGE_CURVE('',#22,#142,#242,.T.); +#242 = SURFACE_CURVE('',#243,(#247,#254),.PCURVE_S1.); +#243 = LINE('',#244,#245); +#244 = CARTESIAN_POINT('',(0.,0.,0.)); +#245 = VECTOR('',#246,1.); +#246 = DIRECTION('',(1.,0.,-0.)); +#247 = PCURVE('',#44,#248); +#248 = DEFINITIONAL_REPRESENTATION('',(#249),#253); +#249 = LINE('',#250,#251); +#250 = CARTESIAN_POINT('',(0.,0.)); +#251 = VECTOR('',#252,1.); +#252 = DIRECTION('',(0.,1.)); +#253 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#254 = PCURVE('',#72,#255); +#255 = DEFINITIONAL_REPRESENTATION('',(#256),#260); +#256 = LINE('',#257,#258); +#257 = CARTESIAN_POINT('',(0.,0.)); +#258 = VECTOR('',#259,1.); +#259 = DIRECTION('',(1.,0.)); +#260 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#261 = ORIENTED_EDGE('',*,*,#21,.T.); +#262 = ORIENTED_EDGE('',*,*,#263,.T.); +#263 = EDGE_CURVE('',#24,#144,#264,.T.); +#264 = SURFACE_CURVE('',#265,(#269,#276),.PCURVE_S1.); +#265 = LINE('',#266,#267); +#266 = CARTESIAN_POINT('',(0.,0.,6.)); +#267 = VECTOR('',#268,1.); +#268 = DIRECTION('',(1.,0.,-0.)); +#269 = PCURVE('',#44,#270); +#270 = DEFINITIONAL_REPRESENTATION('',(#271),#275); +#271 = LINE('',#272,#273); +#272 = CARTESIAN_POINT('',(6.,0.)); +#273 = VECTOR('',#274,1.); +#274 = DIRECTION('',(0.,1.)); +#275 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#276 = PCURVE('',#126,#277); +#277 = DEFINITIONAL_REPRESENTATION('',(#278),#282); +#278 = LINE('',#279,#280); +#279 = CARTESIAN_POINT('',(0.,0.)); +#280 = VECTOR('',#281,1.); +#281 = DIRECTION('',(1.,0.)); +#282 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#283 = ORIENTED_EDGE('',*,*,#141,.F.); +#284 = ADVANCED_FACE('',(#285),#100,.T.); +#285 = FACE_BOUND('',#286,.T.); +#286 = EDGE_LOOP('',(#287,#308,#309,#330)); +#287 = ORIENTED_EDGE('',*,*,#288,.F.); +#288 = EDGE_CURVE('',#57,#172,#289,.T.); +#289 = SURFACE_CURVE('',#290,(#294,#301),.PCURVE_S1.); +#290 = LINE('',#291,#292); +#291 = CARTESIAN_POINT('',(0.,12.,0.)); +#292 = VECTOR('',#293,1.); +#293 = DIRECTION('',(1.,0.,-0.)); +#294 = PCURVE('',#100,#295); +#295 = DEFINITIONAL_REPRESENTATION('',(#296),#300); +#296 = LINE('',#297,#298); +#297 = CARTESIAN_POINT('',(0.,0.)); +#298 = VECTOR('',#299,1.); +#299 = DIRECTION('',(0.,1.)); +#300 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#301 = PCURVE('',#72,#302); +#302 = DEFINITIONAL_REPRESENTATION('',(#303),#307); +#303 = LINE('',#304,#305); +#304 = CARTESIAN_POINT('',(0.,12.)); +#305 = VECTOR('',#306,1.); +#306 = DIRECTION('',(1.,0.)); +#307 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#308 = ORIENTED_EDGE('',*,*,#84,.T.); +#309 = ORIENTED_EDGE('',*,*,#310,.T.); +#310 = EDGE_CURVE('',#85,#195,#311,.T.); +#311 = SURFACE_CURVE('',#312,(#316,#323),.PCURVE_S1.); +#312 = LINE('',#313,#314); +#313 = CARTESIAN_POINT('',(0.,12.,6.)); +#314 = VECTOR('',#315,1.); +#315 = DIRECTION('',(1.,0.,-0.)); +#316 = PCURVE('',#100,#317); +#317 = DEFINITIONAL_REPRESENTATION('',(#318),#322); +#318 = LINE('',#319,#320); +#319 = CARTESIAN_POINT('',(6.,0.)); +#320 = VECTOR('',#321,1.); +#321 = DIRECTION('',(0.,1.)); +#322 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#323 = PCURVE('',#126,#324); +#324 = DEFINITIONAL_REPRESENTATION('',(#325),#329); +#325 = LINE('',#326,#327); +#326 = CARTESIAN_POINT('',(0.,12.)); +#327 = VECTOR('',#328,1.); +#328 = DIRECTION('',(1.,0.)); +#329 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#330 = ORIENTED_EDGE('',*,*,#194,.F.); +#331 = ADVANCED_FACE('',(#332),#72,.F.); +#332 = FACE_BOUND('',#333,.F.); +#333 = EDGE_LOOP('',(#334,#335,#336,#337)); +#334 = ORIENTED_EDGE('',*,*,#56,.F.); +#335 = ORIENTED_EDGE('',*,*,#241,.T.); +#336 = ORIENTED_EDGE('',*,*,#171,.T.); +#337 = ORIENTED_EDGE('',*,*,#288,.F.); +#338 = ADVANCED_FACE('',(#339),#126,.T.); +#339 = FACE_BOUND('',#340,.T.); +#340 = EDGE_LOOP('',(#341,#342,#343,#344)); +#341 = ORIENTED_EDGE('',*,*,#112,.F.); +#342 = ORIENTED_EDGE('',*,*,#263,.T.); +#343 = ORIENTED_EDGE('',*,*,#217,.T.); +#344 = ORIENTED_EDGE('',*,*,#310,.F.); +#345 = ( GEOMETRIC_REPRESENTATION_CONTEXT(3) +GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#349)) GLOBAL_UNIT_ASSIGNED_CONTEXT +((#346,#347,#348)) REPRESENTATION_CONTEXT('Context #1', + '3D Context with UNIT and UNCERTAINTY') ); +#346 = ( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) ); +#347 = ( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) ); +#348 = ( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() ); +#349 = UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#346, + 'distance_accuracy_value','confusion accuracy'); +#350 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#7)); +#351 = SHAPE_DEFINITION_REPRESENTATION(#352,#358); +#352 = PRODUCT_DEFINITION_SHAPE('','',#353); +#353 = PRODUCT_DEFINITION('design','',#354,#357); +#354 = PRODUCT_DEFINITION_FORMATION('','',#355); +#355 = PRODUCT('Gehäuse','Gehäuse','',(#356)); +#356 = PRODUCT_CONTEXT('',#2,'mechanical'); +#357 = PRODUCT_DEFINITION_CONTEXT('part definition',#2,'design'); +#358 = ADVANCED_BREP_SHAPE_REPRESENTATION('',(#11,#359),#689); +#359 = MANIFOLD_SOLID_BREP('',#360); +#360 = CLOSED_SHELL('',(#361,#481,#581,#628,#675,#682)); +#361 = ADVANCED_FACE('',(#362),#376,.F.); +#362 = FACE_BOUND('',#363,.F.); +#363 = EDGE_LOOP('',(#364,#399,#427,#455)); +#364 = ORIENTED_EDGE('',*,*,#365,.F.); +#365 = EDGE_CURVE('',#366,#368,#370,.T.); +#366 = VERTEX_POINT('',#367); +#367 = CARTESIAN_POINT('',(25.,0.,0.)); +#368 = VERTEX_POINT('',#369); +#369 = CARTESIAN_POINT('',(25.,0.,6.)); +#370 = SURFACE_CURVE('',#371,(#375,#387),.PCURVE_S1.); +#371 = LINE('',#372,#373); +#372 = CARTESIAN_POINT('',(25.,0.,0.)); +#373 = VECTOR('',#374,1.); +#374 = DIRECTION('',(0.,0.,1.)); +#375 = PCURVE('',#376,#381); +#376 = PLANE('',#377); +#377 = AXIS2_PLACEMENT_3D('',#378,#379,#380); +#378 = CARTESIAN_POINT('',(25.,0.,0.)); +#379 = DIRECTION('',(1.,0.,-0.)); +#380 = DIRECTION('',(0.,0.,1.)); +#381 = DEFINITIONAL_REPRESENTATION('',(#382),#386); +#382 = LINE('',#383,#384); +#383 = CARTESIAN_POINT('',(0.,0.)); +#384 = VECTOR('',#385,1.); +#385 = DIRECTION('',(1.,0.)); +#386 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#387 = PCURVE('',#388,#393); +#388 = PLANE('',#389); +#389 = AXIS2_PLACEMENT_3D('',#390,#391,#392); +#390 = CARTESIAN_POINT('',(25.,0.,0.)); +#391 = DIRECTION('',(-0.,1.,0.)); +#392 = DIRECTION('',(0.,0.,1.)); +#393 = DEFINITIONAL_REPRESENTATION('',(#394),#398); +#394 = LINE('',#395,#396); +#395 = CARTESIAN_POINT('',(0.,0.)); +#396 = VECTOR('',#397,1.); +#397 = DIRECTION('',(1.,0.)); +#398 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#399 = ORIENTED_EDGE('',*,*,#400,.T.); +#400 = EDGE_CURVE('',#366,#401,#403,.T.); +#401 = VERTEX_POINT('',#402); +#402 = CARTESIAN_POINT('',(25.,12.,0.)); +#403 = SURFACE_CURVE('',#404,(#408,#415),.PCURVE_S1.); +#404 = LINE('',#405,#406); +#405 = CARTESIAN_POINT('',(25.,0.,0.)); +#406 = VECTOR('',#407,1.); +#407 = DIRECTION('',(-0.,1.,0.)); +#408 = PCURVE('',#376,#409); +#409 = DEFINITIONAL_REPRESENTATION('',(#410),#414); +#410 = LINE('',#411,#412); +#411 = CARTESIAN_POINT('',(0.,0.)); +#412 = VECTOR('',#413,1.); +#413 = DIRECTION('',(0.,-1.)); +#414 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#415 = PCURVE('',#416,#421); +#416 = PLANE('',#417); +#417 = AXIS2_PLACEMENT_3D('',#418,#419,#420); +#418 = CARTESIAN_POINT('',(25.,0.,0.)); +#419 = DIRECTION('',(0.,0.,1.)); +#420 = DIRECTION('',(1.,0.,-0.)); +#421 = DEFINITIONAL_REPRESENTATION('',(#422),#426); +#422 = LINE('',#423,#424); +#423 = CARTESIAN_POINT('',(0.,0.)); +#424 = VECTOR('',#425,1.); +#425 = DIRECTION('',(0.,1.)); +#426 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#427 = ORIENTED_EDGE('',*,*,#428,.T.); +#428 = EDGE_CURVE('',#401,#429,#431,.T.); +#429 = VERTEX_POINT('',#430); +#430 = CARTESIAN_POINT('',(25.,12.,6.)); +#431 = SURFACE_CURVE('',#432,(#436,#443),.PCURVE_S1.); +#432 = LINE('',#433,#434); +#433 = CARTESIAN_POINT('',(25.,12.,0.)); +#434 = VECTOR('',#435,1.); +#435 = DIRECTION('',(0.,0.,1.)); +#436 = PCURVE('',#376,#437); +#437 = DEFINITIONAL_REPRESENTATION('',(#438),#442); +#438 = LINE('',#439,#440); +#439 = CARTESIAN_POINT('',(0.,-12.)); +#440 = VECTOR('',#441,1.); +#441 = DIRECTION('',(1.,0.)); +#442 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#443 = PCURVE('',#444,#449); +#444 = PLANE('',#445); +#445 = AXIS2_PLACEMENT_3D('',#446,#447,#448); +#446 = CARTESIAN_POINT('',(25.,12.,0.)); +#447 = DIRECTION('',(-0.,1.,0.)); +#448 = DIRECTION('',(0.,0.,1.)); +#449 = DEFINITIONAL_REPRESENTATION('',(#450),#454); +#450 = LINE('',#451,#452); +#451 = CARTESIAN_POINT('',(0.,0.)); +#452 = VECTOR('',#453,1.); +#453 = DIRECTION('',(1.,0.)); +#454 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#455 = ORIENTED_EDGE('',*,*,#456,.F.); +#456 = EDGE_CURVE('',#368,#429,#457,.T.); +#457 = SURFACE_CURVE('',#458,(#462,#469),.PCURVE_S1.); +#458 = LINE('',#459,#460); +#459 = CARTESIAN_POINT('',(25.,0.,6.)); +#460 = VECTOR('',#461,1.); +#461 = DIRECTION('',(-0.,1.,0.)); +#462 = PCURVE('',#376,#463); +#463 = DEFINITIONAL_REPRESENTATION('',(#464),#468); +#464 = LINE('',#465,#466); +#465 = CARTESIAN_POINT('',(6.,0.)); +#466 = VECTOR('',#467,1.); +#467 = DIRECTION('',(0.,-1.)); +#468 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#469 = PCURVE('',#470,#475); +#470 = PLANE('',#471); +#471 = AXIS2_PLACEMENT_3D('',#472,#473,#474); +#472 = CARTESIAN_POINT('',(25.,0.,6.)); +#473 = DIRECTION('',(0.,0.,1.)); +#474 = DIRECTION('',(1.,0.,-0.)); +#475 = DEFINITIONAL_REPRESENTATION('',(#476),#480); +#476 = LINE('',#477,#478); +#477 = CARTESIAN_POINT('',(0.,0.)); +#478 = VECTOR('',#479,1.); +#479 = DIRECTION('',(0.,1.)); +#480 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#481 = ADVANCED_FACE('',(#482),#496,.T.); +#482 = FACE_BOUND('',#483,.T.); +#483 = EDGE_LOOP('',(#484,#514,#537,#560)); +#484 = ORIENTED_EDGE('',*,*,#485,.F.); +#485 = EDGE_CURVE('',#486,#488,#490,.T.); +#486 = VERTEX_POINT('',#487); +#487 = CARTESIAN_POINT('',(41.,0.,0.)); +#488 = VERTEX_POINT('',#489); +#489 = CARTESIAN_POINT('',(41.,0.,6.)); +#490 = SURFACE_CURVE('',#491,(#495,#507),.PCURVE_S1.); +#491 = LINE('',#492,#493); +#492 = CARTESIAN_POINT('',(41.,0.,0.)); +#493 = VECTOR('',#494,1.); +#494 = DIRECTION('',(0.,0.,1.)); +#495 = PCURVE('',#496,#501); +#496 = PLANE('',#497); +#497 = AXIS2_PLACEMENT_3D('',#498,#499,#500); +#498 = CARTESIAN_POINT('',(41.,0.,0.)); +#499 = DIRECTION('',(1.,0.,-0.)); +#500 = DIRECTION('',(0.,0.,1.)); +#501 = DEFINITIONAL_REPRESENTATION('',(#502),#506); +#502 = LINE('',#503,#504); +#503 = CARTESIAN_POINT('',(0.,0.)); +#504 = VECTOR('',#505,1.); +#505 = DIRECTION('',(1.,0.)); +#506 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#507 = PCURVE('',#388,#508); +#508 = DEFINITIONAL_REPRESENTATION('',(#509),#513); +#509 = LINE('',#510,#511); +#510 = CARTESIAN_POINT('',(0.,16.)); +#511 = VECTOR('',#512,1.); +#512 = DIRECTION('',(1.,0.)); +#513 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#514 = ORIENTED_EDGE('',*,*,#515,.T.); +#515 = EDGE_CURVE('',#486,#516,#518,.T.); +#516 = VERTEX_POINT('',#517); +#517 = CARTESIAN_POINT('',(41.,12.,0.)); +#518 = SURFACE_CURVE('',#519,(#523,#530),.PCURVE_S1.); +#519 = LINE('',#520,#521); +#520 = CARTESIAN_POINT('',(41.,0.,0.)); +#521 = VECTOR('',#522,1.); +#522 = DIRECTION('',(-0.,1.,0.)); +#523 = PCURVE('',#496,#524); +#524 = DEFINITIONAL_REPRESENTATION('',(#525),#529); +#525 = LINE('',#526,#527); +#526 = CARTESIAN_POINT('',(0.,0.)); +#527 = VECTOR('',#528,1.); +#528 = DIRECTION('',(0.,-1.)); +#529 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#530 = PCURVE('',#416,#531); +#531 = DEFINITIONAL_REPRESENTATION('',(#532),#536); +#532 = LINE('',#533,#534); +#533 = CARTESIAN_POINT('',(16.,0.)); +#534 = VECTOR('',#535,1.); +#535 = DIRECTION('',(0.,1.)); +#536 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#537 = ORIENTED_EDGE('',*,*,#538,.T.); +#538 = EDGE_CURVE('',#516,#539,#541,.T.); +#539 = VERTEX_POINT('',#540); +#540 = CARTESIAN_POINT('',(41.,12.,6.)); +#541 = SURFACE_CURVE('',#542,(#546,#553),.PCURVE_S1.); +#542 = LINE('',#543,#544); +#543 = CARTESIAN_POINT('',(41.,12.,0.)); +#544 = VECTOR('',#545,1.); +#545 = DIRECTION('',(0.,0.,1.)); +#546 = PCURVE('',#496,#547); +#547 = DEFINITIONAL_REPRESENTATION('',(#548),#552); +#548 = LINE('',#549,#550); +#549 = CARTESIAN_POINT('',(0.,-12.)); +#550 = VECTOR('',#551,1.); +#551 = DIRECTION('',(1.,0.)); +#552 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#553 = PCURVE('',#444,#554); +#554 = DEFINITIONAL_REPRESENTATION('',(#555),#559); +#555 = LINE('',#556,#557); +#556 = CARTESIAN_POINT('',(0.,16.)); +#557 = VECTOR('',#558,1.); +#558 = DIRECTION('',(1.,0.)); +#559 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#560 = ORIENTED_EDGE('',*,*,#561,.F.); +#561 = EDGE_CURVE('',#488,#539,#562,.T.); +#562 = SURFACE_CURVE('',#563,(#567,#574),.PCURVE_S1.); +#563 = LINE('',#564,#565); +#564 = CARTESIAN_POINT('',(41.,0.,6.)); +#565 = VECTOR('',#566,1.); +#566 = DIRECTION('',(-0.,1.,0.)); +#567 = PCURVE('',#496,#568); +#568 = DEFINITIONAL_REPRESENTATION('',(#569),#573); +#569 = LINE('',#570,#571); +#570 = CARTESIAN_POINT('',(6.,0.)); +#571 = VECTOR('',#572,1.); +#572 = DIRECTION('',(0.,-1.)); +#573 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#574 = PCURVE('',#470,#575); +#575 = DEFINITIONAL_REPRESENTATION('',(#576),#580); +#576 = LINE('',#577,#578); +#577 = CARTESIAN_POINT('',(16.,0.)); +#578 = VECTOR('',#579,1.); +#579 = DIRECTION('',(0.,1.)); +#580 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#581 = ADVANCED_FACE('',(#582),#388,.F.); +#582 = FACE_BOUND('',#583,.F.); +#583 = EDGE_LOOP('',(#584,#605,#606,#627)); +#584 = ORIENTED_EDGE('',*,*,#585,.F.); +#585 = EDGE_CURVE('',#366,#486,#586,.T.); +#586 = SURFACE_CURVE('',#587,(#591,#598),.PCURVE_S1.); +#587 = LINE('',#588,#589); +#588 = CARTESIAN_POINT('',(25.,0.,0.)); +#589 = VECTOR('',#590,1.); +#590 = DIRECTION('',(1.,0.,-0.)); +#591 = PCURVE('',#388,#592); +#592 = DEFINITIONAL_REPRESENTATION('',(#593),#597); +#593 = LINE('',#594,#595); +#594 = CARTESIAN_POINT('',(0.,0.)); +#595 = VECTOR('',#596,1.); +#596 = DIRECTION('',(0.,1.)); +#597 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#598 = PCURVE('',#416,#599); +#599 = DEFINITIONAL_REPRESENTATION('',(#600),#604); +#600 = LINE('',#601,#602); +#601 = CARTESIAN_POINT('',(0.,0.)); +#602 = VECTOR('',#603,1.); +#603 = DIRECTION('',(1.,0.)); +#604 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#605 = ORIENTED_EDGE('',*,*,#365,.T.); +#606 = ORIENTED_EDGE('',*,*,#607,.T.); +#607 = EDGE_CURVE('',#368,#488,#608,.T.); +#608 = SURFACE_CURVE('',#609,(#613,#620),.PCURVE_S1.); +#609 = LINE('',#610,#611); +#610 = CARTESIAN_POINT('',(25.,0.,6.)); +#611 = VECTOR('',#612,1.); +#612 = DIRECTION('',(1.,0.,-0.)); +#613 = PCURVE('',#388,#614); +#614 = DEFINITIONAL_REPRESENTATION('',(#615),#619); +#615 = LINE('',#616,#617); +#616 = CARTESIAN_POINT('',(6.,0.)); +#617 = VECTOR('',#618,1.); +#618 = DIRECTION('',(0.,1.)); +#619 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#620 = PCURVE('',#470,#621); +#621 = DEFINITIONAL_REPRESENTATION('',(#622),#626); +#622 = LINE('',#623,#624); +#623 = CARTESIAN_POINT('',(0.,0.)); +#624 = VECTOR('',#625,1.); +#625 = DIRECTION('',(1.,0.)); +#626 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#627 = ORIENTED_EDGE('',*,*,#485,.F.); +#628 = ADVANCED_FACE('',(#629),#444,.T.); +#629 = FACE_BOUND('',#630,.T.); +#630 = EDGE_LOOP('',(#631,#652,#653,#674)); +#631 = ORIENTED_EDGE('',*,*,#632,.F.); +#632 = EDGE_CURVE('',#401,#516,#633,.T.); +#633 = SURFACE_CURVE('',#634,(#638,#645),.PCURVE_S1.); +#634 = LINE('',#635,#636); +#635 = CARTESIAN_POINT('',(25.,12.,0.)); +#636 = VECTOR('',#637,1.); +#637 = DIRECTION('',(1.,0.,-0.)); +#638 = PCURVE('',#444,#639); +#639 = DEFINITIONAL_REPRESENTATION('',(#640),#644); +#640 = LINE('',#641,#642); +#641 = CARTESIAN_POINT('',(0.,0.)); +#642 = VECTOR('',#643,1.); +#643 = DIRECTION('',(0.,1.)); +#644 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#645 = PCURVE('',#416,#646); +#646 = DEFINITIONAL_REPRESENTATION('',(#647),#651); +#647 = LINE('',#648,#649); +#648 = CARTESIAN_POINT('',(0.,12.)); +#649 = VECTOR('',#650,1.); +#650 = DIRECTION('',(1.,0.)); +#651 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#652 = ORIENTED_EDGE('',*,*,#428,.T.); +#653 = ORIENTED_EDGE('',*,*,#654,.T.); +#654 = EDGE_CURVE('',#429,#539,#655,.T.); +#655 = SURFACE_CURVE('',#656,(#660,#667),.PCURVE_S1.); +#656 = LINE('',#657,#658); +#657 = CARTESIAN_POINT('',(25.,12.,6.)); +#658 = VECTOR('',#659,1.); +#659 = DIRECTION('',(1.,0.,-0.)); +#660 = PCURVE('',#444,#661); +#661 = DEFINITIONAL_REPRESENTATION('',(#662),#666); +#662 = LINE('',#663,#664); +#663 = CARTESIAN_POINT('',(6.,0.)); +#664 = VECTOR('',#665,1.); +#665 = DIRECTION('',(0.,1.)); +#666 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#667 = PCURVE('',#470,#668); +#668 = DEFINITIONAL_REPRESENTATION('',(#669),#673); +#669 = LINE('',#670,#671); +#670 = CARTESIAN_POINT('',(0.,12.)); +#671 = VECTOR('',#672,1.); +#672 = DIRECTION('',(1.,0.)); +#673 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#674 = ORIENTED_EDGE('',*,*,#538,.F.); +#675 = ADVANCED_FACE('',(#676),#416,.F.); +#676 = FACE_BOUND('',#677,.F.); +#677 = EDGE_LOOP('',(#678,#679,#680,#681)); +#678 = ORIENTED_EDGE('',*,*,#400,.F.); +#679 = ORIENTED_EDGE('',*,*,#585,.T.); +#680 = ORIENTED_EDGE('',*,*,#515,.T.); +#681 = ORIENTED_EDGE('',*,*,#632,.F.); +#682 = ADVANCED_FACE('',(#683),#470,.T.); +#683 = FACE_BOUND('',#684,.T.); +#684 = EDGE_LOOP('',(#685,#686,#687,#688)); +#685 = ORIENTED_EDGE('',*,*,#456,.F.); +#686 = ORIENTED_EDGE('',*,*,#607,.T.); +#687 = ORIENTED_EDGE('',*,*,#561,.T.); +#688 = ORIENTED_EDGE('',*,*,#654,.F.); +#689 = ( GEOMETRIC_REPRESENTATION_CONTEXT(3) +GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#693)) GLOBAL_UNIT_ASSIGNED_CONTEXT +((#690,#691,#692)) REPRESENTATION_CONTEXT('Context #1', + '3D Context with UNIT and UNCERTAINTY') ); +#690 = ( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) ); +#691 = ( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) ); +#692 = ( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() ); +#693 = UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#690, + 'distance_accuracy_value','confusion accuracy'); +#694 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#355)); +#695 = SHAPE_DEFINITION_REPRESENTATION(#696,#702); +#696 = PRODUCT_DEFINITION_SHAPE('','',#697); +#697 = PRODUCT_DEFINITION('design','',#698,#701); +#698 = PRODUCT_DEFINITION_FORMATION('','',#699); +#699 = PRODUCT('bracket','bracket','',(#700)); +#700 = PRODUCT_CONTEXT('',#2,'mechanical'); +#701 = PRODUCT_DEFINITION_CONTEXT('part definition',#2,'design'); +#702 = ADVANCED_BREP_SHAPE_REPRESENTATION('',(#11,#703),#1033); +#703 = MANIFOLD_SOLID_BREP('',#704); +#704 = CLOSED_SHELL('',(#705,#825,#925,#972,#1019,#1026)); +#705 = ADVANCED_FACE('',(#706),#720,.F.); +#706 = FACE_BOUND('',#707,.F.); +#707 = EDGE_LOOP('',(#708,#743,#771,#799)); +#708 = ORIENTED_EDGE('',*,*,#709,.F.); +#709 = EDGE_CURVE('',#710,#712,#714,.T.); +#710 = VERTEX_POINT('',#711); +#711 = CARTESIAN_POINT('',(45.,0.,0.)); +#712 = VERTEX_POINT('',#713); +#713 = CARTESIAN_POINT('',(45.,0.,6.)); +#714 = SURFACE_CURVE('',#715,(#719,#731),.PCURVE_S1.); +#715 = LINE('',#716,#717); +#716 = CARTESIAN_POINT('',(45.,0.,0.)); +#717 = VECTOR('',#718,1.); +#718 = DIRECTION('',(0.,0.,1.)); +#719 = PCURVE('',#720,#725); +#720 = PLANE('',#721); +#721 = AXIS2_PLACEMENT_3D('',#722,#723,#724); +#722 = CARTESIAN_POINT('',(45.,0.,0.)); +#723 = DIRECTION('',(1.,0.,-0.)); +#724 = DIRECTION('',(0.,0.,1.)); +#725 = DEFINITIONAL_REPRESENTATION('',(#726),#730); +#726 = LINE('',#727,#728); +#727 = CARTESIAN_POINT('',(0.,0.)); +#728 = VECTOR('',#729,1.); +#729 = DIRECTION('',(1.,0.)); +#730 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#731 = PCURVE('',#732,#737); +#732 = PLANE('',#733); +#733 = AXIS2_PLACEMENT_3D('',#734,#735,#736); +#734 = CARTESIAN_POINT('',(45.,0.,0.)); +#735 = DIRECTION('',(-0.,1.,0.)); +#736 = DIRECTION('',(0.,0.,1.)); +#737 = DEFINITIONAL_REPRESENTATION('',(#738),#742); +#738 = LINE('',#739,#740); +#739 = CARTESIAN_POINT('',(0.,0.)); +#740 = VECTOR('',#741,1.); +#741 = DIRECTION('',(1.,0.)); +#742 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#743 = ORIENTED_EDGE('',*,*,#744,.T.); +#744 = EDGE_CURVE('',#710,#745,#747,.T.); +#745 = VERTEX_POINT('',#746); +#746 = CARTESIAN_POINT('',(45.,12.,0.)); +#747 = SURFACE_CURVE('',#748,(#752,#759),.PCURVE_S1.); +#748 = LINE('',#749,#750); +#749 = CARTESIAN_POINT('',(45.,0.,0.)); +#750 = VECTOR('',#751,1.); +#751 = DIRECTION('',(-0.,1.,0.)); +#752 = PCURVE('',#720,#753); +#753 = DEFINITIONAL_REPRESENTATION('',(#754),#758); +#754 = LINE('',#755,#756); +#755 = CARTESIAN_POINT('',(0.,0.)); +#756 = VECTOR('',#757,1.); +#757 = DIRECTION('',(0.,-1.)); +#758 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#759 = PCURVE('',#760,#765); +#760 = PLANE('',#761); +#761 = AXIS2_PLACEMENT_3D('',#762,#763,#764); +#762 = CARTESIAN_POINT('',(45.,0.,0.)); +#763 = DIRECTION('',(0.,0.,1.)); +#764 = DIRECTION('',(1.,0.,-0.)); +#765 = DEFINITIONAL_REPRESENTATION('',(#766),#770); +#766 = LINE('',#767,#768); +#767 = CARTESIAN_POINT('',(0.,0.)); +#768 = VECTOR('',#769,1.); +#769 = DIRECTION('',(0.,1.)); +#770 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#771 = ORIENTED_EDGE('',*,*,#772,.T.); +#772 = EDGE_CURVE('',#745,#773,#775,.T.); +#773 = VERTEX_POINT('',#774); +#774 = CARTESIAN_POINT('',(45.,12.,6.)); +#775 = SURFACE_CURVE('',#776,(#780,#787),.PCURVE_S1.); +#776 = LINE('',#777,#778); +#777 = CARTESIAN_POINT('',(45.,12.,0.)); +#778 = VECTOR('',#779,1.); +#779 = DIRECTION('',(0.,0.,1.)); +#780 = PCURVE('',#720,#781); +#781 = DEFINITIONAL_REPRESENTATION('',(#782),#786); +#782 = LINE('',#783,#784); +#783 = CARTESIAN_POINT('',(0.,-12.)); +#784 = VECTOR('',#785,1.); +#785 = DIRECTION('',(1.,0.)); +#786 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#787 = PCURVE('',#788,#793); +#788 = PLANE('',#789); +#789 = AXIS2_PLACEMENT_3D('',#790,#791,#792); +#790 = CARTESIAN_POINT('',(45.,12.,0.)); +#791 = DIRECTION('',(-0.,1.,0.)); +#792 = DIRECTION('',(0.,0.,1.)); +#793 = DEFINITIONAL_REPRESENTATION('',(#794),#798); +#794 = LINE('',#795,#796); +#795 = CARTESIAN_POINT('',(0.,0.)); +#796 = VECTOR('',#797,1.); +#797 = DIRECTION('',(1.,0.)); +#798 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#799 = ORIENTED_EDGE('',*,*,#800,.F.); +#800 = EDGE_CURVE('',#712,#773,#801,.T.); +#801 = SURFACE_CURVE('',#802,(#806,#813),.PCURVE_S1.); +#802 = LINE('',#803,#804); +#803 = CARTESIAN_POINT('',(45.,0.,6.)); +#804 = VECTOR('',#805,1.); +#805 = DIRECTION('',(-0.,1.,0.)); +#806 = PCURVE('',#720,#807); +#807 = DEFINITIONAL_REPRESENTATION('',(#808),#812); +#808 = LINE('',#809,#810); +#809 = CARTESIAN_POINT('',(6.,0.)); +#810 = VECTOR('',#811,1.); +#811 = DIRECTION('',(0.,-1.)); +#812 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#813 = PCURVE('',#814,#819); +#814 = PLANE('',#815); +#815 = AXIS2_PLACEMENT_3D('',#816,#817,#818); +#816 = CARTESIAN_POINT('',(45.,0.,6.)); +#817 = DIRECTION('',(0.,0.,1.)); +#818 = DIRECTION('',(1.,0.,-0.)); +#819 = DEFINITIONAL_REPRESENTATION('',(#820),#824); +#820 = LINE('',#821,#822); +#821 = CARTESIAN_POINT('',(0.,0.)); +#822 = VECTOR('',#823,1.); +#823 = DIRECTION('',(0.,1.)); +#824 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#825 = ADVANCED_FACE('',(#826),#840,.T.); +#826 = FACE_BOUND('',#827,.T.); +#827 = EDGE_LOOP('',(#828,#858,#881,#904)); +#828 = ORIENTED_EDGE('',*,*,#829,.F.); +#829 = EDGE_CURVE('',#830,#832,#834,.T.); +#830 = VERTEX_POINT('',#831); +#831 = CARTESIAN_POINT('',(57.,0.,0.)); +#832 = VERTEX_POINT('',#833); +#833 = CARTESIAN_POINT('',(57.,0.,6.)); +#834 = SURFACE_CURVE('',#835,(#839,#851),.PCURVE_S1.); +#835 = LINE('',#836,#837); +#836 = CARTESIAN_POINT('',(57.,0.,0.)); +#837 = VECTOR('',#838,1.); +#838 = DIRECTION('',(0.,0.,1.)); +#839 = PCURVE('',#840,#845); +#840 = PLANE('',#841); +#841 = AXIS2_PLACEMENT_3D('',#842,#843,#844); +#842 = CARTESIAN_POINT('',(57.,0.,0.)); +#843 = DIRECTION('',(1.,0.,-0.)); +#844 = DIRECTION('',(0.,0.,1.)); +#845 = DEFINITIONAL_REPRESENTATION('',(#846),#850); +#846 = LINE('',#847,#848); +#847 = CARTESIAN_POINT('',(0.,0.)); +#848 = VECTOR('',#849,1.); +#849 = DIRECTION('',(1.,0.)); +#850 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#851 = PCURVE('',#732,#852); +#852 = DEFINITIONAL_REPRESENTATION('',(#853),#857); +#853 = LINE('',#854,#855); +#854 = CARTESIAN_POINT('',(0.,12.)); +#855 = VECTOR('',#856,1.); +#856 = DIRECTION('',(1.,0.)); +#857 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#858 = ORIENTED_EDGE('',*,*,#859,.T.); +#859 = EDGE_CURVE('',#830,#860,#862,.T.); +#860 = VERTEX_POINT('',#861); +#861 = CARTESIAN_POINT('',(57.,12.,0.)); +#862 = SURFACE_CURVE('',#863,(#867,#874),.PCURVE_S1.); +#863 = LINE('',#864,#865); +#864 = CARTESIAN_POINT('',(57.,0.,0.)); +#865 = VECTOR('',#866,1.); +#866 = DIRECTION('',(-0.,1.,0.)); +#867 = PCURVE('',#840,#868); +#868 = DEFINITIONAL_REPRESENTATION('',(#869),#873); +#869 = LINE('',#870,#871); +#870 = CARTESIAN_POINT('',(0.,0.)); +#871 = VECTOR('',#872,1.); +#872 = DIRECTION('',(0.,-1.)); +#873 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#874 = PCURVE('',#760,#875); +#875 = DEFINITIONAL_REPRESENTATION('',(#876),#880); +#876 = LINE('',#877,#878); +#877 = CARTESIAN_POINT('',(12.,0.)); +#878 = VECTOR('',#879,1.); +#879 = DIRECTION('',(0.,1.)); +#880 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#881 = ORIENTED_EDGE('',*,*,#882,.T.); +#882 = EDGE_CURVE('',#860,#883,#885,.T.); +#883 = VERTEX_POINT('',#884); +#884 = CARTESIAN_POINT('',(57.,12.,6.)); +#885 = SURFACE_CURVE('',#886,(#890,#897),.PCURVE_S1.); +#886 = LINE('',#887,#888); +#887 = CARTESIAN_POINT('',(57.,12.,0.)); +#888 = VECTOR('',#889,1.); +#889 = DIRECTION('',(0.,0.,1.)); +#890 = PCURVE('',#840,#891); +#891 = DEFINITIONAL_REPRESENTATION('',(#892),#896); +#892 = LINE('',#893,#894); +#893 = CARTESIAN_POINT('',(0.,-12.)); +#894 = VECTOR('',#895,1.); +#895 = DIRECTION('',(1.,0.)); +#896 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#897 = PCURVE('',#788,#898); +#898 = DEFINITIONAL_REPRESENTATION('',(#899),#903); +#899 = LINE('',#900,#901); +#900 = CARTESIAN_POINT('',(0.,12.)); +#901 = VECTOR('',#902,1.); +#902 = DIRECTION('',(1.,0.)); +#903 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#904 = ORIENTED_EDGE('',*,*,#905,.F.); +#905 = EDGE_CURVE('',#832,#883,#906,.T.); +#906 = SURFACE_CURVE('',#907,(#911,#918),.PCURVE_S1.); +#907 = LINE('',#908,#909); +#908 = CARTESIAN_POINT('',(57.,0.,6.)); +#909 = VECTOR('',#910,1.); +#910 = DIRECTION('',(-0.,1.,0.)); +#911 = PCURVE('',#840,#912); +#912 = DEFINITIONAL_REPRESENTATION('',(#913),#917); +#913 = LINE('',#914,#915); +#914 = CARTESIAN_POINT('',(6.,0.)); +#915 = VECTOR('',#916,1.); +#916 = DIRECTION('',(0.,-1.)); +#917 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#918 = PCURVE('',#814,#919); +#919 = DEFINITIONAL_REPRESENTATION('',(#920),#924); +#920 = LINE('',#921,#922); +#921 = CARTESIAN_POINT('',(12.,0.)); +#922 = VECTOR('',#923,1.); +#923 = DIRECTION('',(0.,1.)); +#924 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#925 = ADVANCED_FACE('',(#926),#732,.F.); +#926 = FACE_BOUND('',#927,.F.); +#927 = EDGE_LOOP('',(#928,#949,#950,#971)); +#928 = ORIENTED_EDGE('',*,*,#929,.F.); +#929 = EDGE_CURVE('',#710,#830,#930,.T.); +#930 = SURFACE_CURVE('',#931,(#935,#942),.PCURVE_S1.); +#931 = LINE('',#932,#933); +#932 = CARTESIAN_POINT('',(45.,0.,0.)); +#933 = VECTOR('',#934,1.); +#934 = DIRECTION('',(1.,0.,-0.)); +#935 = PCURVE('',#732,#936); +#936 = DEFINITIONAL_REPRESENTATION('',(#937),#941); +#937 = LINE('',#938,#939); +#938 = CARTESIAN_POINT('',(0.,0.)); +#939 = VECTOR('',#940,1.); +#940 = DIRECTION('',(0.,1.)); +#941 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#942 = PCURVE('',#760,#943); +#943 = DEFINITIONAL_REPRESENTATION('',(#944),#948); +#944 = LINE('',#945,#946); +#945 = CARTESIAN_POINT('',(0.,0.)); +#946 = VECTOR('',#947,1.); +#947 = DIRECTION('',(1.,0.)); +#948 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#949 = ORIENTED_EDGE('',*,*,#709,.T.); +#950 = ORIENTED_EDGE('',*,*,#951,.T.); +#951 = EDGE_CURVE('',#712,#832,#952,.T.); +#952 = SURFACE_CURVE('',#953,(#957,#964),.PCURVE_S1.); +#953 = LINE('',#954,#955); +#954 = CARTESIAN_POINT('',(45.,0.,6.)); +#955 = VECTOR('',#956,1.); +#956 = DIRECTION('',(1.,0.,-0.)); +#957 = PCURVE('',#732,#958); +#958 = DEFINITIONAL_REPRESENTATION('',(#959),#963); +#959 = LINE('',#960,#961); +#960 = CARTESIAN_POINT('',(6.,0.)); +#961 = VECTOR('',#962,1.); +#962 = DIRECTION('',(0.,1.)); +#963 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#964 = PCURVE('',#814,#965); +#965 = DEFINITIONAL_REPRESENTATION('',(#966),#970); +#966 = LINE('',#967,#968); +#967 = CARTESIAN_POINT('',(0.,0.)); +#968 = VECTOR('',#969,1.); +#969 = DIRECTION('',(1.,0.)); +#970 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#971 = ORIENTED_EDGE('',*,*,#829,.F.); +#972 = ADVANCED_FACE('',(#973),#788,.T.); +#973 = FACE_BOUND('',#974,.T.); +#974 = EDGE_LOOP('',(#975,#996,#997,#1018)); +#975 = ORIENTED_EDGE('',*,*,#976,.F.); +#976 = EDGE_CURVE('',#745,#860,#977,.T.); +#977 = SURFACE_CURVE('',#978,(#982,#989),.PCURVE_S1.); +#978 = LINE('',#979,#980); +#979 = CARTESIAN_POINT('',(45.,12.,0.)); +#980 = VECTOR('',#981,1.); +#981 = DIRECTION('',(1.,0.,-0.)); +#982 = PCURVE('',#788,#983); +#983 = DEFINITIONAL_REPRESENTATION('',(#984),#988); +#984 = LINE('',#985,#986); +#985 = CARTESIAN_POINT('',(0.,0.)); +#986 = VECTOR('',#987,1.); +#987 = DIRECTION('',(0.,1.)); +#988 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#989 = PCURVE('',#760,#990); +#990 = DEFINITIONAL_REPRESENTATION('',(#991),#995); +#991 = LINE('',#992,#993); +#992 = CARTESIAN_POINT('',(0.,12.)); +#993 = VECTOR('',#994,1.); +#994 = DIRECTION('',(1.,0.)); +#995 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#996 = ORIENTED_EDGE('',*,*,#772,.T.); +#997 = ORIENTED_EDGE('',*,*,#998,.T.); +#998 = EDGE_CURVE('',#773,#883,#999,.T.); +#999 = SURFACE_CURVE('',#1000,(#1004,#1011),.PCURVE_S1.); +#1000 = LINE('',#1001,#1002); +#1001 = CARTESIAN_POINT('',(45.,12.,6.)); +#1002 = VECTOR('',#1003,1.); +#1003 = DIRECTION('',(1.,0.,-0.)); +#1004 = PCURVE('',#788,#1005); +#1005 = DEFINITIONAL_REPRESENTATION('',(#1006),#1010); +#1006 = LINE('',#1007,#1008); +#1007 = CARTESIAN_POINT('',(6.,0.)); +#1008 = VECTOR('',#1009,1.); +#1009 = DIRECTION('',(0.,1.)); +#1010 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1011 = PCURVE('',#814,#1012); +#1012 = DEFINITIONAL_REPRESENTATION('',(#1013),#1017); +#1013 = LINE('',#1014,#1015); +#1014 = CARTESIAN_POINT('',(0.,12.)); +#1015 = VECTOR('',#1016,1.); +#1016 = DIRECTION('',(1.,0.)); +#1017 = ( GEOMETRIC_REPRESENTATION_CONTEXT(2) +PARAMETRIC_REPRESENTATION_CONTEXT() REPRESENTATION_CONTEXT('2D SPACE','' + ) ); +#1018 = ORIENTED_EDGE('',*,*,#882,.F.); +#1019 = ADVANCED_FACE('',(#1020),#760,.F.); +#1020 = FACE_BOUND('',#1021,.F.); +#1021 = EDGE_LOOP('',(#1022,#1023,#1024,#1025)); +#1022 = ORIENTED_EDGE('',*,*,#744,.F.); +#1023 = ORIENTED_EDGE('',*,*,#929,.T.); +#1024 = ORIENTED_EDGE('',*,*,#859,.T.); +#1025 = ORIENTED_EDGE('',*,*,#976,.F.); +#1026 = ADVANCED_FACE('',(#1027),#814,.T.); +#1027 = FACE_BOUND('',#1028,.T.); +#1028 = EDGE_LOOP('',(#1029,#1030,#1031,#1032)); +#1029 = ORIENTED_EDGE('',*,*,#800,.F.); +#1030 = ORIENTED_EDGE('',*,*,#951,.T.); +#1031 = ORIENTED_EDGE('',*,*,#905,.T.); +#1032 = ORIENTED_EDGE('',*,*,#998,.F.); +#1033 = ( GEOMETRIC_REPRESENTATION_CONTEXT(3) +GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#1037)) +GLOBAL_UNIT_ASSIGNED_CONTEXT((#1034,#1035,#1036)) REPRESENTATION_CONTEXT +('Context #1','3D Context with UNIT and UNCERTAINTY') ); +#1034 = ( LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.) ); +#1035 = ( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.) ); +#1036 = ( NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) SOLID_ANGLE_UNIT() ); +#1037 = UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#1034, + 'distance_accuracy_value','confusion accuracy'); +#1038 = PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#699)); +ENDSEC; +END-ISO-10303-21; diff --git a/tests/fff_print/CMakeLists.txt b/tests/fff_print/CMakeLists.txt index 43afd4281d..fc46bb8fdc 100644 --- a/tests/fff_print/CMakeLists.txt +++ b/tests/fff_print/CMakeLists.txt @@ -15,6 +15,7 @@ add_executable(${_TEST_NAME}_tests test_perimeters.cpp test_print.cpp test_printobject.cpp + test_mixed_filament.cpp test_skirt_brim.cpp test_slicing_pipeline_hook.cpp test_support_material.cpp diff --git a/tests/fff_print/test_mixed_filament.cpp b/tests/fff_print/test_mixed_filament.cpp new file mode 100644 index 0000000000..25b426c210 --- /dev/null +++ b/tests/fff_print/test_mixed_filament.cpp @@ -0,0 +1,324 @@ +#include + +#include "libslic3r/GCode/ToolOrdering.hpp" +#include "libslic3r/MultiNozzleUtils.hpp" +#include "libslic3r/Print.hpp" + +#include "test_helpers.hpp" + +using namespace Slic3r; +using namespace Slic3r::Test; + +namespace { + +// Two physical filaments plus one mixed slot (config index 2, 1-based id 3) blending them 60/40. +// The mixed arrays are parallel to filament_colour and must be sized to the filament count. +// Note ConfigOptionBools deserializes on ',' while ConfigOptionStrings uses ';'. +DynamicPrintConfig mixed_config(bool sublayer_on, const char *ratios = "0.6,0.4") +{ + DynamicPrintConfig config = multifilament_config(3); + config.set_deserialize_strict({ + {"filament_is_mixed", "0,0,1"}, + {"filament_mixed_components", ";;1,2"}, + {"filament_mixed_sublayer_ratios", std::string(";;") + ratios}, + {"filament_mixed_gradient", "0,0,0"}, + {"filament_mixed_gradient_range", ";;"}, + {"filament_mixed_gradient_curve", ";;"}, + {"filament_mixed_gradient_per_part","0,0,0"}, + {"enable_mixed_color_sublayer", sublayer_on ? "1" : "0"}, + // Assign every region role to the mixed slot so it actually participates in slicing. + {"outer_wall_filament_id", "3"}, + {"inner_wall_filament_id", "3"}, + {"sparse_infill_filament_id", "3"}, + {"internal_solid_filament_id", "3"}, + {"top_surface_filament_id", "3"}, + {"bottom_surface_filament_id", "3"}, + }); + return config; +} + +// Total sub-layer groups and per-layer mixed-filament resolutions across the whole tool ordering. +void count_mixed(ToolOrdering &to, size_t &groups, size_t &resolutions) +{ + groups = resolutions = 0; + for (const LayerTools < : to.layer_tools()) { + groups += lt.mixed_sub_layer_groups.size(); + resolutions += lt.mixed_filament_resolution.size(); + } +} + +} // namespace + +TEST_CASE("enable_mixed_color_sublayer reaches the Print config", "[MixedFilament]") +{ + Print print; + Model model; + init_print({cube(20)}, print, model, mixed_config(true)); + + // The option lives in PrintConfig; if it did not survive Print::apply the slicer would + // silently fall back to the whole-layer path. + CHECK(print.config().enable_mixed_color_sublayer.value == true); + REQUIRE(print.config().filament_is_mixed.values.size() == 3); + CHECK(print.config().filament_is_mixed.values[2] == true); + REQUIRE(print.config().filament_mixed_components.values.size() == 3); + CHECK(print.config().filament_mixed_components.values[2] == "1,2"); +} + +TEST_CASE("Mixed filament splits layers into sub-layers when the option is on", "[MixedFilament]") +{ + Print print; + Model model; + init_print({cube(20)}, print, model, mixed_config(true)); + print.process(); + + ToolOrdering &to = const_cast(print.tool_ordering()); + REQUIRE(!to.layer_tools().empty()); + + size_t groups = 0, resolutions = 0; + count_mixed(to, groups, resolutions); + + INFO("layers=" << to.layer_tools().size() << " groups=" << groups); + CHECK(groups > 0); +} + +TEST_CASE("Mixed filament alternates whole layers when the option is off", "[MixedFilament]") +{ + Print print; + Model model; + init_print({cube(20)}, print, model, mixed_config(false)); + print.process(); + + ToolOrdering &to = const_cast(print.tool_ordering()); + REQUIRE(!to.layer_tools().empty()); + + size_t groups = 0, resolutions = 0; + count_mixed(to, groups, resolutions); + + // With splitting off the slot is realized by the deficit round-robin scheduler instead: + // no sub-layer groups, but a per-layer resolution to one physical component. + INFO("layers=" << to.layer_tools().size() << " resolutions=" << resolutions); + CHECK(groups == 0); + CHECK(resolutions > 0); +} + +TEST_CASE("Sub-layer splitting emits the scaled sub-heights into G-code", "[MixedFilament]") +{ + // layer_height 0.2 split 60/40 gives sub-layers of 0.12 and 0.08. The emitter reports the + // sub-height (not the nominal layer height) in the HEIGHT tag and scales flow to match. + DynamicPrintConfig config = mixed_config(true); + config.set_deserialize_strict({{"layer_height", "0.2"}, {"initial_layer_print_height", "0.2"}}); + + Print print; + Model model; + init_print({cube(20)}, print, model, config); + print.process(); + const std::string gc = Slic3r::Test::gcode(print); + + REQUIRE(!gc.empty()); + INFO("gcode bytes=" << gc.size()); + CHECK(gc.find(";HEIGHT:0.12") != std::string::npos); + CHECK(gc.find(";HEIGHT:0.08") != std::string::npos); +} + +TEST_CASE("Whole-layer mixing emits only the nominal layer height", "[MixedFilament]") +{ + DynamicPrintConfig config = mixed_config(false); + config.set_deserialize_strict({{"layer_height", "0.2"}, {"initial_layer_print_height", "0.2"}}); + + Print print; + Model model; + init_print({cube(20)}, print, model, config); + print.process(); + const std::string gc = Slic3r::Test::gcode(print); + + REQUIRE(!gc.empty()); + // No sub-layer split, so the 60/40 sub-heights must never appear. + CHECK(gc.find(";HEIGHT:0.12") == std::string::npos); + CHECK(gc.find(";HEIGHT:0.08") == std::string::npos); +} + +TEST_CASE("By-object prints without mixed filaments keep their used-filament set", "[MixedFilament]") +{ + // With no mixed slot the by-object bookkeeping stays plain: object 2 prints with filament 2, + // so both filaments are used and no mixed filament is reported. + DynamicPrintConfig config = multifilament_config(2, {{"print_sequence", "by object"}}); + const std::vector> overrides{ {}, { {"extruder", "2"} } }; + + Print print; + Model model; + init_print(std::vector{cube(20), cube(20)}, print, model, config, &overrides); + REQUIRE(print.objects().size() == 2); + print.process(); + + CHECK(print.get_slice_used_filaments(false) == std::vector{0, 1}); + CHECK(print.get_slice_used_filaments(true) == std::vector{0, 1}); + CHECK(print.get_slice_used_mixed_filaments().empty()); +} + +TEST_CASE("By-layer prints record a mixed slot's components and the slot itself", "[MixedFilament]") +{ + // Control for the by-object case below: the by-layer path publishes the physical + // components (0-based 0 and 1) as used filaments and the mixed slot (config index 2) as + // a used mixed filament. By-object prints must report exactly the same. + Print print; + Model model; + init_print({cube(20)}, print, model, mixed_config(false)); + print.process(); + + CHECK(print.get_slice_used_filaments(false) == std::vector{0, 1}); + CHECK(print.get_slice_used_mixed_filaments() == std::vector{2}); +} + +TEST_CASE("By-object prints expand a mixed slot to its components in the slice bookkeeping", "[MixedFilament]") +{ + // Sequential prints build their filament lists from unsorted per-object orderings, which + // still carry the virtual slot (config index 2). The slice-used sets and the published + // grouping result must see the physical components 0 and 1 instead, and the slot itself + // must still be reported as a used mixed filament — exactly what the by-layer path yields. + DynamicPrintConfig config = mixed_config(false); + config.set_deserialize_strict({{"print_sequence", "by object"}}); + + Print print; + Model model; + init_print({cube(20), cube(20)}, print, model, config); + REQUIRE(print.objects().size() == 2); + print.process(); + + const std::vector components{0, 1}; + CHECK(print.get_slice_used_filaments(false) == components); + CHECK(print.get_slice_used_filaments(true) == components); + CHECK(print.get_slice_used_mixed_filaments() == std::vector{2}); + + auto group_result = print.get_layered_nozzle_group_result(); + REQUIRE(group_result != nullptr); + CHECK(group_result->get_used_filaments() == components); +} + +TEST_CASE("By-object G-code lists a mixed slot's components in the filament header", "[MixedFilament]") +{ + DynamicPrintConfig config = mixed_config(false); + config.set_deserialize_strict({{"print_sequence", "by object"}}); + + Print print; + Model model; + init_print({cube(20), cube(20)}, print, model, config); + const std::string gc = Slic3r::Test::gcode(print); + + REQUIRE(!gc.empty()); + // The header names the filaments that must be loaded (components 1 and 2, 1-based), + // never the virtual slot 3. + CHECK(gc.find("; filament: 1,2\n") != std::string::npos); + CHECK(gc.find("; filament: 3") == std::string::npos); +} + +TEST_CASE("Print::validate rejects a mixed filament as the wipe tower filament", "[MixedFilament]") +{ + // The validate backstop refuses a mixed (virtual) slot as the wipe tower filament; the GUI hides + // the slot from that option. Two cubes on physical filaments 1 and 2 make the tower real, and the + // region roles mixed_config() points at the slot are reset so only the tower uses it. + DynamicPrintConfig config = mixed_config(false); + config.set_deserialize_strict({ + {"enable_prime_tower", "1"}, + {"wipe_tower_x", "50"}, // inside the 200x200 test bed + {"wipe_tower_y", "50"}, // (the default y, 220, is not) + {"layer_change_gcode", "G92 E0\n"}, // validate() relative-E reset, as in test_print.cpp's build_cubes + {"outer_wall_filament_id", "0"}, + {"inner_wall_filament_id", "0"}, + {"sparse_infill_filament_id", "0"}, + {"internal_solid_filament_id", "0"}, + {"top_surface_filament_id", "0"}, + {"bottom_surface_filament_id", "0"}, + }); + const std::vector> overrides{ { {"extruder", "1"} }, { {"extruder", "2"} } }; + + SECTION("a physical wipe tower filament validates") { + config.set_deserialize_strict({{"wipe_tower_filament", "2"}}); + Print print; + Model model; + init_print(std::vector{cube(20), cube(20)}, print, model, config, &overrides); + REQUIRE(print.has_wipe_tower()); + const StringObjectException err = print.validate(); + INFO(err.string); + CHECK(err.string.empty()); + } + + SECTION("the mixed slot is refused") { + config.set_deserialize_strict({{"wipe_tower_filament", "3"}}); + Print print; + Model model; + init_print(std::vector{cube(20), cube(20)}, print, model, config, &overrides); + REQUIRE(print.has_wipe_tower()); + const StringObjectException err = print.validate(); + CHECK_FALSE(err.string.empty()); + CHECK(err.opt_key == "wipe_tower_filament"); + } +} + +TEST_CASE("Print::validate warns when a gradient mixed filament is used without sublayer mixing", "[MixedFilament]") +{ + // A gradient mixed filament only renders its gradient with the process option enabled; without + // it ToolOrdering prints one whole component per layer and the gradient is dropped silently, + // so validate() warns whenever the slot actually takes part in the print. The layer-change + // reset avoids an unrelated relative-extrusion warning, as in the wipe tower test above. + DynamicPrintConfig config = mixed_config(false); + config.set_deserialize_strict({ + {"filament_mixed_gradient", "0,0,1"}, + {"layer_change_gcode", "G92 E0\n"}, + }); + + auto count_opt = [](Print &print, const char *opt_key) { + std::vector warnings; + print.validate(&warnings); + return std::count_if(warnings.begin(), warnings.end(), + [&](const StringObjectException &w) { return w.opt_key == opt_key; }); + }; + + SECTION("gradient slot used, sublayer mixing off") { + Print print; + Model model; + init_print({cube(20)}, print, model, config); + std::vector warnings; + const StringObjectException err = print.validate(&warnings); + CHECK(err.string.empty()); + const auto it = std::find_if(warnings.begin(), warnings.end(), [](const StringObjectException &w) { + return w.opt_key == "enable_mixed_color_sublayer"; + }); + REQUIRE(it != warnings.end()); + CHECK(it->is_warning); + CHECK(std::count_if(warnings.begin(), warnings.end(), [](const StringObjectException &w) { + return w.opt_key == "enable_mixed_color_sublayer"; + }) == 1); + } + + SECTION("sublayer mixing on") { + config.set_deserialize_strict({{"enable_mixed_color_sublayer", "1"}}); + Print print; + Model model; + init_print({cube(20)}, print, model, config); + CHECK(count_opt(print, "enable_mixed_color_sublayer") == 0); + } + + SECTION("gradient flag off") { + config.set_deserialize_strict({{"filament_mixed_gradient", "0,0,0"}}); + Print print; + Model model; + init_print({cube(20)}, print, model, config); + CHECK(count_opt(print, "enable_mixed_color_sublayer") == 0); + } + + SECTION("mixed slot not used") { + config.set_deserialize_strict({ + {"outer_wall_filament_id", "0"}, + {"inner_wall_filament_id", "0"}, + {"sparse_infill_filament_id", "0"}, + {"internal_solid_filament_id", "0"}, + {"top_surface_filament_id", "0"}, + {"bottom_surface_filament_id", "0"}, + }); + Print print; + Model model; + const std::vector> overrides{{{ "extruder", "1" }}}; + init_print(std::vector{cube(20)}, print, model, config, &overrides); + CHECK(count_opt(print, "enable_mixed_color_sublayer") == 0); + } +} diff --git a/tests/libslic3r/CMakeLists.txt b/tests/libslic3r/CMakeLists.txt index 28c39c2d6a..bf3981f519 100644 --- a/tests/libslic3r/CMakeLists.txt +++ b/tests/libslic3r/CMakeLists.txt @@ -20,6 +20,7 @@ add_executable(${_TEST_NAME}_tests test_vendor_cache.cpp test_elephant_foot_compensation.cpp test_fill_corner_smoothing.cpp + test_filament_mixer.cpp test_fill_plane_path.cpp test_geometry.cpp test_multimaterial_segmentation.cpp @@ -28,7 +29,9 @@ add_executable(${_TEST_NAME}_tests test_mutable_polygon.cpp test_mutable_priority_queue.cpp test_nozzle_volume_type.cpp + test_step.cpp test_stl.cpp + test_triangle_selector.cpp test_meshboolean.cpp test_marchingsquares.cpp test_model.cpp diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index c839149f5f..4c0a09cf3f 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -1,5 +1,6 @@ #include "libslic3r/Model.hpp" +#include "libslic3r/TriangleSelector.hpp" #include "libslic3r/Format/3mf.hpp" #include "libslic3r/Format/bbs_3mf.hpp" #include "libslic3r/Format/STL.hpp" @@ -497,3 +498,95 @@ SCENARIO("Nozzle-group metadata .3mf round-trip", "[3mf][MultiNozzle]") { delete plate; } } + + +// A mixed-color filament occupies an ordinary filament slot, and painting with it stores an +// ordinary extruder state: a project saved by BambuStudio encodes filament 5 of a 5-slot setup +// as paint state 5, with the mix described by the parallel filament_mixed_* project arrays. +SCENARIO("Mixed-color filament setup and painting round-trip through a .3mf", "[3mf][MixedFilament]") { + GIVEN("a painted model whose project config describes a mixed filament in the last slot") { + Model model; + std::string src_file = std::string(TEST_DATA_DIR) + "/test_3mf/Prusa.stl"; + REQUIRE(load_stl(src_file.c_str(), &model)); + model.add_default_instances(); + + // Both the exporter and the importer stage Metadata/project_settings.config through the + // model's backup path; point them at writable temp dirs. + ScopedTemporaryDir backup_dir("orca_mixed_src"); + model.set_backup_path(backup_dir.string()); + + ModelVolume* mv = model.objects.front()->volumes.front(); + { + TriangleSelector selector(mv->mesh()); + selector.set_facet(0, EnforcerBlockerType::Extruder5); // the mixed slot + selector.set_facet(1, EnforcerBlockerType::Extruder2); + REQUIRE(mv->mmu_segmentation_facets.set(selector)); + } + + DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); + config.set_key_value("filament_colour", new ConfigOptionStrings( + { "#00AE42", "#FFFF00", "#FF0000", "#0000FF", "#FF6A26" })); + config.set_key_value("filament_is_mixed", new ConfigOptionBools( + { false, false, false, false, true })); + config.set_key_value("filament_mixed_components", new ConfigOptionStrings( + { "", "", "", "", "3,2" })); + config.set_key_value("filament_mixed_sublayer_ratios", new ConfigOptionStrings( + { "", "", "", "", "0.4200,0.5800" })); + + WHEN("stored to and reloaded from a .3mf") { + ScopedTemporaryFile temp(".3mf"); + const std::string test_file = temp.string(); + + PlateData* plate = new PlateData(); + plate->plate_index = 0; + + StoreParams store_params; + store_params.path = test_file.c_str(); + store_params.model = &model; + store_params.config = &config; + store_params.strategy = SaveStrategy::Zip64 | SaveStrategy::Silence; + store_params.plate_data_list.push_back(plate); + REQUIRE(store_bbs_3mf(store_params)); + + Model dst_model; + ScopedTemporaryDir dst_backup_dir("orca_mixed_dst"); + dst_model.set_backup_path(dst_backup_dir.string()); + DynamicPrintConfig dst_config; + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + PlateDataPtrs dst_plates; + std::vector project_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + REQUIRE(load_bbs_3mf(test_file.c_str(), &dst_config, &ctxt, &dst_model, &dst_plates, + &project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, + LoadStrategy::LoadModel | LoadStrategy::LoadConfig)); + + THEN("the mixed-filament project keys survive") { + auto* is_mixed = dst_config.option("filament_is_mixed"); + REQUIRE(is_mixed != nullptr); + REQUIRE(is_mixed->values == std::vector({ 0, 0, 0, 0, 1 })); + + auto* components = dst_config.option("filament_mixed_components"); + REQUIRE(components != nullptr); + REQUIRE(components->values.size() == 5); + REQUIRE(components->values[4] == "3,2"); + + auto* ratios = dst_config.option("filament_mixed_sublayer_ratios"); + REQUIRE(ratios != nullptr); + REQUIRE(ratios->values.size() == 5); + REQUIRE(ratios->values[4] == "0.4200,0.5800"); + } + + THEN("the painted facets survive, including the one painted with the mixed slot") { + REQUIRE(dst_model.objects.size() == 1); + ModelVolume* dst_mv = dst_model.objects.front()->volumes.front(); + REQUIRE_FALSE(dst_mv->mmu_segmentation_facets.empty()); + REQUIRE(dst_mv->mmu_segmentation_facets.has_facets(*dst_mv, EnforcerBlockerType::Extruder2)); + REQUIRE(dst_mv->mmu_segmentation_facets.has_facets(*dst_mv, EnforcerBlockerType::Extruder5)); + } + + release_PlateData_list(dst_plates); + delete plate; // store_bbs_3mf does not take ownership of the source plate + } + } +} diff --git a/tests/libslic3r/test_filament_mixer.cpp b/tests/libslic3r/test_filament_mixer.cpp new file mode 100644 index 0000000000..ade0c910dc --- /dev/null +++ b/tests/libslic3r/test_filament_mixer.cpp @@ -0,0 +1,205 @@ +#include + +#include "libslic3r/FilamentMixer.hpp" +#include "libslic3r/PrintConfig.hpp" + +using namespace Slic3r; + +TEST_CASE("parse_mixed_components reads 1-based component ids", "[FilamentMixer]") +{ + REQUIRE(parse_mixed_components("1,3") == std::vector{1, 3}); + REQUIRE(parse_mixed_components("2, 4 ,5") == std::vector{2, 4, 5}); + + SECTION("Malformed input yields no components") { + REQUIRE(parse_mixed_components("").empty()); + REQUIRE(parse_mixed_components("abc").empty()); + } +} + +TEST_CASE("parse_mixed_ratios normalizes to sum 1.0", "[FilamentMixer]") +{ + auto r = parse_mixed_ratios("0.7,0.3", 2); + REQUIRE(r.size() == 2); + REQUIRE_THAT(r[0], Catch::Matchers::WithinAbs(0.7, 1e-9)); + REQUIRE_THAT(r[1], Catch::Matchers::WithinAbs(0.3, 1e-9)); + + SECTION("Unnormalized input is rescaled") { + auto v = parse_mixed_ratios("2,2", 2); + REQUIRE_THAT(v[0], Catch::Matchers::WithinAbs(0.5, 1e-9)); + REQUIRE_THAT(v[1], Catch::Matchers::WithinAbs(0.5, 1e-9)); + } + + SECTION("Empty or mismatched input falls back to equal shares") { + auto v = parse_mixed_ratios("", 3); + REQUIRE(v.size() == 3); + for (double x : v) + REQUIRE_THAT(x, Catch::Matchers::WithinAbs(1.0 / 3.0, 1e-9)); + } +} + +TEST_CASE("has_any_mixed_filament detects mixed slots", "[FilamentMixer]") +{ + REQUIRE_FALSE(has_any_mixed_filament({})); + REQUIRE_FALSE(has_any_mixed_filament({0, 0, 0})); + REQUIRE(has_any_mixed_filament({0, 1, 0})); +} + +TEST_CASE("expand_mixed_filaments replaces mixed slots with their components", "[FilamentMixer]") +{ + // Slot 2 (0-based) is a mix of physical filaments 1 and 2 (1-based) => 0 and 1 (0-based). + const std::vector is_mixed = {0, 0, 1}; + const std::vector comp_strs = {"", "", "1,2"}; + + REQUIRE(expand_mixed_filaments({2}, is_mixed, comp_strs) == std::vector{0, 1}); + + SECTION("Non-mixed entries pass through, result is sorted and deduplicated") { + REQUIRE(expand_mixed_filaments({2, 0}, is_mixed, comp_strs) == std::vector{0, 1}); + } +} + +TEST_CASE("check_mixed_filament_integrity flags dangling component references", "[FilamentMixer]") +{ + const std::vector is_mixed = {0, 0, 1}; + + SECTION("All components resolve") { + REQUIRE(check_mixed_filament_integrity(is_mixed, {"", "", "1,2"}, 2).empty()); + } + + SECTION("A component past the physical filament count is broken") { + auto broken = check_mixed_filament_integrity(is_mixed, {"", "", "1,9"}, 2); + REQUIRE(broken == std::vector{2}); + } +} + +TEST_CASE("remap_mixed_components_on_delete rewrites ids around the deleted slot", "[FilamentMixer]") +{ + const std::vector is_mixed = {0, 0, 0, 1}; + std::vector comps = {"", "", "", "1,3"}; + + SECTION("Deleting a filament below the references shifts them down") { + remap_mixed_components_on_delete(is_mixed, comps, 2); + REQUIRE(comps[3] == "1,2"); + } + + SECTION("Deleting a referenced filament zeroes that component") { + remap_mixed_components_on_delete(is_mixed, comps, 1); + // 1 -> 0 (deleted sentinel), 3 -> 2 + REQUIRE(comps[3] == "0,2"); + } +} + +TEST_CASE("check_mixed_filament_type_consistency flags mismatched component types", "[FilamentMixer]") +{ + const std::vector is_mixed = {0, 0, 1}; + const std::vector comp_strs = {"", "", "1,2"}; + + REQUIRE(check_mixed_filament_type_consistency(is_mixed, comp_strs, {"PLA", "PLA"}).empty()); + + auto bad = check_mixed_filament_type_consistency(is_mixed, comp_strs, {"PLA", "PETG"}); + REQUIRE(bad == std::vector{2}); +} + +TEST_CASE("a support-flagged component reads as its own filament type for the consistency check", "[FilamentMixer]") +{ + // The sidebar derives each component's type through DynamicPrintConfig::get_filament_type, + // which folds filament_is_support into the type, so toggling that flag alone flips the + // verdict and the mixed filament list has to be refreshed on filament_is_support too. + DynamicPrintConfig plain_pla; + plain_pla.set_key_value("filament_type", new ConfigOptionStrings({"PLA"})); + plain_pla.set_key_value("filament_is_support", new ConfigOptionBools({false})); + std::string displayed; + REQUIRE(plain_pla.get_filament_type(displayed) == "PLA"); + + DynamicPrintConfig support_pla; + support_pla.set_key_value("filament_type", new ConfigOptionStrings({"PLA"})); + support_pla.set_key_value("filament_is_support", new ConfigOptionBools({true})); + REQUIRE(support_pla.get_filament_type(displayed) == "PLA-S"); + REQUIRE(displayed == "Sup.PLA"); + + const std::vector is_mixed = {0, 0, 1}; + const std::vector comp_strs = {"", "", "1,2"}; + REQUIRE(check_mixed_filament_type_consistency(is_mixed, comp_strs, {"PLA", "PLA-S"}) == std::vector{2}); +} + +TEST_CASE("gradient curves round-trip and sample monotonically", "[FilamentMixer]") +{ + SECTION("Empty input yields an empty curve") { + REQUIRE(parse_gradient_curve("").empty()); + REQUIRE(serialize_gradient_curve(GradientCurve{}).empty()); + } + + SECTION("Legacy 2-field anchors survive a parse/serialize round trip") { + GradientCurve c = parse_gradient_curve("0,0.15|0.5,0.5|1,0.85"); + REQUIRE(c.points.size() == 3); + + // Anchors with no tangent override serialize back to the 2-field legacy form + // (canonical fixed-precision, so compare by re-parsing rather than by string). + const std::string round_tripped = serialize_gradient_curve(c); + REQUIRE(round_tripped.find(",nan") == std::string::npos); + + GradientCurve c2 = parse_gradient_curve(round_tripped); + REQUIRE(c2.points.size() == c.points.size()); + for (size_t i = 0; i < c.points.size(); ++i) { + REQUIRE_THAT(c2.points[i].x, Catch::Matchers::WithinAbs(c.points[i].x, 1e-4)); + REQUIRE_THAT(c2.points[i].y, Catch::Matchers::WithinAbs(c.points[i].y, 1e-4)); + } + } + + SECTION("Sampling is clamped at the ends and monotone in between") { + GradientCurve c = parse_gradient_curve("0,0.15|0.5,0.5|1,0.85"); + REQUIRE_THAT(sample_gradient_curve(c, 0.0), Catch::Matchers::WithinAbs(0.15, 1e-9)); + REQUIRE_THAT(sample_gradient_curve(c, 1.0), Catch::Matchers::WithinAbs(0.85, 1e-9)); + // Outside the control point range the end values are held. + REQUIRE_THAT(sample_gradient_curve(c, -1.0), Catch::Matchers::WithinAbs(0.15, 1e-9)); + REQUIRE_THAT(sample_gradient_curve(c, 2.0), Catch::Matchers::WithinAbs(0.85, 1e-9)); + + double prev = sample_gradient_curve(c, 0.0); + for (int i = 1; i <= 20; ++i) { + double v = sample_gradient_curve(c, i / 20.0); + REQUIRE(v >= prev - 1e-9); + prev = v; + } + } + + SECTION("A curve with fewer than two points falls back to 0.5") { + GradientCurve c = parse_gradient_curve("0.5,0.7"); + REQUIRE_THAT(sample_gradient_curve(c, 0.3), Catch::Matchers::WithinAbs(0.5, 1e-9)); + } +} + +TEST_CASE("blend_color mixes two hex colors", "[FilamentMixer]") +{ + // ratio 0 keeps the first color, ratio 1 the second. + REQUIRE(blend_color("#FF0000", "#0000FF", 0.0f) == "#FF0000"); + REQUIRE(blend_color("#FF0000", "#0000FF", 1.0f) == "#0000FF"); + + SECTION("Blue and yellow make green, not grey (pigment mixing)") { + // The polynomial model approximates subtractive pigment behaviour. + std::string mixed = blend_color("#0021D0", "#FCD300", 0.5f); + REQUIRE(mixed.size() == 7); + REQUIRE(mixed[0] == '#'); + auto comp = [&](int i) { return std::stoi(mixed.substr(1 + 2 * i, 2), nullptr, 16); }; + // Green channel should dominate red and blue. + REQUIRE(comp(1) > comp(0)); + REQUIRE(comp(1) > comp(2)); + } +} + +TEST_CASE("blend_color_multi weights components", "[FilamentMixer]") +{ + SECTION("A single component is returned unchanged") { + REQUIRE(blend_color_multi({"#FF0000"}, {1}) == "#FF0000"); + } + + SECTION("Mixing a color with itself stays close to that color") { + // The mixer is a degree-4 polynomial fit of pigment behaviour, so mixing a color with + // itself lands near it rather than exactly on it; allow a small per-channel drift. + std::string mixed = blend_color_multi({"#123456", "#123456"}, {1, 1}); + REQUIRE(mixed.size() == 7); + auto comp = [](const std::string &hex, int i) { + return std::stoi(hex.substr(1 + 2 * i, 2), nullptr, 16); + }; + for (int i = 0; i < 3; ++i) + REQUIRE(std::abs(comp(mixed, i) - comp("#123456", i)) <= 8); + } +} diff --git a/tests/libslic3r/test_fill_corner_smoothing.cpp b/tests/libslic3r/test_fill_corner_smoothing.cpp index f2c25e816d..a9e752f250 100644 --- a/tests/libslic3r/test_fill_corner_smoothing.cpp +++ b/tests/libslic3r/test_fill_corner_smoothing.cpp @@ -171,3 +171,24 @@ TEST_CASE("Corner smoothing keeps the ends of a path that returns to its start", REQUIRE(retrace.front() == sharp.front()); REQUIRE(retrace.back() == sharp.back()); } + +TEST_CASE("Corner smoothing ignores vertices splitting a straight leg", "[FillCornerSmoothing][Regression]") +{ + // The triangular and grid infills emit a vertex halfway along the straight run joining two of + // their corners. Measuring the legs up to that vertex instead of up to the next corner let the + // rounding reach only half as far there as it did into the very same run elsewhere in the + // pattern, so geometrically identical corners came out rounded to different radii. + const Polyline plain{ Point::new_scale(0., 20.), Point::new_scale(10., 0.), + Point::new_scale(20., 0.), Point::new_scale(30., 20.) }; + Polyline split = plain; + split.points.insert(split.points.begin() + 2, Point::new_scale(15., 0.)); + + Polyline smooth_plain = plain; + smooth_polyline_corners(smooth_plain, 1., tolerance); + Polyline smooth_split = split; + smooth_polyline_corners(smooth_split, 1., tolerance); + + REQUIRE(smooth_split.points == smooth_plain.points); + // Both corners reach the middle of the 10mm run they share, which the extra vertex sat on. + REQUIRE(contains(smooth_plain, Point::new_scale(15., 0.))); +} diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 037a76a805..55c18bfa9e 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -566,3 +566,327 @@ TEST_CASE("A printer specific filament supersedes the generic library filament w CHECK(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_c, nullptr))); } + +namespace { + +const char *kMixedKeys[] = { + "filament_is_mixed", + "filament_mixed_components", + "filament_mixed_sublayer_ratios", + "filament_mixed_gradient", + "filament_mixed_gradient_range", + "filament_mixed_gradient_curve", + "filament_mixed_gradient_per_part", +}; + +} // namespace + +// Mixed-color filament metadata lives in project_config as parallel per-filament arrays. +// set_num_filaments() is the single place that grows them alongside filament_colour; if it +// misses them, creating a mixed slot writes past the end of the short arrays. +TEST_CASE("set_num_filaments keeps mixed-color arrays in step with the filament count", "[Preset][Bundle][FilamentMixer]") +{ + auto mixed_array_size = [](const DynamicPrintConfig &cfg, const std::string &key) -> size_t { + if (const auto *b = cfg.option(key)) + return b->values.size(); + if (const auto *s = cfg.option(key)) + return s->values.size(); + return size_t(-1); // key missing entirely + }; + + PresetBundle bundle; + + const unsigned int n = GENERATE(2u, 4u, 8u); + bundle.set_num_filaments(n, std::string("#FF0000")); + + REQUIRE(bundle.project_config.option("filament_colour")->values.size() == n); + for (const char *key : kMixedKeys) { + DYNAMIC_SECTION("grown: " << key) { + CHECK(mixed_array_size(bundle.project_config, key) == n); + } + } + + SECTION("shrinking keeps them in step too") { + bundle.set_num_filaments(1, std::string("#00FF00")); + REQUIRE(bundle.project_config.option("filament_colour")->values.size() == 1); + for (const char *key : kMixedKeys) + CHECK(mixed_array_size(bundle.project_config, key) == 1); + } +} + +// A mix is described by 1-based indices into the project's filament list, which Orca rebuilds +// from the selected printer's snapshot (filament_%02u / filament_colors) at startup and on every +// printer selection. Held anywhere but that same per-printer snapshot, the mixed arrays end up +// indexing a filament list they were never saved against. +TEST_CASE("Mixed-color filament metadata is snapshotted per printer, with its filament list", "[Preset][Bundle][FilamentMixer]") +{ + PresetBundle bundle; + // export_selections skips the built-in "Default Printer" placeholder entirely. + add_inmemory_preset(bundle.printers, "Test Printer"); + bundle.printers.select_preset_by_name("Test Printer", true); + bundle.set_num_filaments(2u, std::string("#FF0000")); + bundle.project_config.option("filament_is_mixed")->values = { false, true }; + bundle.project_config.option("filament_mixed_components")->values = { "", "1,2" }; + bundle.project_config.option("filament_mixed_sublayer_ratios")->values = { "", "0.5,0.5" }; + + AppConfig app_config; + bundle.export_selections(app_config); + + const std::string printer_name = bundle.printers.get_selected_preset_name(); + for (const char *key : kMixedKeys) { + DYNAMIC_SECTION("per printer, not global: " << key) { + CHECK(app_config.has_printer_setting(printer_name, key)); + CHECK_FALSE(app_config.has("presets", key)); + } + } + + SECTION("with the encoding load_selections reads back") { + CHECK(app_config.get_printer_setting(printer_name, "filament_is_mixed") == "0,1"); + CHECK(app_config.get_printer_setting(printer_name, "filament_mixed_components") == "|1,2"); + CHECK(app_config.get_printer_setting(printer_name, "filament_mixed_sublayer_ratios") == "|0.5,0.5"); + } +} + +// The gradient curve is the one mixed array whose values contain '|' themselves — it separates the +// control points — so it cannot be '|'-joined into the app config like its siblings without a +// multi-point curve being split across filament slots on the way back in. +TEST_CASE("A multi-point gradient curve survives the app-config snapshot", "[Preset][Bundle][FilamentMixer]") +{ + const std::vector curves = { "", "", "0,0|0.5,0.3|1,1" }; + + PresetBundle bundle; + add_inmemory_preset(bundle.printers, "Test Printer"); + bundle.printers.select_preset_by_name("Test Printer", true); + bundle.set_num_filaments(3u, std::string("#FF0000")); + bundle.project_config.option("filament_mixed_gradient_curve")->values = curves; + + AppConfig app_config; + bundle.export_selections(app_config); + + // Decoding the stored form returns the three slots intact, curve delimiters and all. A plain + // '|' join would decode as five slots here instead of three. + std::vector decoded; + REQUIRE(unescape_strings_cstyle( + app_config.get_printer_setting(bundle.printers.get_selected_preset_name(), "filament_mixed_gradient_curve"), decoded)); + CHECK(decoded == curves); +} + +// A multi-tool printer sizes the filament list from its nozzle count. Mixed-color slots are extra +// virtual filaments at the tail of that list with no nozzle of their own, so the count has to +// allow for them: sizing to the nozzle count alone drops the project's mixes and strips every +// painted facet above the new count. +TEST_CASE("Sizing the filament list to a multi-tool nozzle count keeps mixed slots", "[Preset][Bundle][FilamentMixer]") +{ + // The 5-slot layout of a 4-tool project carrying one mix of filaments 2 and 3. + const size_t nozzle_count = 4; + PresetBundle bundle; + bundle.set_num_filaments(5u, std::string("#FF0000")); + bundle.project_config.option("filament_is_mixed")->values = + { false, false, false, false, true }; + bundle.project_config.option("filament_mixed_components")->values = + { "", "", "", "", "2,3" }; + + REQUIRE(bundle.num_mixed_filaments() == 1); + + SECTION("nozzle count plus the mixed slots preserves the mix") { + bundle.set_num_filaments(nozzle_count + bundle.num_mixed_filaments(), std::string("#00FF00")); + + CHECK(bundle.filament_presets.size() == 5); + CHECK(bundle.num_mixed_filaments() == 1); + CHECK(bundle.is_mixed_filament(4)); + CHECK(bundle.project_config.option("filament_mixed_components")->values[4] == "2,3"); + } + + SECTION("the nozzle count alone is what truncated it away") { + bundle.set_num_filaments(nozzle_count, std::string("#00FF00")); + + CHECK(bundle.filament_presets.size() == nozzle_count); + CHECK(bundle.num_mixed_filaments() == 0); + } +} + +// The nozzle-count top-up in update_multi_material_filament_presets() grows filament_presets on +// its own, so a physical count derived from that list reports a slot no per-filament array has +// yet. That is what made the extruder-count handler conclude there was nothing to add and leave +// the new sidebar combo with no colour to draw. +TEST_CASE("The physical filament count is not fooled by a lone filament_presets top-up", "[Preset][Bundle][FilamentMixer]") +{ + PresetBundle bundle; + + SECTION("no mixed slots") { + bundle.set_num_filaments(4u, std::string("#FF0000")); + bundle.printers.get_edited_preset().config.option("nozzle_diameter", true)->values = + { 0.4, 0.4, 0.4, 0.4, 0.4 }; + bundle.update_multi_material_filament_presets(); + + REQUIRE(bundle.filament_presets.size() == 5); // the top-up moved this list on its own + REQUIRE(bundle.project_config.option("filament_colour")->values.size() == 4); + CHECK(bundle.num_physical_filaments() == 4); + } + + SECTION("behind a mixed tail") { + bundle.set_num_filaments(5u, std::string("#FF0000")); + bundle.project_config.option("filament_is_mixed")->values = + { false, false, false, false, true }; + bundle.printers.get_edited_preset().config.option("nozzle_diameter", true)->values = + { 0.4, 0.4, 0.4, 0.4, 0.4, 0.4 }; + bundle.update_multi_material_filament_presets(); + + REQUIRE(bundle.filament_presets.size() == 6); + REQUIRE(bundle.project_config.option("filament_colour")->values.size() == 5); + CHECK(bundle.num_physical_filaments() == 4); + CHECK(bundle.num_mixed_filaments() == 1); + } +} + +// Which slots are new is a fact about the per-filament arrays, not about filament_presets, for the +// same reason. Keyed off the wrong one, a freshly opened slot silently keeps filament 1's colour. +TEST_CASE("New filament colours are placed by array position", "[Preset][Bundle][FilamentMixer]") +{ + PresetBundle bundle; + bundle.set_num_filaments(4u, std::string("#FF0000")); + bundle.printers.get_edited_preset().config.option("nozzle_diameter", true)->values = + { 0.4, 0.4, 0.4, 0.4, 0.4 }; + bundle.update_multi_material_filament_presets(); + REQUIRE(bundle.filament_presets.size() == 5); + REQUIRE(bundle.project_config.option("filament_colour")->values.size() == 4); + + // The call Sidebar::add_custom_filament makes once the extruder count opens a slot. + bundle.set_num_filaments(5u, std::string("#00FF00")); + + const auto &colours = bundle.project_config.option("filament_colour")->values; + REQUIRE(colours.size() == 5); + CHECK(colours[4] == "#00FF00"); // not colours[0], which resize() would have padded with +} + +// The mixed-slot flags are written into the app config on exit and read back on the next start. +// If the read side loses them the slots survive as filaments but stop being mixes, so the project +// comes back with the mix showing as an ordinary physical filament. +TEST_CASE("A saved mix is still a mix after an app restart", "[Preset][Bundle][FilamentMixer]") +{ + AppConfig app_config; + + // Last session: a 4-tool project carrying one mix of filaments 2 and 3 at the tail. + { + PresetBundle bundle; + add_inmemory_preset(bundle.printers, "Test Printer"); + bundle.printers.select_preset_by_name("Test Printer", true); + add_inmemory_preset(bundle.filaments, "Test Filament"); + bundle.filaments.select_preset_by_name("Test Filament", true); + bundle.set_num_filaments(5u, std::string("#FF0000")); + bundle.filament_presets.assign(5, "Test Filament"); + bundle.project_config.option("filament_is_mixed")->values = + { false, false, false, false, true }; + bundle.project_config.option("filament_mixed_components")->values = + { "", "", "", "", "2,3" }; + bundle.export_selections(app_config); + + REQUIRE(app_config.get_printer_setting("Test Printer", "filament_is_mixed") == "0,0,0,0,1"); + } + + // This session. + PresetBundle bundle; + add_inmemory_preset(bundle.printers, "Test Printer"); + add_inmemory_preset(bundle.filaments, "Test Filament"); + bundle.load_selections(app_config); + + CHECK(bundle.filament_presets.size() == 5); + CHECK(bundle.num_mixed_filaments() == 1); + CHECK(bundle.is_mixed_filament(4)); + CHECK(bundle.project_config.option("filament_mixed_components")->values[4] == "2,3"); +} + +// The same restart, on the printer shape that actually shows the bug: a 4-tool changer whose +// saved filament list is one longer than its nozzle count, because the extra slot is the mix. +TEST_CASE("A saved mix survives a restart on a multi-tool printer", "[Preset][Bundle][FilamentMixer]") +{ + auto make_toolchanger = [](PresetBundle &bundle) -> Preset & { + Preset &p = add_inmemory_preset(bundle.printers, "Tool Changer"); + p.config.option("nozzle_diameter", true)->values = { 0.4, 0.4, 0.4, 0.4 }; + p.config.option("single_extruder_multi_material", true)->value = false; + return p; + }; + + AppConfig app_config; + { + PresetBundle bundle; + make_toolchanger(bundle); + bundle.printers.select_preset_by_name("Tool Changer", true); + add_inmemory_preset(bundle.filaments, "Test Filament"); + bundle.filaments.select_preset_by_name("Test Filament", true); + bundle.set_num_filaments(5u, std::string("#FF0000")); + bundle.filament_presets.assign(5, "Test Filament"); + bundle.project_config.option("filament_is_mixed")->values = + { false, false, false, false, true }; + bundle.project_config.option("filament_mixed_components")->values = + { "", "", "", "", "1,2" }; + bundle.export_selections(app_config); + REQUIRE(app_config.get_printer_setting("Tool Changer", "filament_is_mixed") == "0,0,0,0,1"); + } + + PresetBundle bundle; + make_toolchanger(bundle); + add_inmemory_preset(bundle.filaments, "Test Filament"); + bundle.load_selections(app_config); + + CHECK(bundle.filament_presets.size() == 5); + CHECK(bundle.num_mixed_filaments() == 1); + CHECK(bundle.is_mixed_filament(4)); + + SECTION("and through the GUI startup calls that follow it") { + // GUI_App::load_current_presets sizes the list for a non-SEMM printer, growing only. + const size_t target = 4u + bundle.num_mixed_filaments(); + if (target > bundle.filament_presets.size()) + bundle.set_num_filaments(target); + CHECK(bundle.num_mixed_filaments() == 1); + + // TabPrinter::extruders_count_changed. + bundle.on_extruders_count_changed(4); + CHECK(bundle.num_mixed_filaments() == 1); + + // Tab::select_preset re-reads the snapshot when remember_printer_config is on. + bundle.update_selections(app_config); + CHECK(bundle.filament_presets.size() == 5); + CHECK(bundle.num_mixed_filaments() == 1); + CHECK(bundle.is_mixed_filament(4)); + } +} + +// The startup sizing in GUI_App::load_current_presets targets the nozzle count plus the mixes. +// That is a floor, never a ceiling: set_num_filaments() trims at the raw tail, which is exactly +// where the mixes live, so applying the target to a longer list deletes them. A list longer than +// the target is reachable - raising the extruder count without saving the printer preset leaves +// the extra physical slot behind on the next start - so the startup sizing must only ever grow. +TEST_CASE("Sizing down to the nozzle count plus mixes is what eats the mixed tail", "[Preset][Bundle][FilamentMixer]") +{ + // 5 physical + 1 mix, on a printer preset still reporting 4 nozzles. + const size_t nozzle_count = 4; + PresetBundle bundle; + bundle.set_num_filaments(6u, std::string("#FF0000")); + bundle.project_config.option("filament_is_mixed")->values = + { false, false, false, false, false, true }; + bundle.project_config.option("filament_mixed_components")->values = + { "", "", "", "", "", "1,2" }; + REQUIRE(bundle.num_physical_filaments() == 5); + + const size_t target = nozzle_count + bundle.num_mixed_filaments(); + REQUIRE(target < bundle.filament_presets.size()); + + SECTION("applied as written, the mix is gone and every slot reads physical") { + bundle.set_num_filaments(target); + + CHECK(bundle.filament_presets.size() == target); + CHECK(bundle.num_mixed_filaments() == 0); + CHECK(bundle.num_physical_filaments() == target); + } + + SECTION("applied as a floor, the mix is left alone") { + if (target > bundle.filament_presets.size()) + bundle.set_num_filaments(target); + + CHECK(bundle.filament_presets.size() == 6); + CHECK(bundle.num_mixed_filaments() == 1); + CHECK(bundle.is_mixed_filament(5)); + CHECK(bundle.project_config.option("filament_mixed_components")->values[5] == "1,2"); + } +} diff --git a/tests/libslic3r/test_step.cpp b/tests/libslic3r/test_step.cpp new file mode 100644 index 0000000000..a2aa7218f1 --- /dev/null +++ b/tests/libslic3r/test_step.cpp @@ -0,0 +1,93 @@ +#include + +#include + +#include "libslic3r/Model.hpp" +#include "libslic3r/Format/STEP.hpp" +#include "test_utils.hpp" + +using namespace Slic3r; + +static void write_step_line(const std::string &path, const std::string &line) +{ + boost::nowide::ofstream file(path, std::ios::binary); + file << "ISO-10303-21;\n" << line << "\nEND-ISO-10303-21;\n"; +} + +// preprocess() hands back the input path unless it transcoded into a temporary. +static std::string preprocess_result(const std::string &line) +{ + ScopedSlic3rTemporaryDir scratch; + + ScopedTemporaryFile step(".step"); + write_step_line(step.string(), line); + + std::string output_path; + StepPreProcessor preprocessor; + REQUIRE(preprocessor.preprocess(step.string().c_str(), output_path)); + + return output_path == step.string() ? "untouched" : "transcoded"; +} + +// data/utf8_part_names.step is three boxes written by OCCT's own STEP writer, whose +// PRODUCT names were then patched to raw UTF-8. Most CAD exporters write non-ASCII names +// that way rather than in the \X2\ escape form. The third part is ASCII, as a control. +TEST_CASE("Part names with multi-byte UTF-8 survive import", "[Step]") +{ + // getNamedSolids() replaces a name that isUtf8() rejects with a running number. + const std::string path = TEST_DATA_DIR PATH_SEPARATOR "utf8_part_names.step"; + + Model model; + bool cancel = false; + Step step(path); // no isUtf8Fn, matching how Model::read_from_step builds it + + REQUIRE(step.load() == Step::Step_Status::LOAD_SUCCESS); + REQUIRE(step.mesh(&model, cancel, false) == Step::Step_Status::MESH_SUCCESS); + + REQUIRE(model.objects.size() == 1); + const ModelObject *object = model.objects.front(); + REQUIRE(object->volumes.size() == 3); + // "ce" is split off, or the hex escape would swallow it as further hex digits. + CHECK(object->volumes[0]->name == "pi\xC3\xA8" "ce"); + CHECK(object->volumes[1]->name == "Geh\xC3\xA4use"); + CHECK(object->volumes[2]->name == "bracket"); +} + +TEST_CASE("isUtf8 recognises two, three and four byte sequences", "[Step]") +{ + CHECK(StepPreProcessor::isUtf8("\xC3\xA9")); // U+00E9 + CHECK(StepPreProcessor::isUtf8("\xE4\xB8\xAD")); // U+4E2D + CHECK(StepPreProcessor::isUtf8("\xF0\x9F\x94\xA9")); // U+1F529 + CHECK_FALSE(StepPreProcessor::isUtf8("\x81\x30")); // 0x81 is not a lead byte + CHECK_FALSE(StepPreProcessor::isUtf8("\xC3")); // truncated sequence +} + +// The only caller of isGBK is preprocess(), which nothing calls today. +TEST_CASE("Encoding detection decides whether a step file is transcoded", "[Step]") +{ + SECTION("UTF-8, so left alone") + { + // A two byte sequence also satisfies every GBK range, so misdetecting it as + // not-UTF-8 sends it to be transcoded. + const std::string sequence = GENERATE(std::string("\xC3\xA9"), // U+00E9 + std::string("\xE4\xB8\xAD"), // U+4E2D + std::string("\xF0\x9F\x94\xA9")); // U+1F529 + + CHECK(preprocess_result("NAME('" + sequence + "');") == "untouched"); + } + + SECTION("neither UTF-8 nor GBK, so left alone") + { + // 0x81 is not a UTF-8 lead byte, and 0x30 is below the 0x40 floor for a GBK trail. + CHECK(preprocess_result("NAME('\x81\x30');") == "untouched"); + } + + SECTION("GBK, so transcoded") + { + // U+554A in GBK, whose lead byte is not valid UTF-8. Pins the other direction, + // since a detector that never reports GBK would pass every case above. + CHECK(preprocess_result("NAME('\xB0\xA1');") == "transcoded"); + } + + SECTION("plain ASCII, so left alone") { CHECK(preprocess_result("NAME('bracket');") == "untouched"); } +} diff --git a/tests/libslic3r/test_triangle_selector.cpp b/tests/libslic3r/test_triangle_selector.cpp new file mode 100644 index 0000000000..fd2ab9efa8 --- /dev/null +++ b/tests/libslic3r/test_triangle_selector.cpp @@ -0,0 +1,125 @@ +#include + +#include "libslic3r/TriangleSelector.hpp" +#include "libslic3r/TriangleMesh.hpp" + +using namespace Slic3r; + +// A sphere gives well over ExtruderMax original facets, so every extruder state can be assigned +// to a facet of its own without any splitting getting in the way. +static TriangleMesh test_mesh() { return make_sphere(5., 2 * PI / 24); } + +// Read the nibble_idx-th 4-bit group of a serialized bitstream, least significant bit first. +static int nibble_at(const std::vector &bitstream, size_t nibble_idx) +{ + int n = 0; + for (size_t bit = 0; bit < 4; ++bit) + n |= int(bitstream[nibble_idx * 4 + bit]) << bit; + return n; +} + +TEST_CASE("Every extruder state survives a serialize/deserialize round trip", "[TriangleSelector]") +{ + const TriangleMesh mesh = test_mesh(); + const int max_state = int(EnforcerBlockerType::ExtruderMax); + REQUIRE(int(mesh.its.indices.size()) >= max_state); + + TriangleSelector selector(mesh); + for (int state = 1; state <= max_state; ++state) + selector.set_facet(state - 1, EnforcerBlockerType(state)); + + TriangleSelector restored(mesh); + restored.deserialize(selector.serialize()); + + for (int state = 1; state <= max_state; ++state) { + INFO("Extruder " << state); + REQUIRE(restored.has_facets(EnforcerBlockerType(state))); + REQUIRE(restored.num_facets(EnforcerBlockerType(state)) == 1); + } +} + +TEST_CASE("Serialized data reports the extruder states it uses", "[TriangleSelector]") +{ + const TriangleMesh mesh = test_mesh(); + TriangleSelector selector(mesh); + selector.set_facet(0, EnforcerBlockerType::Extruder16); + selector.set_facet(1, EnforcerBlockerType::Extruder32); + + const TriangleSelector::TriangleSplittingData data = selector.serialize(); + + REQUIRE(data.used_states.size() == size_t(EnforcerBlockerType::ExtruderMax) + 1); + REQUIRE(data.used_states[size_t(EnforcerBlockerType::Extruder16)]); + REQUIRE(data.used_states[size_t(EnforcerBlockerType::Extruder32)]); + REQUIRE_FALSE(data.used_states[size_t(EnforcerBlockerType::Extruder17)]); + + SECTION("used_states recomputed from the bitstream agrees") { + TriangleSelector::TriangleSplittingData recomputed = data; + recomputed.reset_used_states(); + recomputed.update_used_states(0); + REQUIRE(recomputed.used_states == data.used_states); + } + + SECTION("has_facets on the raw data agrees") { + REQUIRE(TriangleSelector::has_facets(data, EnforcerBlockerType::Extruder32)); + REQUIRE_FALSE(TriangleSelector::has_facets(data, EnforcerBlockerType::Extruder17)); + } +} + +// States 3..17 must keep the pre-existing encoding ("11" prefix plus one nibble of state-3) so +// projects written by older builds stay readable and newly written ones stay readable by them. +TEST_CASE("Extruder states up to 17 keep the single-nibble encoding", "[TriangleSelector]") +{ + const int state = GENERATE(3, 8, 16, 17); + + TriangleSelector selector(test_mesh()); + selector.set_facet(0, EnforcerBlockerType(state)); + const std::vector bitstream = selector.serialize().bitstream; + + INFO("Extruder " << state); + // Two nibbles: the "11"-prefixed leaf code, then the state itself. + REQUIRE(bitstream.size() == 8); + REQUIRE(nibble_at(bitstream, 0) == 0b1100); + REQUIRE(nibble_at(bitstream, 1) == state - 3); +} + +// States 18 and above set the state nibble to 0b1111 and carry (state-18) in one more nibble. +TEST_CASE("Extruder states above 17 are encoded in a second nibble", "[TriangleSelector]") +{ + const int state = GENERATE(18, 25, 32); + + TriangleSelector selector(test_mesh()); + selector.set_facet(0, EnforcerBlockerType(state)); + const std::vector bitstream = selector.serialize().bitstream; + + INFO("Extruder " << state); + REQUIRE(bitstream.size() == 12); + REQUIRE(nibble_at(bitstream, 0) == 0b1100); + REQUIRE(nibble_at(bitstream, 1) == 0b1111); + REQUIRE(nibble_at(bitstream, 2) == state - 18); +} + +// Model.cpp writes these hex strings into the 3MF for colored mesh imports; the selector must +// decode exactly the states CONST_FILAMENTS assigns to them. +TEST_CASE("Extruder states match the CONST_FILAMENTS hex encoding", "[TriangleSelector]") +{ + struct Case { const char *hex; int state; }; + const auto c = GENERATE(values({ + {"8", 2}, {"0C", 3}, {"DC", 16}, {"EC", 17}, {"0FC", 18}, {"EFC", 32}, + })); + + // get_triangle_as_string emits the nibbles most significant first, so read the hex backwards. + const std::string hex = c.hex; + std::vector bitstream; + for (auto it = hex.rbegin(); it != hex.rend(); ++it) { + const int nibble = *it >= 'A' ? (*it - 'A' + 10) : (*it - '0'); + for (int bit = 0; bit < 4; ++bit) + bitstream.push_back((nibble >> bit) & 1); + } + + TriangleSelector::TriangleSplittingData data; + data.triangles_to_split.emplace_back(0, 0); + data.bitstream = bitstream; + + INFO("Hex " << c.hex << " -> extruder " << c.state); + REQUIRE(TriangleSelector::has_facets(data, EnforcerBlockerType(c.state))); +} diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 16c19910ee..6f53b0329c 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -2,6 +2,7 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME) add_executable(${_TEST_NAME}_tests ${_TEST_NAME}_tests_main.cpp test_dev_mapping.cpp + test_filament_bitmap_utils.cpp test_device_progress.cpp test_network_versions.cpp test_action_source.cpp diff --git a/tests/slic3rutils/test_filament_bitmap_utils.cpp b/tests/slic3rutils/test_filament_bitmap_utils.cpp new file mode 100644 index 0000000000..997a119521 --- /dev/null +++ b/tests/slic3rutils/test_filament_bitmap_utils.cpp @@ -0,0 +1,256 @@ +// recompute_mixed_slot_colors lives in libslic3r_gui; this is the only suite that links it. +// Same Windows include prologue as test_dev_mapping.cpp (wx pulls in ; keep +// WIN32_LEAN_AND_MEAN / NOMINMAX ahead of the Catch2 headers). +#ifdef WIN32 + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #ifndef NOMINMAX + #define NOMINMAX + #endif + #include +#endif + +#include + +#include + +#include +#include + +#include "libslic3r/FilamentMixer.hpp" +#include "libslic3r/PrintConfig.hpp" +#include "slic3r/GUI/FilamentBitmapUtils.hpp" + +using namespace Slic3r; +using Slic3r::GUI::recompute_mixed_slot_colors; + +namespace { + +// Two physical slots (1 = red, 2 = blue) and mixed slot 3 built from them. +DynamicPrintConfig mixed_config(const std::string& components = "1,2", const std::string& ratios = "0.5,0.5") +{ + DynamicPrintConfig cfg; + cfg.set_key_value("filament_is_mixed", new ConfigOptionBools({false, false, true})); + cfg.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", "", components})); + cfg.set_key_value("filament_mixed_sublayer_ratios", new ConfigOptionStrings({"", "", ratios})); + cfg.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false, false, false})); + cfg.set_key_value("filament_colour", new ConfigOptionStrings({"#FF0000", "#0000FF", "#000000"})); + return cfg; +} + +wxColour expected_blend(const std::vector& hex, const std::vector& weights) +{ + return wxColour(wxString(blend_color_multi(hex, weights))); +} + +// Compare channels one at a time so a failure names the channel. +void require_same_rgb(const wxColour& actual, const wxColour& expected) +{ + REQUIRE(int(actual.Red()) == int(expected.Red())); + REQUIRE(int(actual.Green()) == int(expected.Green())); + REQUIRE(int(actual.Blue()) == int(expected.Blue())); +} + +} // namespace + +TEST_CASE("recompute_mixed_slot_colors blends a mixed slot from its components' colours", "[FilamentBitmapUtils]") +{ + std::vector colors{wxColour(255, 0, 0), wxColour(0, 0, 255)}; + recompute_mixed_slot_colors(colors, mixed_config()); + + REQUIRE(colors.size() == 3); + require_same_rgb(colors[2], expected_blend({"#FF0000", "#0000FF"}, {5000, 5000})); + REQUIRE(int(colors[2].Alpha()) == 255); + // Physical slots are left alone. + require_same_rgb(colors[0], wxColour(255, 0, 0)); + require_same_rgb(colors[1], wxColour(0, 0, 255)); +} + +TEST_CASE("recompute_mixed_slot_colors leaves the colours alone without mixed slots", "[FilamentBitmapUtils]") +{ + std::vector colors{wxColour(255, 0, 0), wxColour(0, 0, 255)}; + + SECTION("no mixed keys at all") { + recompute_mixed_slot_colors(colors, DynamicPrintConfig{}); + } + SECTION("mixed flags present but all false") { + DynamicPrintConfig cfg; + cfg.set_key_value("filament_is_mixed", new ConfigOptionBools({false, false})); + cfg.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", ""})); + recompute_mixed_slot_colors(colors, cfg); + } + REQUIRE(colors.size() == 2); + require_same_rgb(colors[0], wxColour(255, 0, 0)); + require_same_rgb(colors[1], wxColour(0, 0, 255)); +} + +TEST_CASE("recompute_mixed_slot_colors falls back to grey for a broken component reference", "[FilamentBitmapUtils]") +{ + const wxColour grey(128, 128, 128, 255); + std::vector colors{wxColour(255, 0, 0), wxColour(0, 0, 255)}; + + SECTION("dangling component id") { + recompute_mixed_slot_colors(colors, mixed_config("1,9")); + } + SECTION("empty component list") { + recompute_mixed_slot_colors(colors, mixed_config("")); + } + REQUIRE(colors.size() == 3); + require_same_rgb(colors[2], grey); +} + +TEST_CASE("recompute_mixed_slot_colors uses the project colour when a slot colour is unset", "[FilamentBitmapUtils]") +{ + // Slot 2 carries no colour in the vector; filament_colour[1] = "#0000FF" is used instead. + std::vector colors{wxColour(255, 0, 0), wxColour()}; + recompute_mixed_slot_colors(colors, mixed_config()); + require_same_rgb(colors[2], expected_blend({"#FF0000", "#0000FF"}, {5000, 5000})); +} + +TEST_CASE("recompute_mixed_slot_colors blends a gradient slot from its end points only", "[FilamentBitmapUtils]") +{ + DynamicPrintConfig cfg; + cfg.set_key_value("filament_is_mixed", new ConfigOptionBools({false, false, false, true})); + cfg.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", "", "", "1,2,3"})); + cfg.set_key_value("filament_mixed_sublayer_ratios", new ConfigOptionStrings({"", "", "", "0.2,0.3,0.5"})); + cfg.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false, false, false, true})); + cfg.set_key_value("filament_colour", new ConfigOptionStrings({"#FF0000", "#00FF00", "#0000FF", "#000000"})); + + std::vector colors{wxColour(255, 0, 0), wxColour(0, 255, 0), wxColour(0, 0, 255)}; + recompute_mixed_slot_colors(colors, cfg); + + REQUIRE(colors.size() == 4); + require_same_rgb(colors[3], expected_blend({"#FF0000", "#0000FF"}, {5000, 5000})); +} + +TEST_CASE("recompute_mixed_slot_colors honours the configured ratios and is idempotent", "[FilamentBitmapUtils]") +{ + std::vector colors{wxColour(255, 0, 0), wxColour(0, 0, 255)}; + const DynamicPrintConfig cfg = mixed_config("1,2", "0.7,0.3"); + recompute_mixed_slot_colors(colors, cfg); + const wxColour first = colors[2]; + // The configured 70/30 ratio must reach the blend (it is not the equal-share default). + require_same_rgb(first, expected_blend({"#FF0000", "#0000FF"}, {7000, 3000})); + REQUIRE(first != expected_blend({"#FF0000", "#0000FF"}, {5000, 5000})); + recompute_mixed_slot_colors(colors, cfg); + require_same_rgb(colors[2], first); +} + +// --- mixed_gradient_ramp / sample_gradient_ramp ----------------------------------------- +// +// The ramp is what every mixed filament swatch is drawn from, so these pin the three things +// a plain fade between two endpoint colours cannot express: the reserved ratio band, the +// component order, and the custom curve. + +namespace { + +// Slot 3 (index 2) is a gradient mix of physical slots 1 (red) and 2 (blue). +DynamicPrintConfig gradient_config(const std::string& components = "1,2", + const std::string& range = "0.9,0.1", + const std::string& curve = "") +{ + DynamicPrintConfig cfg; + cfg.set_key_value("filament_is_mixed", new ConfigOptionBools({false, false, true})); + cfg.set_key_value("filament_mixed_components", new ConfigOptionStrings({"", "", components})); + cfg.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false, false, true})); + cfg.set_key_value("filament_mixed_gradient_range", new ConfigOptionStrings({"", "", range})); + cfg.set_key_value("filament_mixed_gradient_curve", new ConfigOptionStrings({"", "", curve})); + cfg.set_key_value("filament_colour", new ConfigOptionStrings({"#FF0000", "#0000FF", "#000000"})); + return cfg; +} + +} // namespace + +TEST_CASE("mixed_gradient_ramp runs bottom to top and never reaches a pure component", "[FilamentBitmapUtils]") +{ + // range "0.9,0.1": component 1 (red) is the majority at the bottom and the minority at the top. + const auto ramp = Slic3r::GUI::mixed_gradient_ramp(gradient_config(), 2, 16); + REQUIRE(ramp.size() == 16); + + // Neither end is the pure component colour - the slicer clamps the blend to + // [kGradientMinRatio, kGradientMaxRatio], which a fade between the pure colours would ignore. + REQUIRE(ramp.front() != wxColour(255, 0, 0)); + REQUIRE(ramp.back() != wxColour(0, 0, 255)); + + // Red falls and blue rises monotonically from bottom to top. + for (size_t i = 1; i < ramp.size(); ++i) { + REQUIRE(int(ramp[i].Red()) <= int(ramp[i - 1].Red())); + REQUIRE(int(ramp[i].Blue()) >= int(ramp[i - 1].Blue())); + } +} + +TEST_CASE("mixed_gradient_ramp follows the range's direction rather than the component order", "[FilamentBitmapUtils]") +{ + const auto rising = Slic3r::GUI::mixed_gradient_ramp(gradient_config("1,2", "0.1,0.9"), 2, 16); + const auto falling = Slic3r::GUI::mixed_gradient_ramp(gradient_config("1,2", "0.9,0.1"), 2, 16); + REQUIRE(rising.size() == 16); + REQUIRE(falling.size() == 16); + + // "0.1,0.9" starts blue-heavy at the bottom; "0.9,0.1" starts red-heavy. Reversing the + // range must reverse the ramp, which endpoint colours ordered by HSV cannot express. + REQUIRE(int(rising.front().Blue()) > int(rising.front().Red())); + REQUIRE(int(falling.front().Red()) > int(falling.front().Blue())); + require_same_rgb(rising.front(), falling.back()); +} + +TEST_CASE("mixed_gradient_ramp bends with a custom curve", "[FilamentBitmapUtils]") +{ + // Component 1 holds near its maximum for the first half, then drops - a shape a straight + // fade between two endpoints cannot draw. + const auto curved = Slic3r::GUI::mixed_gradient_ramp( + gradient_config("1,2", "0.9,0.1", "0,0.9|0.5,0.85|1,0.1"), 2, 16); + const auto linear = Slic3r::GUI::mixed_gradient_ramp(gradient_config("1,2", "0.9,0.1"), 2, 16); + REQUIRE(curved.size() == 16); + + // The curve holds component 1 high through the lower half, so every band up to mid height + // is at least as red as the straight fade and mid height is strictly redder. + for (size_t i = 0; i <= curved.size() / 2; ++i) + REQUIRE(int(curved[i].Red()) >= int(linear[i].Red())); + REQUIRE(int(curved[curved.size() / 2].Red()) > int(linear[linear.size() / 2].Red())); + // It still ends blue-dominant, like the straight fade. + REQUIRE(int(curved.back().Blue()) > int(curved.back().Red())); +} + +TEST_CASE("mixed_gradient_ramp is empty for anything but a two-component gradient slot", "[FilamentBitmapUtils]") +{ + SECTION("slot is not mixed") { + REQUIRE(Slic3r::GUI::mixed_gradient_ramp(gradient_config(), 0, 16).empty()); + } + SECTION("gradient is off") { + DynamicPrintConfig cfg = gradient_config(); + cfg.set_key_value("filament_mixed_gradient", new ConfigOptionBools({false, false, false})); + REQUIRE(Slic3r::GUI::mixed_gradient_ramp(cfg, 2, 16).empty()); + } + SECTION("three components") { + REQUIRE(Slic3r::GUI::mixed_gradient_ramp(gradient_config("1,2,3"), 2, 16).empty()); + } + SECTION("slot out of range") { + REQUIRE(Slic3r::GUI::mixed_gradient_ramp(gradient_config(), 9, 16).empty()); + } + SECTION("no mixed keys at all") { + REQUIRE(Slic3r::GUI::mixed_gradient_ramp(DynamicPrintConfig{}, 0, 16).empty()); + } +} + +TEST_CASE("sample_gradient_ramp blends each step through the shared blender", "[FilamentBitmapUtils]") +{ + // A flat curve makes every step the same 30/70 mix, which must come out as the blend the + // dialog's own swatches are drawn with - not a channel lerp between the two components. + GradientCurve curve; + curve.points = {{0.0, 0.3, NAN, NAN}, {1.0, 0.3, NAN, NAN}}; + const auto ramp = Slic3r::GUI::sample_gradient_ramp(wxColour(255, 0, 0), wxColour(0, 0, 255), curve, 4); + REQUIRE(ramp.size() == 4); + + const wxColour expected = Slic3r::GUI::blend_n_colors({wxColour(255, 0, 0), wxColour(0, 0, 255)}, {0.3, 0.7}); + for (const wxColour& c : ramp) + require_same_rgb(c, expected); +} + +TEST_CASE("sample_gradient_ramp returns nothing without a usable curve or step count", "[FilamentBitmapUtils]") +{ + GradientCurve curve; + REQUIRE(Slic3r::GUI::sample_gradient_ramp(wxColour(255, 0, 0), wxColour(0, 0, 255), curve, 8).empty()); + curve.points = {{0.0, kGradientMaxRatio, NAN, NAN}, {1.0, kGradientMinRatio, NAN, NAN}}; + REQUIRE(Slic3r::GUI::sample_gradient_ramp(wxColour(255, 0, 0), wxColour(0, 0, 255), curve, 0).empty()); +} diff --git a/tests/test_utils.hpp b/tests/test_utils.hpp index 97e684fd6e..e3fbbe8fab 100644 --- a/tests/test_utils.hpp +++ b/tests/test_utils.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include @@ -32,7 +33,7 @@ inline Slic3r::TriangleMesh load_model(const std::string &obj_filename) // --------------------------------------------------------------------------- // Owns a unique path under the system temp dir, "-[]" -// (parallel-safe, cross-platform). Shared base for the two RAII temp guards below. +// (parallel-safe, cross-platform). Shared base for the RAII temp guards below. class ScopedTemporaryPath { public: @@ -70,6 +71,24 @@ public: ~ScopedTemporaryDir() { boost::system::error_code ec; boost::filesystem::remove_all(m_path, ec); } }; +// A temp directory that is also Slic3r::temporary_dir() for its lifetime. No test +// process sets that global, so code under test which writes there (for example +// StepPreProcessor::preprocess) lands at the filesystem root. Restored on scope exit +// even when an assertion throws, so it cannot leak into later tests. +class ScopedSlic3rTemporaryDir : public ScopedTemporaryDir +{ +public: + explicit ScopedSlic3rTemporaryDir(const std::string &prefix = "orca") + : ScopedTemporaryDir(prefix), m_previous(Slic3r::temporary_dir()) + { Slic3r::set_temporary_dir(string()); } + // Runs before ~ScopedTemporaryDir, so the setting goes back while the directory + // it names still exists. + ~ScopedSlic3rTemporaryDir() { Slic3r::set_temporary_dir(m_previous); } + +private: + const std::string m_previous; +}; + // --------------------------------------------------------------------------- // Debug-only test artifacts //