Merge branch 'main' into main

This commit is contained in:
Saumya Jain 2024-04-11 13:45:00 +02:00 committed by GitHub
commit c010b58dad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7567 changed files with 102925 additions and 77231 deletions

View file

@ -5,6 +5,12 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
### ✨Try our improved Cura 5.7✨
Before filling out the report below, we want you to try the latest Cura 5.7 Beta.
This version of Cura has become significantly more reliable and has an updated slicing engine that will automatically send a report to the Cura Team for analysis.
#### [You can find the downloads here](https://github.com/Ultimaker/Cura/releases/tag/5.7.0-beta.1) ####
If you still encounter a crash you are still welcome to report the issue so we can use your model as a test case, you can find instructions on how to do that below.
### Project File ### Project File
**⚠️ Before you continue, we need your project file to troubleshoot a slicing crash.** **⚠️ Before you continue, we need your project file to troubleshoot a slicing crash.**
It contains the printer and settings we need for troubleshooting. It contains the printer and settings we need for troubleshooting.
@ -68,4 +74,3 @@ body:
description: You can add the zip file and additional information that is relevant to the issue in the comments below. description: You can add the zip file and additional information that is relevant to the issue in the comments below.
validations: validations:
required: true required: true

View file

@ -20,12 +20,8 @@ on:
- 'main' - 'main'
- 'CURA-*' - 'CURA-*'
- 'PP-*' - 'PP-*'
- '[0-9].[0-9]' - '[0-9].[0-9]*'
- '[0-9].[0-9][0-9]' - '[0-9].[0-9][0-9]*'
tags:
- '[0-9].[0-9].[0-9]*'
- '[0-9].[0-9].[0-9]'
- '[0-9].[0-9][0-9].[0-9]*'
env: env:
CONAN_LOGIN_USERNAME_CURA: ${{ secrets.CONAN_USER }} CONAN_LOGIN_USERNAME_CURA: ${{ secrets.CONAN_USER }}
@ -44,3 +40,11 @@ jobs:
recipe_id_full: ${{ needs.conan-recipe-version.outputs.recipe_id_full }} recipe_id_full: ${{ needs.conan-recipe-version.outputs.recipe_id_full }}
recipe_id_latest: ${{ needs.conan-recipe-version.outputs.recipe_id_latest }} recipe_id_latest: ${{ needs.conan-recipe-version.outputs.recipe_id_latest }}
secrets: inherit secrets: inherit
conan-package-create:
needs: [ conan-recipe-version, conan-package-export ]
uses: ultimaker/cura-workflows/.github/workflows/conan-package-create-linux.yml@main
with:
recipe_id_full: ${{ needs.conan-recipe-version.outputs.recipe_id_full }}
conan_extra_args: "-o cura:enable_i18n=True"
secrets: inherit

View file

@ -58,7 +58,7 @@ jobs:
enterprise: ${{ github.event.inputs.enterprise == 'true' }} enterprise: ${{ github.event.inputs.enterprise == 'true' }}
staging: ${{ github.event.inputs.staging == 'true' }} staging: ${{ github.event.inputs.staging == 'true' }}
architecture: X64 architecture: X64
operating_system: windows-2022 operating_system: self-hosted-Windows-X64
secrets: inherit secrets: inherit
linux-installer: linux-installer:

View file

@ -39,19 +39,14 @@ on:
options: options:
- ubuntu-22.04 - ubuntu-22.04
env:
CONAN_ARGS: ${{ inputs.conan_args || '' }}
ENTERPRISE: ${{ inputs.enterprise || false }}
STAGING: ${{ inputs.staging || false }}
jobs: jobs:
installer: linux-installer:
uses: ultimaker/cura-workflows/.github/workflows/cura-installer-linux.yml@main uses: ultimaker/cura-workflows/.github/workflows/cura-installer-linux.yml@main
with: with:
cura_conan_version: ${{ inputs.cura_conan_version }} cura_conan_version: ${{ inputs.cura_conan_version }}
conan_args: ${{ inputs.conan_args }} conan_args: ${{ inputs.conan_args }}
enterprise: ${{ inputs.enterprise == 'true' }} enterprise: ${{ inputs.enterprise }}
staging: ${{ inputs.staging == 'true' }} staging: ${{ inputs.staging }}
architecture: ${{ inputs.architecture }} architecture: ${{ inputs.architecture }}
operating_system: ${{ inputs.operating_system }} operating_system: ${{ inputs.operating_system }}
secrets: inherit secrets: inherit

View file

@ -43,19 +43,14 @@ on:
- macos-11 - macos-11
- macos-12 - macos-12
env:
CONAN_ARGS: ${{ inputs.conan_args || '' }}
ENTERPRISE: ${{ inputs.enterprise || false }}
STAGING: ${{ inputs.staging || false }}
jobs: jobs:
installer: macos-installer:
uses: ultimaker/cura-workflows/.github/workflows/cura-installer-macos.yml@main uses: ultimaker/cura-workflows/.github/workflows/cura-installer-macos.yml@main
with: with:
cura_conan_version: ${{ inputs.cura_conan_version }} cura_conan_version: ${{ inputs.cura_conan_version }}
conan_args: ${{ inputs.conan_args }} conan_args: ${{ inputs.conan_args }}
enterprise: ${{ inputs.enterprise == 'true' }} enterprise: ${{ inputs.enterprise }}
staging: ${{ inputs.staging == 'true' }} staging: ${{ inputs.staging }}
architecture: ${{ inputs.architecture }} architecture: ${{ inputs.architecture }}
operating_system: ${{ inputs.operating_system }} operating_system: ${{ inputs.operating_system }}
secrets: inherit secrets: inherit

View file

@ -18,6 +18,7 @@ jobs:
- uses: technote-space/get-diff-action@v6 - uses: technote-space/get-diff-action@v6
with: with:
DIFF_FILTER: AMRCD
PATTERNS: | PATTERNS: |
resources/+(extruders|definitions)/*.def.json resources/+(extruders|definitions)/*.def.json
resources/+(intent|quality|variants)/**/*.inst.cfg resources/+(intent|quality|variants)/**/*.inst.cfg
@ -41,6 +42,10 @@ jobs:
if: env.GIT_DIFF && !env.MATCHED_FILES if: env.GIT_DIFF && !env.MATCHED_FILES
run: python printer-linter/src/terminal.py --diagnose --report printer-linter-result/fixes.yml ${{ env.GIT_DIFF_FILTERED }} run: python printer-linter/src/terminal.py --diagnose --report printer-linter-result/fixes.yml ${{ env.GIT_DIFF_FILTERED }}
- name: Check Deleted Files(s)
if: env.GIT_DIFF
run: python printer-linter/src/terminal.py --deleted --report printer-linter-result/comment.md ${{ env.GIT_DIFF_FILTERED }}
- name: Save PR metadata - name: Save PR metadata
run: | run: |
echo ${{ github.event.number }} > printer-linter-result/pr-id.txt echo ${{ github.event.number }} > printer-linter-result/pr-id.txt

View file

@ -39,6 +39,11 @@ jobs:
echo "pr_id=$(cat printer-linter-result/pr-id.txt)" >> $GITHUB_ENV echo "pr_id=$(cat printer-linter-result/pr-id.txt)" >> $GITHUB_ENV
echo "pr_head_repo=$(cat printer-linter-result/pr-head-repo.txt)" >> $GITHUB_ENV echo "pr_head_repo=$(cat printer-linter-result/pr-head-repo.txt)" >> $GITHUB_ENV
echo "pr_head_ref=$(cat printer-linter-result/pr-head-ref.txt)" >> $GITHUB_ENV echo "pr_head_ref=$(cat printer-linter-result/pr-head-ref.txt)" >> $GITHUB_ENV
if [[ -f "printer-linter-result/comment.md" ]]; then
echo "commentFileExists=true" >> $GITHUB_ENV
else
echo "commentFileExists=false" >> $GITHUB_ENV
fi
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with: with:
@ -72,6 +77,13 @@ jobs:
mkdir printer-linter-result mkdir printer-linter-result
unzip printer-linter-result.zip -d printer-linter-result unzip printer-linter-result.zip -d printer-linter-result
- name: Run PR Comments
if: env.commentFileExists == 'true'
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ env.pr_id }}
body-path: 'printer-linter-result/comment.md'
- name: Run clang-tidy-pr-comments action - name: Run clang-tidy-pr-comments action
uses: platisd/clang-tidy-pr-comments@bc0bb7da034a8317d54e7fe1e819159002f4cc40 uses: platisd/clang-tidy-pr-comments@bc0bb7da034a8317d54e7fe1e819159002f4cc40
with: with:

View file

@ -1,5 +1,5 @@
# NOTE: Best to keep all of these remarks in, they might prove useful in the future. # NOTE: Best to keep all of these remarks in, they might prove useful in the future.
# This is basically just the standard one that is sugested on 'new workflow'. # This is basically just the standard one that is suggested on 'new workflow'.
name: Scorecard supply-chain security name: Scorecard supply-chain security
on: on:
@ -21,51 +21,42 @@ jobs:
name: Scorecard analysis name: Scorecard analysis
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
# Needed to upload the results to code-scanning dashboard. # Needed for Code scanning upload
security-events: write security-events: write
# Needed to publish results and get a badge (see publish_results below). # Needed for GitHub OIDC token if publish_results is true
id-token: write id-token: write
# Uncomment the permissions below if installing in a private repository.
# contents: read
# actions: read
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # v3.1.0 uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with: with:
persist-credentials: false persist-credentials: false
- name: "Run analysis" - name: "Run analysis"
uses: ossf/scorecard-action@e38b1902ae4f44df626f11ba0734b14fb91f8f86 # v2.1.2 uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1
with: with:
results_file: results.sarif results_file: results.sarif
results_format: sarif results_format: sarif
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if: # Scorecard team runs a weekly scan of public GitHub repos,
# - you want to enable the Branch-Protection check on a *public* repository, or # see https://github.com/ossf/scorecard#public-data.
# - you are installing Scorecard on a *private* repository # Setting `publish_results: true` helps us scale by leveraging your workflow to
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. # extract the results instead of relying on our own infrastructure to run scans.
# repo_token: ${{ secrets.SCORECARD_TOKEN }} # And it's free for you!
# Public repositories:
# - Publish results to OpenSSF REST API for easy access by consumers
# - Allows the repository to include the Scorecard badge.
# - See https://github.com/ossf/scorecard-action#publishing-results.
# For private repositories:
# - `publish_results` will always be set to `false`, regardless
# of the value entered here.
publish_results: true publish_results: true
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # Upload the results as artifacts (optional). Commenting out will disable
# format to the repository Actions tab. # uploads of run results in SARIF format to the repository Actions tab.
# https://docs.github.com/en/actions/advanced-guides/storing-workflow-data-as-artifacts
- name: "Upload artifact" - name: "Upload artifact"
uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # v3.1.0 uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with: with:
name: SARIF file name: SARIF file
path: results.sarif path: results.sarif
retention-days: 5 retention-days: 5
# Upload the results to GitHub's code scanning dashboard. # Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning" - name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@17573ee1cc1b9d061760f3a006fc4aac4f944fd5 # v2.2.4 uses: github/codeql-action/upload-sarif@83a02f7883b12e0e4e1a146174f5e2292a01e601 # v2.16.4
with: with:
sarif_file: results.sarif sarif_file: results.sarif

View file

@ -11,3 +11,4 @@ jobs:
with: with:
event: ${{ github.event.workflow_run.event }} event: ${{ github.event.workflow_run.event }}
conclusion: ${{ github.event.workflow_run.conclusion }} conclusion: ${{ github.event.workflow_run.conclusion }}
secrets: inherit

View file

@ -55,7 +55,8 @@ jobs:
needs: [ conan-recipe-version ] needs: [ conan-recipe-version ]
with: with:
recipe_id_full: ${{ needs.conan-recipe-version.outputs.recipe_id_full }} recipe_id_full: ${{ needs.conan-recipe-version.outputs.recipe_id_full }}
conan_extra_args: '-g VirtualPythonEnv -o cura:devtools=True -c tools.build:skip_test=False' conan_extra_args: '-g VirtualPythonEnv -o cura:devtools=True -c tools.build:skip_test=False --options "*:enable_sentry=False"'
unit_test_cmd: 'pytest --junitxml=junit_cura.xml' unit_test_cmd: 'pytest --junitxml=junit_cura.xml'
unit_test_dir: 'tests' unit_test_dir: 'tests'
conan_generator_dir: './venv/bin' conan_generator_dir: './venv/bin'
secrets: inherit

View file

@ -34,24 +34,20 @@ on:
operating_system: operating_system:
description: 'OS' description: 'OS'
required: true required: true
default: 'windows-2022' default: 'self-hosted-Windows-X64'
type: choice type: choice
options: options:
- self-hosted-Windows-X64
- windows-2022 - windows-2022
env:
CONAN_ARGS: ${{ inputs.conan_args || '' }}
ENTERPRISE: ${{ inputs.enterprise || false }}
STAGING: ${{ inputs.staging || false }}
jobs: jobs:
installer: windows-installer:
uses: ultimaker/cura-workflows/.github/workflows/cura-installer-windows.yml@main uses: ultimaker/cura-workflows/.github/workflows/cura-installer-windows.yml@main
with: with:
cura_conan_version: ${{ inputs.cura_conan_version }} cura_conan_version: ${{ inputs.cura_conan_version }}
conan_args: ${{ inputs.conan_args }} conan_args: ${{ inputs.conan_args }}
enterprise: ${{ inputs.enterprise == 'true' }} enterprise: ${{ inputs.enterprise }}
staging: ${{ inputs.staging == 'true' }} staging: ${{ inputs.staging }}
architecture: ${{ inputs.architecture }} architecture: ${{ inputs.architecture }}
operating_system: ${{ inputs.operating_system }} operating_system: ${{ inputs.operating_system }}
secrets: inherit secrets: inherit

View file

@ -3,6 +3,8 @@ checks:
diagnostic-mesh-file-size: true diagnostic-mesh-file-size: true
diagnostic-definition-redundant-override: true diagnostic-definition-redundant-override: true
diagnostic-resources-macos-app-directory-name: true diagnostic-resources-macos-app-directory-name: true
diagnostic-resource-file-deleted: true
diagnostic-material-temperature-defined: true
fixes: fixes:
diagnostic-definition-redundant-override: true diagnostic-definition-redundant-override: true
format: format:

View file

@ -26,7 +26,9 @@
*With hundreds of settings & community-managed print profiles,* <br> *With hundreds of settings & community-managed print profiles,* <br>
*Ultimaker Cura is sure to lead your next project to a success.* *Ultimaker Cura is sure to lead your next project to a success.*
<br> **Contribute Printer Profiles?** -- Please [look here](https://github.com/Ultimaker/Cura/wiki/Adding-new-machine-profiles-to-Cura) first. <br>
**Contribute Translations?** -- Please [look here](https://github.com/Ultimaker/Cura/wiki/Translating-Cura) first.
<br> <br>
[![Button Building]][Building] [![Button Building]][Building]

View file

@ -55,7 +55,8 @@ exe = EXE(
target_arch={{ target_arch }}, target_arch={{ target_arch }},
codesign_identity=os.getenv('CODESIGN_IDENTITY', None), codesign_identity=os.getenv('CODESIGN_IDENTITY', None),
entitlements_file={{ entitlements_file }}, entitlements_file={{ entitlements_file }},
icon={{ icon }} icon={{ icon }},
contents_directory='.'
) )
coll = COLLECT( coll = COLLECT(
@ -70,188 +71,7 @@ coll = COLLECT(
) )
{% if macos == true %} {% if macos == true %}
# PyInstaller seems to copy everything in the resource folder for the MacOS, this causes issues with codesigning and notarizing app = BUNDLE(
# The folder structure should adhere to the one specified in Table 2-5
# https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html#//apple_ref/doc/uid/10000123i-CH101-SW1
# The class below is basically ducktyping the BUNDLE class of PyInstaller and using our own `assemble` method for more fine-grain and specific
# control. Some code of the method below is copied from:
# https://github.com/pyinstaller/pyinstaller/blob/22d1d2a5378228744cc95f14904dae1664df32c4/PyInstaller/building/osx.py#L115
#-----------------------------------------------------------------------------
# Copyright (c) 2005-2022, PyInstaller Development Team.
#
# Distributed under the terms of the GNU General Public License (version 2
# or later) with exception for distributing the bootloader.
#
# The full license is in the file COPYING.txt, distributed with this software.
#
# SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
#-----------------------------------------------------------------------------
import plistlib
import shutil
import PyInstaller.utils.osx as osxutils
from pathlib import Path
from PyInstaller.building.osx import BUNDLE
from PyInstaller.building.utils import (_check_path_overlap, _rmtree, add_suffix_to_extension, checkCache)
from PyInstaller.building.datastruct import logger
from PyInstaller.building.icon import normalize_icon_type
class UMBUNDLE(BUNDLE):
def assemble(self):
from PyInstaller.config import CONF
if _check_path_overlap(self.name) and os.path.isdir(self.name):
_rmtree(self.name)
logger.info("Building BUNDLE %s", self.tocbasename)
# Create a minimal Mac bundle structure.
macos_path = Path(self.name, "Contents", "MacOS")
resources_path = Path(self.name, "Contents", "Resources")
frameworks_path = Path(self.name, "Contents", "Frameworks")
os.makedirs(macos_path)
os.makedirs(resources_path)
os.makedirs(frameworks_path)
# Makes sure the icon exists and attempts to convert to the proper format if applicable
self.icon = normalize_icon_type(self.icon, ("icns",), "icns", CONF["workpath"])
# Ensure icon path is absolute
self.icon = os.path.abspath(self.icon)
# Copy icns icon to Resources directory.
shutil.copy(self.icon, os.path.join(self.name, 'Contents', 'Resources'))
# Key/values for a minimal Info.plist file
info_plist_dict = {
"CFBundleDisplayName": self.appname,
"CFBundleName": self.appname,
# Required by 'codesign' utility.
# The value for CFBundleIdentifier is used as the default unique name of your program for Code Signing
# purposes. It even identifies the APP for access to restricted OS X areas like Keychain.
#
# The identifier used for signing must be globally unique. The usual form for this identifier is a
# hierarchical name in reverse DNS notation, starting with the toplevel domain, followed by the company
# name, followed by the department within the company, and ending with the product name. Usually in the
# form: com.mycompany.department.appname
# CLI option --osx-bundle-identifier sets this value.
"CFBundleIdentifier": self.bundle_identifier,
"CFBundleExecutable": os.path.basename(self.exename),
"CFBundleIconFile": os.path.basename(self.icon),
"CFBundleInfoDictionaryVersion": "6.0",
"CFBundlePackageType": "APPL",
"CFBundleVersionString": self.version,
"CFBundleShortVersionString": self.version,
}
# Set some default values. But they still can be overwritten by the user.
if self.console:
# Setting EXE console=True implies LSBackgroundOnly=True.
info_plist_dict['LSBackgroundOnly'] = True
else:
# Let's use high resolution by default.
info_plist_dict['NSHighResolutionCapable'] = True
# Merge info_plist settings from spec file
if isinstance(self.info_plist, dict) and self.info_plist:
info_plist_dict.update(self.info_plist)
plist_filename = os.path.join(self.name, "Contents", "Info.plist")
with open(plist_filename, "wb") as plist_fh:
plistlib.dump(info_plist_dict, plist_fh)
links = []
_QT_BASE_PATH = {'PySide2', 'PySide6', 'PyQt5', 'PyQt6', 'PySide6'}
for inm, fnm, typ in self.toc:
# Adjust name for extensions, if applicable
inm, fnm, typ = add_suffix_to_extension(inm, fnm, typ)
inm = Path(inm)
fnm = Path(fnm)
# Copy files from cache. This ensures that are used files with relative paths to dynamic library
# dependencies (@executable_path)
if typ in ('EXTENSION', 'BINARY') or (typ == 'DATA' and inm.suffix == '.so'):
if any(['.' in p for p in inm.parent.parts]):
inm = Path(inm.name)
fnm = Path(checkCache(
str(fnm),
strip = self.strip,
upx = self.upx,
upx_exclude = self.upx_exclude,
dist_nm = str(inm),
target_arch = self.target_arch,
codesign_identity = self.codesign_identity,
entitlements_file = self.entitlements_file,
strict_arch_validation = (typ == 'EXTENSION'),
))
frame_dst = frameworks_path.joinpath(inm)
if not frame_dst.exists():
if frame_dst.is_dir():
os.makedirs(frame_dst, exist_ok = True)
else:
os.makedirs(frame_dst.parent, exist_ok = True)
shutil.copy(fnm, frame_dst, follow_symlinks = True)
macos_dst = macos_path.joinpath(inm)
if not macos_dst.exists():
if macos_dst.is_dir():
os.makedirs(macos_dst, exist_ok = True)
else:
os.makedirs(macos_dst.parent, exist_ok = True)
# Create relative symlink to the framework
symlink_to = Path(*[".." for p in macos_dst.relative_to(macos_path).parts], "Frameworks").joinpath(
frame_dst.relative_to(frameworks_path))
try:
macos_dst.symlink_to(symlink_to)
except FileExistsError:
pass
else:
if typ == 'DATA':
if any(['.' in p for p in inm.parent.parts]) or inm.suffix == '.so':
# Skip info dist egg and some not needed folders in tcl and tk, since they all contain dots in their files
logger.warning(f"Skipping DATA file {inm}")
continue
res_dst = resources_path.joinpath(inm)
if not res_dst.exists():
if res_dst.is_dir():
os.makedirs(res_dst, exist_ok = True)
else:
os.makedirs(res_dst.parent, exist_ok = True)
shutil.copy(fnm, res_dst, follow_symlinks = True)
macos_dst = macos_path.joinpath(inm)
if not macos_dst.exists():
if macos_dst.is_dir():
os.makedirs(macos_dst, exist_ok = True)
else:
os.makedirs(macos_dst.parent, exist_ok = True)
# Create relative symlink to the resource
symlink_to = Path(*[".." for p in macos_dst.relative_to(macos_path).parts], "Resources").joinpath(
res_dst.relative_to(resources_path))
try:
macos_dst.symlink_to(symlink_to)
except FileExistsError:
pass
else:
macos_dst = macos_path.joinpath(inm)
if not macos_dst.exists():
if macos_dst.is_dir():
os.makedirs(macos_dst, exist_ok = True)
else:
os.makedirs(macos_dst.parent, exist_ok = True)
shutil.copy(fnm, macos_dst, follow_symlinks = True)
# Sign the bundle
logger.info('Signing the BUNDLE...')
try:
osxutils.sign_binary(self.name, self.codesign_identity, self.entitlements_file, deep = True)
except Exception as e:
logger.warning(f"Error while signing the bundle: {e}")
logger.warning("You will need to sign the bundle manually!")
logger.info(f"Building BUNDLE {self.tocbasename} completed successfully.")
app = UMBUNDLE(
coll, coll,
name='{{ display_name }}.app', name='{{ display_name }}.app',
icon={{ icon }}, icon={{ icon }},
@ -272,8 +92,9 @@ app = UMBUNDLE(
}], }],
'CFBundleDocumentTypes': [{ 'CFBundleDocumentTypes': [{
'CFBundleTypeRole': 'Viewer', 'CFBundleTypeRole': 'Viewer',
'CFBundleTypeExtensions': ['*'], 'CFBundleTypeExtensions': ['stl', 'obj', '3mf', 'gcode', 'ufp'],
'CFBundleTypeName': 'Model Files', 'CFBundleTypeName': 'Model Files',
}] }]
}, },
){% endif %} )
{% endif %}

View file

@ -1,15 +1,15 @@
version: "5.7.0-alpha.0" version: "5.8.0-alpha.0"
requirements: requirements:
- "uranium/(latest)@ultimaker/testing" - "uranium/(latest)@ultimaker/testing"
- "curaengine/(latest)@ultimaker/testing" - "curaengine/(latest)@ultimaker/testing"
- "cura_binary_data/(latest)@ultimaker/testing" - "cura_binary_data/(latest)@ultimaker/testing"
- "fdm_materials/(latest)@ultimaker/testing" - "fdm_materials/(latest)@ultimaker/testing"
- "curaengine_plugin_gradual_flow/(latest)@ultimaker/stable" - "curaengine_plugin_gradual_flow/0.1.0-beta.3"
- "dulcificum/latest@ultimaker/testing" - "dulcificum/latest@ultimaker/testing"
- "pyarcus/5.3.0"
- "pysavitar/5.3.0" - "pysavitar/5.3.0"
- "pynest2d/5.3.0" - "pynest2d/5.3.0"
- "curaengine_grpc_definitions/(latest)@ultimaker/testing" - "curaengine_grpc_definitions/0.2.0"
- "native_cad_plugin/2.0.0"
requirements_internal: requirements_internal:
- "fdm_materials/(latest)@internal/testing" - "fdm_materials/(latest)@internal/testing"
- "cura_private_data/(latest)@internal/testing" - "cura_private_data/(latest)@internal/testing"
@ -42,6 +42,14 @@ pyinstaller:
package: "curaengine_plugin_gradual_flow" package: "curaengine_plugin_gradual_flow"
src: "res/bundled_packages" src: "res/bundled_packages"
dst: "share/cura/resources/bundled_packages" dst: "share/cura/resources/bundled_packages"
native_cad_plugin:
package: "native_cad_plugin"
src: "res/plugins/NativeCADplugin"
dst: "share/cura/plugins/NativeCADplugin"
native_cad_plugin_bundled:
package: "native_cad_plugin"
src: "res/bundled_packages"
dst: "share/cura/resources/bundled_packages"
cura_resources: cura_resources:
package: "cura" package: "cura"
src: "resources" src: "resources"
@ -119,7 +127,6 @@ pyinstaller:
- "sqlite3" - "sqlite3"
- "trimesh" - "trimesh"
- "win32ctypes" - "win32ctypes"
- "PyQt6"
- "PyQt6.QtNetwork" - "PyQt6.QtNetwork"
- "PyQt6.sip" - "PyQt6.sip"
- "stl" - "stl"
@ -161,6 +168,10 @@ pycharm_targets:
module_name: Cura module_name: Cura
name: pytest in TestGCodeListDecorator.py name: pytest in TestGCodeListDecorator.py
script_name: tests/TestGCodeListDecorator.py script_name: tests/TestGCodeListDecorator.py
- jinja_path: .run_templates/pycharm_cura_test.run.xml.jinja
module_name: Cura
name: pytest in TestHitChecker.py
script_name: tests/TestHitChecker.py
- jinja_path: .run_templates/pycharm_cura_test.run.xml.jinja - jinja_path: .run_templates/pycharm_cura_test.run.xml.jinja
module_name: Cura module_name: Cura
name: pytest in TestIntentManager.py name: pytest in TestIntentManager.py
@ -189,6 +200,10 @@ pycharm_targets:
module_name: Cura module_name: Cura
name: pytest in TestPrintInformation.py name: pytest in TestPrintInformation.py
script_name: tests/TestPrintInformation.py script_name: tests/TestPrintInformation.py
- jinja_path: .run_templates/pycharm_cura_test.run.xml.jinja
module_name: Cura
name: pytest in TestPrintOrderManager.py
script_name: tests/TestPrintOrderManager.py
- jinja_path: .run_templates/pycharm_cura_test.run.xml.jinja - jinja_path: .run_templates/pycharm_cura_test.run.xml.jinja
module_name: Cura module_name: Cura
name: pytest in TestProfileRequirements.py name: pytest in TestProfileRequirements.py

View file

@ -1,4 +1,5 @@
import os import os
from io import StringIO
from pathlib import Path from pathlib import Path
from jinja2 import Template from jinja2 import Template
@ -150,6 +151,7 @@ class CuraConan(ConanFile):
return "None" return "None"
def _conan_installs(self): def _conan_installs(self):
self.output.info("Collecting conan installs")
conan_installs = {} conan_installs = {}
# list of conan installs # list of conan installs
@ -161,13 +163,22 @@ class CuraConan(ConanFile):
return conan_installs return conan_installs
def _python_installs(self): def _python_installs(self):
self.output.info("Collecting python installs")
python_installs = {} python_installs = {}
# list of python installs # list of python installs
python_ins_cmd = f"python -c \"import pkg_resources; print(';'.join([(s.key+','+ s.version) for s in pkg_resources.working_set]))\"" run_env = VirtualRunEnv(self)
from six import StringIO env = run_env.environment()
env.prepend_path("PYTHONPATH", str(self._site_packages.as_posix()))
venv_vars = env.vars(self, scope = "run")
outer = '"' if self.settings.os == "Windows" else "'"
inner = "'" if self.settings.os == "Windows" else '"'
buffer = StringIO() buffer = StringIO()
self.run(python_ins_cmd, run_environment= True, env = "conanrun", output=buffer) with venv_vars.apply():
self.run(f"""python -c {outer}import pkg_resources; print({inner};{inner}.join([(s.key+{inner},{inner}+ s.version) for s in pkg_resources.working_set])){outer}""",
env = "conanrun",
output = buffer)
packages = str(buffer.getvalue()).split("-----------------\n") packages = str(buffer.getvalue()).split("-----------------\n")
packages = packages[1].strip('\r\n').split(";") packages = packages[1].strip('\r\n').split(";")
@ -220,6 +231,8 @@ class CuraConan(ConanFile):
else: else:
src_path = os.path.join(self.source_folder, data["src"]) src_path = os.path.join(self.source_folder, data["src"])
else: else:
if data["package"] not in self.deps_cpp_info.deps:
continue
src_path = os.path.join(self.deps_cpp_info[data["package"]].rootpath, data["src"]) src_path = os.path.join(self.deps_cpp_info[data["package"]].rootpath, data["src"])
elif "root" in data: # get the paths relative from the install folder elif "root" in data: # get the paths relative from the install folder
src_path = os.path.join(self.install_folder, data["root"], data["src"]) src_path = os.path.join(self.install_folder, data["root"], data["src"])
@ -242,7 +255,7 @@ class CuraConan(ConanFile):
self.output.warning(f"Source path for binary {binary['binary']} does not exist") self.output.warning(f"Source path for binary {binary['binary']} does not exist")
continue continue
for bin in Path(src_path).glob(binary["binary"] + "*[.exe|.dll|.so|.dylib|.so.|.pdb]*"): for bin in Path(src_path).glob(binary["binary"] + "*[.exe|.dll|.so|.dylib|.so.]*"):
binaries.append((str(bin), binary["dst"])) binaries.append((str(bin), binary["dst"]))
for bin in Path(src_path).glob(binary["binary"]): for bin in Path(src_path).glob(binary["binary"]):
binaries.append((str(bin), binary["dst"])) binaries.append((str(bin), binary["dst"]))
@ -320,6 +333,8 @@ class CuraConan(ConanFile):
self.options["openssl"].shared = True self.options["openssl"].shared = True
if self.conf.get("user.curaengine:sentry_url", "", check_type=str) != "": if self.conf.get("user.curaengine:sentry_url", "", check_type=str) != "":
self.options["curaengine"].enable_sentry = True self.options["curaengine"].enable_sentry = True
self.options["arcus"].enable_sentry = True
self.options["clipper"].enable_sentry = True
def validate(self): def validate(self):
version = self.conf.get("user.cura:version", default = self.version, check_type = str) version = self.conf.get("user.cura:version", default = self.version, check_type = str)
@ -330,12 +345,16 @@ class CuraConan(ConanFile):
for req in self.conan_data["requirements"]: for req in self.conan_data["requirements"]:
if self._internal and "fdm_materials" in req: if self._internal and "fdm_materials" in req:
continue continue
if not self._enterprise and "native_cad_plugin" in req:
continue
self.requires(req) self.requires(req)
if self._internal: if self._internal:
for req in self.conan_data["requirements_internal"]: for req in self.conan_data["requirements_internal"]:
self.requires(req) self.requires(req)
self.requires("cpython/3.10.4@ultimaker/stable") self.requires("cpython/3.10.4@ultimaker/stable")
self.requires("clipper/6.4.2@ultimaker/stable")
self.requires("openssl/3.2.0") self.requires("openssl/3.2.0")
self.requires("protobuf/3.21.12")
self.requires("boost/1.82.0") self.requires("boost/1.82.0")
self.requires("spdlog/1.12.0") self.requires("spdlog/1.12.0")
self.requires("fmt/10.1.1") self.requires("fmt/10.1.1")
@ -378,6 +397,12 @@ class CuraConan(ConanFile):
copy(self, "*", curaengine_plugin_gradual_flow.bindirs[0], self.source_folder, keep_path = False) copy(self, "*", curaengine_plugin_gradual_flow.bindirs[0], self.source_folder, keep_path = False)
copy(self, "bundled_*.json", curaengine_plugin_gradual_flow.resdirs[1], str(self.source_path.joinpath("resources", "bundled_packages")), keep_path = False) copy(self, "bundled_*.json", curaengine_plugin_gradual_flow.resdirs[1], str(self.source_path.joinpath("resources", "bundled_packages")), keep_path = False)
if self._enterprise:
rmdir(self, str(self.source_path.joinpath("plugins", "NativeCADplugin")))
curaengine_plugin_gradual_flow = self.dependencies["native_cad_plugin"].cpp_info
copy(self, "*", curaengine_plugin_gradual_flow.resdirs[0], str(self.source_path.joinpath("plugins", "NativeCADplugin")), keep_path = True)
copy(self, "bundled_*.json", curaengine_plugin_gradual_flow.resdirs[1], str(self.source_path.joinpath("resources", "bundled_packages")), keep_path = False)
# Copy resources of cura_binary_data # Copy resources of cura_binary_data
cura_binary_data = self.dependencies["cura_binary_data"].cpp_info cura_binary_data = self.dependencies["cura_binary_data"].cpp_info
copy(self, "*", cura_binary_data.resdirs[0], str(self._share_dir.joinpath("cura")), keep_path = True) copy(self, "*", cura_binary_data.resdirs[0], str(self._share_dir.joinpath("cura")), keep_path = True)
@ -417,7 +442,6 @@ class CuraConan(ConanFile):
) )
if self.options.get_safe("enable_i18n", False) and self._i18n_options["extract"]: if self.options.get_safe("enable_i18n", False) and self._i18n_options["extract"]:
# Update the po and pot files
vb = VirtualBuildEnv(self) vb = VirtualBuildEnv(self)
vb.generate() vb.generate()
@ -502,10 +526,14 @@ echo "CURA_APP_NAME={{ cura_app_name }}" >> ${{ env_prefix }}GITHUB_ENV
if self.in_local_cache: if self.in_local_cache:
self.runenv_info.append_path("PYTHONPATH", os.path.join(self.package_folder, "site-packages")) self.runenv_info.append_path("PYTHONPATH", os.path.join(self.package_folder, "site-packages"))
self.env_info.PYTHONPATH.append(os.path.join(self.package_folder, "site-packages"))
self.runenv_info.append_path("PYTHONPATH", os.path.join(self.package_folder, "plugins")) self.runenv_info.append_path("PYTHONPATH", os.path.join(self.package_folder, "plugins"))
self.env_info.PYTHONPATH.append(os.path.join(self.package_folder, "plugins"))
else: else:
self.runenv_info.append_path("PYTHONPATH", self.source_folder) self.runenv_info.append_path("PYTHONPATH", self.source_folder)
self.env_info.PYTHONPATH.append(self.source_folder)
self.runenv_info.append_path("PYTHONPATH", os.path.join(self.source_folder, "plugins")) self.runenv_info.append_path("PYTHONPATH", os.path.join(self.source_folder, "plugins"))
self.env_info.PYTHONPATH.append(os.path.join(self.source_folder, "plugins"))
def package_id(self): def package_id(self):
self.info.clear() self.info.clear()
@ -518,7 +546,8 @@ echo "CURA_APP_NAME={{ cura_app_name }}" >> ${{ env_prefix }}GITHUB_ENV
del self.info.options.cloud_api_version del self.info.options.cloud_api_version
del self.info.options.display_name del self.info.options.display_name
del self.info.options.cura_debug_mode del self.info.options.cura_debug_mode
self.options.rm_safe("enable_i18n") if self.options.get_safe("enable_i18n", False):
del self.info.options.enable_i18n
# TODO: Use the hash of requirements.txt and requirements-ultimaker.txt, Because changing these will actually result in a different # TODO: Use the hash of requirements.txt and requirements-ultimaker.txt, Because changing these will actually result in a different
# Cura. This is needed because the requirements.txt aren't managed by Conan and therefor not resolved in the package_id. This isn't # Cura. This is needed because the requirements.txt aren't managed by Conan and therefor not resolved in the package_id. This isn't

View file

@ -190,6 +190,20 @@ class Account(QObject):
def isLoggedIn(self) -> bool: def isLoggedIn(self) -> bool:
return self._logged_in return self._logged_in
@pyqtSlot()
def stopSyncing(self) -> None:
Logger.debug(f"Stopping sync of cloud printers")
self._setManualSyncEnabled(True)
if self._update_timer.isActive():
self._update_timer.stop()
@pyqtSlot()
def startSyncing(self) -> None:
Logger.debug(f"Starting sync of cloud printers")
self._setManualSyncEnabled(False)
if not self._update_timer.isActive():
self._update_timer.start()
def _onLoginStateChanged(self, logged_in: bool = False, error_message: Optional[str] = None) -> None: def _onLoginStateChanged(self, logged_in: bool = False, error_message: Optional[str] = None) -> None:
if error_message: if error_message:
if self._error_message: if self._error_message:

View file

@ -14,7 +14,7 @@ DEFAULT_CURA_LATEST_URL = "https://software.ultimaker.com/latest.json"
# Each release has a fixed SDK version coupled with it. It doesn't make sense to make it configurable because, for # Each release has a fixed SDK version coupled with it. It doesn't make sense to make it configurable because, for
# example Cura 3.2 with SDK version 6.1 will not work. So the SDK version is hard-coded here and left out of the # example Cura 3.2 with SDK version 6.1 will not work. So the SDK version is hard-coded here and left out of the
# CuraVersion.py.in template. # CuraVersion.py.in template.
CuraSDKVersion = "8.6.0" CuraSDKVersion = "8.7.0"
try: try:
from cura.CuraVersion import CuraLatestURL from cura.CuraVersion import CuraLatestURL

View file

@ -18,8 +18,8 @@ class BackendPlugin(AdditionalSettingDefinitionsAppender, PluginObject):
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
settings_catalog = i18nCatalog("fdmprinter.def.json") settings_catalog = i18nCatalog("fdmprinter.def.json")
def __init__(self) -> None: def __init__(self, catalog_i18n = settings_catalog) -> None:
super().__init__(self.settings_catalog) super().__init__(catalog_i18n)
self.__port: int = 0 self.__port: int = 0
self._plugin_address: str = "127.0.0.1" self._plugin_address: str = "127.0.0.1"
self._plugin_command: Optional[List[str]] = None self._plugin_command: Optional[List[str]] = None

View file

@ -1,12 +1,12 @@
# Copyright (c) 2023 UltiMaker # Copyright (c) 2023 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import List, cast from typing import List, cast
from PyQt6.QtCore import QObject, QUrl, QMimeData from PyQt6.QtCore import QObject, QUrl, pyqtSignal, pyqtProperty
from PyQt6.QtGui import QDesktopServices from PyQt6.QtGui import QDesktopServices
from PyQt6.QtWidgets import QApplication from PyQt6.QtWidgets import QApplication
from UM.Application import Application
from UM.Event import CallFunctionEvent from UM.Event import CallFunctionEvent
from UM.FlameProfiler import pyqtSlot from UM.FlameProfiler import pyqtSlot
from UM.Math.Vector import Vector from UM.Math.Vector import Vector
@ -32,11 +32,14 @@ from cura.Operations.SetBuildPlateNumberOperation import SetBuildPlateNumberOper
from UM.Logger import Logger from UM.Logger import Logger
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
class CuraActions(QObject): class CuraActions(QObject):
def __init__(self, parent: QObject = None) -> None: def __init__(self, parent: QObject = None) -> None:
super().__init__(parent) super().__init__(parent)
self._operation_stack = Application.getInstance().getOperationStack()
self._operation_stack.changed.connect(self._onUndoStackChanged)
undoStackChanged = pyqtSignal()
@pyqtSlot() @pyqtSlot()
def openDocumentation(self) -> None: def openDocumentation(self) -> None:
# Starting a web browser from a signal handler connected to a menu will crash on windows. # Starting a web browser from a signal handler connected to a menu will crash on windows.
@ -45,6 +48,25 @@ class CuraActions(QObject):
event = CallFunctionEvent(self._openUrl, [QUrl("https://ultimaker.com/en/resources/manuals/software?utm_source=cura&utm_medium=software&utm_campaign=dropdown-documentation")], {}) event = CallFunctionEvent(self._openUrl, [QUrl("https://ultimaker.com/en/resources/manuals/software?utm_source=cura&utm_medium=software&utm_campaign=dropdown-documentation")], {})
cura.CuraApplication.CuraApplication.getInstance().functionEvent(event) cura.CuraApplication.CuraApplication.getInstance().functionEvent(event)
@pyqtProperty(bool, notify=undoStackChanged)
def canUndo(self):
return self._operation_stack.canUndo()
@pyqtProperty(bool, notify=undoStackChanged)
def canRedo(self):
return self._operation_stack.canRedo()
@pyqtSlot()
def undo(self):
self._operation_stack.undo()
@pyqtSlot()
def redo(self):
self._operation_stack.redo()
def _onUndoStackChanged(self):
self.undoStackChanged.emit()
@pyqtSlot() @pyqtSlot()
def openBugReportPage(self) -> None: def openBugReportPage(self) -> None:
event = CallFunctionEvent(self._openUrl, [QUrl("https://github.com/Ultimaker/Cura/issues/new/choose")], {}) event = CallFunctionEvent(self._openUrl, [QUrl("https://github.com/Ultimaker/Cura/issues/new/choose")], {})
@ -249,7 +271,11 @@ class CuraActions(QObject):
# deselect currently selected nodes, and select the new nodes # deselect currently selected nodes, and select the new nodes
for node in Selection.getAllSelectedObjects(): for node in Selection.getAllSelectedObjects():
Selection.remove(node) Selection.remove(node)
numberOfFixedNodes = len(fixed_nodes)
for node in nodes: for node in nodes:
numberOfFixedNodes += 1
node.printOrder = numberOfFixedNodes
Selection.add(node) Selection.add(node)
def _openUrl(self, url: QUrl) -> None: def _openUrl(self, url: QUrl) -> None:

View file

@ -15,13 +15,13 @@ import numpy
from PyQt6.QtCore import QObject, QTimer, QUrl, QUrlQuery, pyqtSignal, pyqtProperty, QEvent, pyqtEnum, QCoreApplication, \ from PyQt6.QtCore import QObject, QTimer, QUrl, QUrlQuery, pyqtSignal, pyqtProperty, QEvent, pyqtEnum, QCoreApplication, \
QByteArray QByteArray
from PyQt6.QtGui import QColor, QIcon from PyQt6.QtGui import QColor, QIcon
from PyQt6.QtQml import qmlRegisterUncreatableType, qmlRegisterUncreatableMetaObject, qmlRegisterSingletonType, qmlRegisterType from PyQt6.QtQml import qmlRegisterUncreatableMetaObject, qmlRegisterSingletonType, qmlRegisterType
from PyQt6.QtWidgets import QMessageBox from PyQt6.QtWidgets import QMessageBox
import UM.Util import UM.Util
import cura.Settings.cura_empty_instance_containers import cura.Settings.cura_empty_instance_containers
from UM.Application import Application from UM.Application import Application
from UM.Decorators import override from UM.Decorators import override, deprecated
from UM.FlameProfiler import pyqtSlot from UM.FlameProfiler import pyqtSlot
from UM.Logger import Logger from UM.Logger import Logger
from UM.Math.AxisAlignedBox import AxisAlignedBox from UM.Math.AxisAlignedBox import AxisAlignedBox
@ -33,6 +33,7 @@ from UM.Message import Message
from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation
from UM.Operations.GroupedOperation import GroupedOperation from UM.Operations.GroupedOperation import GroupedOperation
from UM.Operations.SetTransformOperation import SetTransformOperation from UM.Operations.SetTransformOperation import SetTransformOperation
from UM.OutputDevice.ProjectOutputDevice import ProjectOutputDevice
from UM.Platform import Platform from UM.Platform import Platform
from UM.PluginError import PluginNotFoundError from UM.PluginError import PluginNotFoundError
from UM.Preferences import Preferences from UM.Preferences import Preferences
@ -104,7 +105,8 @@ from cura.Settings.SettingInheritanceManager import SettingInheritanceManager
from cura.Settings.SidebarCustomMenuItemsModel import SidebarCustomMenuItemsModel from cura.Settings.SidebarCustomMenuItemsModel import SidebarCustomMenuItemsModel
from cura.Settings.SimpleModeSettingsManager import SimpleModeSettingsManager from cura.Settings.SimpleModeSettingsManager import SimpleModeSettingsManager
from cura.TaskManagement.OnExitCallbackManager import OnExitCallbackManager from cura.TaskManagement.OnExitCallbackManager import OnExitCallbackManager
from cura.UI import CuraSplashScreen, MachineActionManager, PrintInformation from cura.UI import CuraSplashScreen, PrintInformation
from cura.UI.MachineActionManager import MachineActionManager
from cura.UI.AddPrinterPagesModel import AddPrinterPagesModel from cura.UI.AddPrinterPagesModel import AddPrinterPagesModel
from cura.UI.MachineSettingsManager import MachineSettingsManager from cura.UI.MachineSettingsManager import MachineSettingsManager
from cura.UI.ObjectsModel import ObjectsModel from cura.UI.ObjectsModel import ObjectsModel
@ -125,6 +127,7 @@ from .Machines.Models.CompatibleMachineModel import CompatibleMachineModel
from .Machines.Models.MachineListModel import MachineListModel from .Machines.Models.MachineListModel import MachineListModel
from .Machines.Models.ActiveIntentQualitiesModel import ActiveIntentQualitiesModel from .Machines.Models.ActiveIntentQualitiesModel import ActiveIntentQualitiesModel
from .Machines.Models.IntentSelectionModel import IntentSelectionModel from .Machines.Models.IntentSelectionModel import IntentSelectionModel
from .PrintOrderManager import PrintOrderManager
from .SingleInstance import SingleInstance from .SingleInstance import SingleInstance
if TYPE_CHECKING: if TYPE_CHECKING:
@ -136,7 +139,7 @@ class CuraApplication(QtApplication):
# SettingVersion represents the set of settings available in the machine/extruder definitions. # SettingVersion represents the set of settings available in the machine/extruder definitions.
# You need to make sure that this version number needs to be increased if there is any non-backwards-compatible # You need to make sure that this version number needs to be increased if there is any non-backwards-compatible
# changes of the settings. # changes of the settings.
SettingVersion = 22 SettingVersion = 23
Created = False Created = False
@ -179,6 +182,7 @@ class CuraApplication(QtApplication):
# Variables set from CLI # Variables set from CLI
self._files_to_open = [] self._files_to_open = []
self._urls_to_open = []
self._use_single_instance = False self._use_single_instance = False
self._single_instance = None self._single_instance = None
@ -186,7 +190,7 @@ class CuraApplication(QtApplication):
self._cura_formula_functions = None # type: Optional[CuraFormulaFunctions] self._cura_formula_functions = None # type: Optional[CuraFormulaFunctions]
self._machine_action_manager = None # type: Optional[MachineActionManager.MachineActionManager] self._machine_action_manager: Optional[MachineActionManager] = None
self.empty_container = None # type: EmptyInstanceContainer self.empty_container = None # type: EmptyInstanceContainer
self.empty_definition_changes_container = None # type: EmptyInstanceContainer self.empty_definition_changes_container = None # type: EmptyInstanceContainer
@ -202,6 +206,7 @@ class CuraApplication(QtApplication):
self._container_manager = None self._container_manager = None
self._object_manager = None self._object_manager = None
self._print_order_manager = None
self._extruders_model = None self._extruders_model = None
self._extruders_model_with_optional = None self._extruders_model_with_optional = None
self._build_plate_model = None self._build_plate_model = None
@ -333,7 +338,7 @@ class CuraApplication(QtApplication):
for filename in self._cli_args.file: for filename in self._cli_args.file:
url = QUrl(filename) url = QUrl(filename)
if url.scheme() in self._supported_url_schemes: if url.scheme() in self._supported_url_schemes:
self._open_url_queue.append(url) self._urls_to_open.append(url)
else: else:
self._files_to_open.append(os.path.abspath(filename)) self._files_to_open.append(os.path.abspath(filename))
@ -352,11 +357,11 @@ class CuraApplication(QtApplication):
self.__addAllEmptyContainers() self.__addAllEmptyContainers()
self.__setLatestResouceVersionsForVersionUpgrade() self.__setLatestResouceVersionsForVersionUpgrade()
self._machine_action_manager = MachineActionManager.MachineActionManager(self) self._machine_action_manager = MachineActionManager(self)
self._machine_action_manager.initialize() self._machine_action_manager.initialize()
def __sendCommandToSingleInstance(self): def __sendCommandToSingleInstance(self):
self._single_instance = SingleInstance(self, self._files_to_open) self._single_instance = SingleInstance(self, self._files_to_open, self._urls_to_open)
# If we use single instance, try to connect to the single instance server, send commands, and then exit. # If we use single instance, try to connect to the single instance server, send commands, and then exit.
# If we cannot find an existing single instance server, this is the only instance, so just keep going. # If we cannot find an existing single instance server, this is the only instance, so just keep going.
@ -373,9 +378,15 @@ class CuraApplication(QtApplication):
Resources.addExpectedDirNameInData(dir_name) Resources.addExpectedDirNameInData(dir_name)
app_root = os.path.abspath(os.path.join(os.path.dirname(sys.executable))) app_root = os.path.abspath(os.path.join(os.path.dirname(sys.executable)))
Resources.addSecureSearchPath(os.path.join(app_root, "share", "cura", "resources"))
if platform.system() == "Darwin":
Resources.addSecureSearchPath(os.path.join(app_root, "Resources", "share", "cura", "resources"))
Resources.addSecureSearchPath(
os.path.join(self._app_install_dir, "Resources", "share", "cura", "resources"))
else:
Resources.addSecureSearchPath(os.path.join(app_root, "share", "cura", "resources"))
Resources.addSecureSearchPath(os.path.join(self._app_install_dir, "share", "cura", "resources")) Resources.addSecureSearchPath(os.path.join(self._app_install_dir, "share", "cura", "resources"))
if not hasattr(sys, "frozen"): if not hasattr(sys, "frozen"):
cura_data_root = os.environ.get('CURA_DATA_ROOT', None) cura_data_root = os.environ.get('CURA_DATA_ROOT', None)
if cura_data_root: if cura_data_root:
@ -607,6 +618,7 @@ class CuraApplication(QtApplication):
preferences.addPreference("view/invert_zoom", False) preferences.addPreference("view/invert_zoom", False)
preferences.addPreference("view/filter_current_build_plate", False) preferences.addPreference("view/filter_current_build_plate", False)
preferences.addPreference("view/navigation_style", "cura")
preferences.addPreference("cura/sidebar_collapsed", False) preferences.addPreference("cura/sidebar_collapsed", False)
preferences.addPreference("cura/favorite_materials", "") preferences.addPreference("cura/favorite_materials", "")
@ -899,6 +911,7 @@ class CuraApplication(QtApplication):
# initialize info objects # initialize info objects
self._print_information = PrintInformation.PrintInformation(self) self._print_information = PrintInformation.PrintInformation(self)
self._cura_actions = CuraActions.CuraActions(self) self._cura_actions = CuraActions.CuraActions(self)
self._print_order_manager = PrintOrderManager(self.getObjectsModel().getNodes)
self.processEvents() self.processEvents()
# Initialize setting visibility presets model. # Initialize setting visibility presets model.
self._setting_visibility_presets_model = SettingVisibilityPresetsModel(self.getPreferences(), parent = self) self._setting_visibility_presets_model = SettingVisibilityPresetsModel(self.getPreferences(), parent = self)
@ -956,6 +969,8 @@ class CuraApplication(QtApplication):
self.callLater(self._openFile, file_name) self.callLater(self._openFile, file_name)
for file_name in self._open_file_queue: # Open all the files that were queued up while plug-ins were loading. for file_name in self._open_file_queue: # Open all the files that were queued up while plug-ins were loading.
self.callLater(self._openFile, file_name) self.callLater(self._openFile, file_name)
for url in self._urls_to_open:
self.callLater(self._openUrl, url)
for url in self._open_url_queue: for url in self._open_url_queue:
self.callLater(self._openUrl, url) self.callLater(self._openUrl, url)
@ -979,6 +994,7 @@ class CuraApplication(QtApplication):
t.setEnabledAxis([ToolHandle.XAxis, ToolHandle.YAxis, ToolHandle.ZAxis]) t.setEnabledAxis([ToolHandle.XAxis, ToolHandle.YAxis, ToolHandle.ZAxis])
Selection.selectionChanged.connect(self.onSelectionChanged) Selection.selectionChanged.connect(self.onSelectionChanged)
self._print_order_manager.printOrderChanged.connect(self._onPrintOrderChanged)
# Set default background color for scene # Set default background color for scene
self.getRenderer().setBackgroundColor(QColor(245, 245, 245)) self.getRenderer().setBackgroundColor(QColor(245, 245, 245))
@ -1068,6 +1084,10 @@ class CuraApplication(QtApplication):
def getTextManager(self, *args) -> "TextManager": def getTextManager(self, *args) -> "TextManager":
return self._text_manager return self._text_manager
@pyqtSlot()
def setWorkplaceDropToBuildplate(self):
return self._physics.setAppAllModelDropDown()
def getCuraFormulaFunctions(self, *args) -> "CuraFormulaFunctions": def getCuraFormulaFunctions(self, *args) -> "CuraFormulaFunctions":
if self._cura_formula_functions is None: if self._cura_formula_functions is None:
self._cura_formula_functions = CuraFormulaFunctions(self) self._cura_formula_functions = CuraFormulaFunctions(self)
@ -1094,6 +1114,10 @@ class CuraApplication(QtApplication):
self._object_manager = ObjectsModel(self) self._object_manager = ObjectsModel(self)
return self._object_manager return self._object_manager
@pyqtSlot(str, result = "QVariantList")
def getSupportedActionMachineList(self, definition_id: str) -> List["MachineAction"]:
return self._machine_action_manager.getSupportedActions(self._machine_manager.getDefinitionByMachineId(definition_id))
@pyqtSlot(result = QObject) @pyqtSlot(result = QObject)
def getExtrudersModel(self, *args) -> "ExtrudersModel": def getExtrudersModel(self, *args) -> "ExtrudersModel":
if self._extruders_model is None: if self._extruders_model is None:
@ -1119,6 +1143,16 @@ class CuraApplication(QtApplication):
self._build_plate_model = BuildPlateModel(self) self._build_plate_model = BuildPlateModel(self)
return self._build_plate_model return self._build_plate_model
@pyqtSlot()
def exportUcp(self):
writer = self.getMeshFileHandler().getWriter("3MFWriter")
if writer is None:
Logger.warning("3mf writer is not enabled")
return
writer.exportUcp()
def getCuraSceneController(self, *args) -> CuraSceneController: def getCuraSceneController(self, *args) -> CuraSceneController:
if self._cura_scene_controller is None: if self._cura_scene_controller is None:
self._cura_scene_controller = CuraSceneController.createCuraSceneController() self._cura_scene_controller = CuraSceneController.createCuraSceneController()
@ -1129,14 +1163,16 @@ class CuraApplication(QtApplication):
self._setting_inheritance_manager = SettingInheritanceManager.createSettingInheritanceManager() self._setting_inheritance_manager = SettingInheritanceManager.createSettingInheritanceManager()
return self._setting_inheritance_manager return self._setting_inheritance_manager
def getMachineActionManager(self, *args: Any) -> MachineActionManager.MachineActionManager: @pyqtSlot(result = QObject)
def getMachineActionManager(self, *args: Any) -> MachineActionManager:
"""Get the machine action manager """Get the machine action manager
We ignore any *args given to this, as we also register the machine manager as qml singleton. We ignore any *args given to this, as we also register the machine manager as qml singleton.
It wants to give this function an engine and script engine, but we don't care about that. It wants to give this function an engine and script engine, but we don't care about that.
""" """
return cast(MachineActionManager.MachineActionManager, self._machine_action_manager) return self._machine_action_manager
@pyqtSlot(result = QObject) @pyqtSlot(result = QObject)
def getMaterialManagementModel(self) -> MaterialManagementModel: def getMaterialManagementModel(self) -> MaterialManagementModel:
@ -1150,7 +1186,8 @@ class CuraApplication(QtApplication):
self._quality_management_model = QualityManagementModel(parent = self) self._quality_management_model = QualityManagementModel(parent = self)
return self._quality_management_model return self._quality_management_model
def getSimpleModeSettingsManager(self, *args): @pyqtSlot(result=QObject)
def getSimpleModeSettingsManager(self)-> SimpleModeSettingsManager:
if self._simple_mode_settings_manager is None: if self._simple_mode_settings_manager is None:
self._simple_mode_settings_manager = SimpleModeSettingsManager() self._simple_mode_settings_manager = SimpleModeSettingsManager()
return self._simple_mode_settings_manager return self._simple_mode_settings_manager
@ -1193,16 +1230,43 @@ class CuraApplication(QtApplication):
return self._print_information return self._print_information
def getQualityProfilesDropDownMenuModel(self, *args, **kwargs): @pyqtSlot(result=QObject)
def getQualityProfilesDropDownMenuModel(self, *args, **kwargs)-> QualityProfilesDropDownMenuModel:
if self._quality_profile_drop_down_menu_model is None: if self._quality_profile_drop_down_menu_model is None:
self._quality_profile_drop_down_menu_model = QualityProfilesDropDownMenuModel(self) self._quality_profile_drop_down_menu_model = QualityProfilesDropDownMenuModel(self)
return self._quality_profile_drop_down_menu_model return self._quality_profile_drop_down_menu_model
def getCustomQualityProfilesDropDownMenuModel(self, *args, **kwargs): @pyqtSlot(result=QObject)
def getCustomQualityProfilesDropDownMenuModel(self, *args, **kwargs)->CustomQualityProfilesDropDownMenuModel:
if self._custom_quality_profile_drop_down_menu_model is None: if self._custom_quality_profile_drop_down_menu_model is None:
self._custom_quality_profile_drop_down_menu_model = CustomQualityProfilesDropDownMenuModel(self) self._custom_quality_profile_drop_down_menu_model = CustomQualityProfilesDropDownMenuModel(self)
return self._custom_quality_profile_drop_down_menu_model return self._custom_quality_profile_drop_down_menu_model
@deprecated("SimpleModeSettingsManager is deprecated and will be removed in major SDK release, Use getSimpleModeSettingsManager() instead", since = "5.7.0")
def getSimpleModeSettingsManagerWrapper(self, *args, **kwargs):
return self.getSimpleModeSettingsManager()
@deprecated("MachineActionManager is deprecated and will be removed in major SDK release, Use getMachineActionManager() instead", since="5.7.0")
def getMachineActionManagerWrapper(self, *args, **kwargs):
return self.getMachineActionManager()
@deprecated("QualityManagementModel is deprecated and will be removed in major SDK release, Use getQualityManagementModel() instead", since="5.7.0")
def getQualityManagementModelWrapper(self, *args, **kwargs):
return self.getQualityManagementModel()
@deprecated("MaterialManagementModel is deprecated and will be removed in major SDK release, Use getMaterialManagementModel() instead", since = "5.7.0")
def getMaterialManagementModelWrapper(self, *args, **kwargs):
return self.getMaterialManagementModel()
@deprecated("QualityProfilesDropDownMenuModel is deprecated and will be removed in major SDK release, Use getQualityProfilesDropDownMenuModel() instead", since = "5.7.0")
def getQualityProfilesDropDownMenuModelWrapper(self, *args, **kwargs):
return self.getQualityProfilesDropDownMenuModel()
@deprecated("CustomQualityProfilesDropDownMenuModel is deprecated and will be removed in major SDK release, Use getCustomQualityProfilesDropDownMenuModel() instead", since = "5.7.0")
def getCustomQualityProfilesDropDownMenuModelWrapper(self, *args, **kwargs):
return self.getCustomQualityProfilesDropDownMenuModel()
def getCuraAPI(self, *args, **kwargs) -> "CuraAPI": def getCuraAPI(self, *args, **kwargs) -> "CuraAPI":
return self._cura_API return self._cura_API
@ -1218,6 +1282,7 @@ class CuraApplication(QtApplication):
self.processEvents() self.processEvents()
engine.rootContext().setContextProperty("Printer", self) engine.rootContext().setContextProperty("Printer", self)
engine.rootContext().setContextProperty("CuraApplication", self) engine.rootContext().setContextProperty("CuraApplication", self)
engine.rootContext().setContextProperty("PrintOrderManager", self._print_order_manager)
engine.rootContext().setContextProperty("PrintInformation", self._print_information) engine.rootContext().setContextProperty("PrintInformation", self._print_information)
engine.rootContext().setContextProperty("CuraActions", self._cura_actions) engine.rootContext().setContextProperty("CuraActions", self._cura_actions)
engine.rootContext().setContextProperty("CuraSDKVersion", ApplicationMetadata.CuraSDKVersion) engine.rootContext().setContextProperty("CuraSDKVersion", ApplicationMetadata.CuraSDKVersion)
@ -1231,8 +1296,8 @@ class CuraApplication(QtApplication):
qmlRegisterSingletonType(MachineManager, "Cura", 1, 0, self.getMachineManager, "MachineManager") qmlRegisterSingletonType(MachineManager, "Cura", 1, 0, self.getMachineManager, "MachineManager")
qmlRegisterSingletonType(IntentManager, "Cura", 1, 6, self.getIntentManager, "IntentManager") qmlRegisterSingletonType(IntentManager, "Cura", 1, 6, self.getIntentManager, "IntentManager")
qmlRegisterSingletonType(SettingInheritanceManager, "Cura", 1, 0, self.getSettingInheritanceManager, "SettingInheritanceManager") qmlRegisterSingletonType(SettingInheritanceManager, "Cura", 1, 0, self.getSettingInheritanceManager, "SettingInheritanceManager")
qmlRegisterSingletonType(SimpleModeSettingsManager, "Cura", 1, 0, self.getSimpleModeSettingsManager, "SimpleModeSettingsManager") qmlRegisterSingletonType(SimpleModeSettingsManager, "Cura", 1, 0, self.getSimpleModeSettingsManagerWrapper, "SimpleModeSettingsManager")
qmlRegisterSingletonType(MachineActionManager.MachineActionManager, "Cura", 1, 0, self.getMachineActionManager, "MachineActionManager") qmlRegisterSingletonType(MachineActionManager, "Cura", 1, 0, self.getMachineActionManagerWrapper, "MachineActionManager")
self.processEvents() self.processEvents()
qmlRegisterType(NetworkingUtil, "Cura", 1, 5, "NetworkingUtil") qmlRegisterType(NetworkingUtil, "Cura", 1, 5, "NetworkingUtil")
@ -1257,16 +1322,14 @@ class CuraApplication(QtApplication):
qmlRegisterType(FavoriteMaterialsModel, "Cura", 1, 0, "FavoriteMaterialsModel") qmlRegisterType(FavoriteMaterialsModel, "Cura", 1, 0, "FavoriteMaterialsModel")
qmlRegisterType(GenericMaterialsModel, "Cura", 1, 0, "GenericMaterialsModel") qmlRegisterType(GenericMaterialsModel, "Cura", 1, 0, "GenericMaterialsModel")
qmlRegisterType(MaterialBrandsModel, "Cura", 1, 0, "MaterialBrandsModel") qmlRegisterType(MaterialBrandsModel, "Cura", 1, 0, "MaterialBrandsModel")
qmlRegisterSingletonType(QualityManagementModel, "Cura", 1, 0, self.getQualityManagementModel, "QualityManagementModel") qmlRegisterSingletonType(QualityManagementModel, "Cura", 1, 0, self.getQualityManagementModelWrapper,"QualityManagementModel")
qmlRegisterSingletonType(MaterialManagementModel, "Cura", 1, 5, self.getMaterialManagementModel, "MaterialManagementModel") qmlRegisterSingletonType(MaterialManagementModel, "Cura", 1, 5, self.getMaterialManagementModelWrapper,"MaterialManagementModel")
self.processEvents() self.processEvents()
qmlRegisterType(DiscoveredPrintersModel, "Cura", 1, 0, "DiscoveredPrintersModel") qmlRegisterType(DiscoveredPrintersModel, "Cura", 1, 0, "DiscoveredPrintersModel")
qmlRegisterType(DiscoveredCloudPrintersModel, "Cura", 1, 7, "DiscoveredCloudPrintersModel") qmlRegisterType(DiscoveredCloudPrintersModel, "Cura", 1, 7, "DiscoveredCloudPrintersModel")
qmlRegisterSingletonType(QualityProfilesDropDownMenuModel, "Cura", 1, 0, qmlRegisterSingletonType(QualityProfilesDropDownMenuModel, "Cura", 1, 0, self.getQualityProfilesDropDownMenuModelWrapper, "QualityProfilesDropDownMenuModel")
self.getQualityProfilesDropDownMenuModel, "QualityProfilesDropDownMenuModel") qmlRegisterSingletonType(CustomQualityProfilesDropDownMenuModel, "Cura", 1, 0, self.getCustomQualityProfilesDropDownMenuModelWrapper, "CustomQualityProfilesDropDownMenuModel")
qmlRegisterSingletonType(CustomQualityProfilesDropDownMenuModel, "Cura", 1, 0,
self.getCustomQualityProfilesDropDownMenuModel, "CustomQualityProfilesDropDownMenuModel")
qmlRegisterType(NozzleModel, "Cura", 1, 0, "NozzleModel") qmlRegisterType(NozzleModel, "Cura", 1, 0, "NozzleModel")
qmlRegisterType(IntentModel, "Cura", 1, 6, "IntentModel") qmlRegisterType(IntentModel, "Cura", 1, 6, "IntentModel")
qmlRegisterType(IntentCategoryModel, "Cura", 1, 6, "IntentCategoryModel") qmlRegisterType(IntentCategoryModel, "Cura", 1, 6, "IntentCategoryModel")
@ -1395,7 +1458,11 @@ class CuraApplication(QtApplication):
self._scene_bounding_box = scene_bounding_box self._scene_bounding_box = scene_bounding_box
self.sceneBoundingBoxChanged.emit() self.sceneBoundingBoxChanged.emit()
self._platform_activity = True if count > 0 else False if count > 0:
self._platform_activity = True
else:
ProjectOutputDevice.setLastOutputName(None)
self._platform_activity = False
self.activityChanged.emit() self.activityChanged.emit()
@pyqtSlot() @pyqtSlot()
@ -1715,8 +1782,12 @@ class CuraApplication(QtApplication):
Selection.remove(node) Selection.remove(node)
Selection.add(group_node) Selection.add(group_node)
all_nodes = self.getObjectsModel().getNodes()
PrintOrderManager.updatePrintOrdersAfterGroupOperation(all_nodes, group_node, selected_nodes)
@pyqtSlot() @pyqtSlot()
def ungroupSelected(self) -> None: def ungroupSelected(self) -> None:
all_nodes = self.getObjectsModel().getNodes()
selected_objects = Selection.getAllSelectedObjects().copy() selected_objects = Selection.getAllSelectedObjects().copy()
for node in selected_objects: for node in selected_objects:
if node.callDecoration("isGroup"): if node.callDecoration("isGroup"):
@ -1724,21 +1795,30 @@ class CuraApplication(QtApplication):
group_parent = node.getParent() group_parent = node.getParent()
children = node.getChildren().copy() children = node.getChildren().copy()
for child in children:
# Ungroup only 1 level deep
if child.getParent() != node:
continue
# Ungroup only 1 level deep
children_to_ungroup = list(filter(lambda child: child.getParent() == node, children))
for child in children_to_ungroup:
# Set the parent of the children to the parent of the group-node # Set the parent of the children to the parent of the group-node
op.addOperation(SetParentOperation(child, group_parent)) op.addOperation(SetParentOperation(child, group_parent))
# Add all individual nodes to the selection # Add all individual nodes to the selection
Selection.add(child) Selection.add(child)
PrintOrderManager.updatePrintOrdersAfterUngroupOperation(all_nodes, node, children_to_ungroup)
op.push() op.push()
# Note: The group removes itself from the scene once all its children have left it, # Note: The group removes itself from the scene once all its children have left it,
# see GroupDecorator._onChildrenChanged # see GroupDecorator._onChildrenChanged
def _onPrintOrderChanged(self) -> None:
# update object list
scene = self.getController().getScene()
scene.sceneChanged.emit(scene.getRoot())
# reset if already was sliced
Application.getInstance().getBackend().needsSlicing()
Application.getInstance().getBackend().tickle()
def _createSplashScreen(self) -> Optional[CuraSplashScreen.CuraSplashScreen]: def _createSplashScreen(self) -> Optional[CuraSplashScreen.CuraSplashScreen]:
if self._is_headless: if self._is_headless:
return None return None
@ -1902,6 +1982,17 @@ class CuraApplication(QtApplication):
openProjectFile = pyqtSignal(QUrl, bool, arguments = ["project_file", "add_to_recent_files"]) # Emitted when a project file is about to open. openProjectFile = pyqtSignal(QUrl, bool, arguments = ["project_file", "add_to_recent_files"]) # Emitted when a project file is about to open.
@pyqtSlot(QUrl, bool)
def readLocalUcpFile(self, file: QUrl, add_to_recent_files: bool = True):
file_name = QUrl(file).toLocalFile()
workspace_reader = self.getWorkspaceFileHandler()
if workspace_reader is None:
Logger.warning(f"Workspace reader not found, cannot read file {file_name}.")
return
workspace_reader.readLocalFile(file, add_to_recent_files)
@pyqtSlot(QUrl, str, bool) @pyqtSlot(QUrl, str, bool)
@pyqtSlot(QUrl, str) @pyqtSlot(QUrl, str)
@pyqtSlot(QUrl) @pyqtSlot(QUrl)
@ -2107,6 +2198,12 @@ class CuraApplication(QtApplication):
def addNonSliceableExtension(self, extension): def addNonSliceableExtension(self, extension):
self._non_sliceable_extensions.append(extension) self._non_sliceable_extensions.append(extension)
@pyqtSlot(str, result = bool)
def isProjectUcp(self, file_url) -> bool:
file_path = QUrl(file_url).toLocalFile()
workspace_reader = self.getWorkspaceFileHandler().getReaderForFile(file_path)
return workspace_reader.getIsProjectUcp()
@pyqtSlot(str, result=bool) @pyqtSlot(str, result=bool)
def checkIsValidProjectFile(self, file_url): def checkIsValidProjectFile(self, file_url):
"""Checks if the given file URL is a valid project file. """ """Checks if the given file URL is a valid project file. """
@ -2116,6 +2213,8 @@ class CuraApplication(QtApplication):
if workspace_reader is None: if workspace_reader is None:
return False # non-project files won't get a reader return False # non-project files won't get a reader
try: try:
if workspace_reader.getPluginId() == "3MFReader":
workspace_reader.clearOpenAsUcp()
result = workspace_reader.preRead(file_path, show_dialog=False) result = workspace_reader.preRead(file_path, show_dialog=False)
return result == WorkspaceReader.PreReadResult.accepted return result == WorkspaceReader.PreReadResult.accepted
except: except:

88
cura/HitChecker.py Normal file
View file

@ -0,0 +1,88 @@
from typing import List, Dict
from cura.Scene.CuraSceneNode import CuraSceneNode
class HitChecker:
"""Checks if nodes can be printed without causing any collisions and interference"""
def __init__(self, nodes: List[CuraSceneNode]) -> None:
self._hit_map = self._buildHitMap(nodes)
def anyTwoNodesBlockEachOther(self, nodes: List[CuraSceneNode]) -> bool:
"""Returns True if any 2 nodes block each other"""
for a in nodes:
for b in nodes:
if self._hit_map[a][b] and self._hit_map[b][a]:
return True
return False
def canPrintBefore(self, node: CuraSceneNode, other_nodes: List[CuraSceneNode]) -> bool:
"""Returns True if node doesn't block other_nodes and can be printed before them"""
no_hits = all(not self._hit_map[node][other_node] for other_node in other_nodes)
return no_hits
def canPrintAfter(self, node: CuraSceneNode, other_nodes: List[CuraSceneNode]) -> bool:
"""Returns True if node doesn't hit other nodes and can be printed after them"""
no_hits = all(not self._hit_map[other_node][node] for other_node in other_nodes)
return no_hits
def calculateScore(self, a: CuraSceneNode, b: CuraSceneNode) -> int:
"""Calculate score simply sums the number of other objects it 'blocks'
:param a: node
:param b: node
:return: sum of the number of other objects
"""
score_a = sum(self._hit_map[a].values())
score_b = sum(self._hit_map[b].values())
return score_a - score_b
def canPrintNodesInProvidedOrder(self, ordered_nodes: List[CuraSceneNode]) -> bool:
"""Returns True If nodes don't have any hits in provided order"""
for node_index, node in enumerate(ordered_nodes):
nodes_before = ordered_nodes[:node_index - 1] if node_index - 1 >= 0 else []
nodes_after = ordered_nodes[node_index + 1:] if node_index + 1 < len(ordered_nodes) else []
if not self.canPrintBefore(node, nodes_after) or not self.canPrintAfter(node, nodes_before):
return False
return True
@staticmethod
def _buildHitMap(nodes: List[CuraSceneNode]) -> Dict[CuraSceneNode, CuraSceneNode]:
"""Pre-computes all hits between all objects
:nodes: nodes that need to be checked for collisions
:return: dictionary where hit_map[node1][node2] is False if there node1 can be printed before node2
"""
hit_map = {j: {i: HitChecker._checkHit(j, i) for i in nodes} for j in nodes}
return hit_map
@staticmethod
def _checkHit(a: CuraSceneNode, b: CuraSceneNode) -> bool:
"""Checks if a can be printed before b
:param a: node
:param b: node
:return: False if a can be printed before b
"""
if a == b:
return False
a_hit_hull = a.callDecoration("getConvexHullBoundary")
b_hit_hull = b.callDecoration("getConvexHullHeadFull")
overlap = a_hit_hull.intersectsPolygon(b_hit_hull)
if overlap:
return True
# Adhesion areas must never overlap, regardless of printing order
# This would cause over-extrusion
a_hit_hull = a.callDecoration("getAdhesionArea")
b_hit_hull = b.callDecoration("getAdhesionArea")
overlap = a_hit_hull.intersectsPolygon(b_hit_hull)
if overlap:
return True
else:
return False

View file

@ -49,7 +49,7 @@ class MachineErrorChecker(QObject):
self._keys_to_check = set() # type: Set[str] self._keys_to_check = set() # type: Set[str]
self._num_keys_to_check_per_update = 10 self._num_keys_to_check_per_update = 1
def initialize(self) -> None: def initialize(self) -> None:
self._error_check_timer.timeout.connect(self._rescheduleCheck) self._error_check_timer.timeout.connect(self._rescheduleCheck)

View file

@ -21,13 +21,20 @@ class MaterialNode(ContainerNode):
Its subcontainers are quality profiles. Its subcontainers are quality profiles.
""" """
def __init__(self, container_id: str, variant: "VariantNode") -> None: def __init__(self, container_id: str, variant: "VariantNode", *, container: ContainerInterface = None) -> None:
super().__init__(container_id) super().__init__(container_id)
self.variant = variant self.variant = variant
self.qualities = {} # type: Dict[str, QualityNode] # Mapping container IDs to quality profiles. self.qualities = {} # type: Dict[str, QualityNode] # Mapping container IDs to quality profiles.
self.materialChanged = Signal() # Triggered when the material is removed or its metadata is updated. self.materialChanged = Signal() # Triggered when the material is removed or its metadata is updated.
container_registry = ContainerRegistry.getInstance() container_registry = ContainerRegistry.getInstance()
if container is not None:
self.base_file = container.getMetaDataEntry("base_file")
self.material_type = container.getMetaDataEntry("material")
self.brand = container.getMetaDataEntry("brand")
self.guid = container.getMetaDataEntry("GUID")
else:
my_metadata = container_registry.findContainersMetadata(id = container_id)[0] my_metadata = container_registry.findContainersMetadata(id = container_id)[0]
self.base_file = my_metadata["base_file"] self.base_file = my_metadata["base_file"]
self.material_type = my_metadata["material"] self.material_type = my_metadata["material"]

View file

@ -227,7 +227,7 @@ class ExtrudersModel(ListModel):
"material_brand": "", "material_brand": "",
"color_name": "", "color_name": "",
"material_type": "", "material_type": "",
"material_label": "" "material_name": ""
} }
items.append(item) items.append(item)
if self._items != items: if self._items != items:

View file

@ -5,7 +5,7 @@
# online cloud connected printers are represented within this ListModel. Additional information such as the number of # online cloud connected printers are represented within this ListModel. Additional information such as the number of
# connected printers for each printer type is gathered. # connected printers for each printer type is gathered.
from typing import Optional, List, cast from typing import Optional, List, cast, Dict, Any
from PyQt6.QtCore import Qt, QTimer, QObject, pyqtSlot, pyqtProperty, pyqtSignal from PyQt6.QtCore import Qt, QTimer, QObject, pyqtSlot, pyqtProperty, pyqtSignal
@ -30,10 +30,10 @@ class MachineListModel(ListModel):
ComponentTypeRole = Qt.ItemDataRole.UserRole + 8 ComponentTypeRole = Qt.ItemDataRole.UserRole + 8
IsNetworkedMachineRole = Qt.ItemDataRole.UserRole + 9 IsNetworkedMachineRole = Qt.ItemDataRole.UserRole + 9
def __init__(self, parent: Optional[QObject] = None, machines_filter: List[GlobalStack] = None, listenToChanges: bool = True) -> None: def __init__(self, parent: Optional[QObject] = None, machines_filter: List[GlobalStack] = None, listenToChanges: bool = True, showCloudPrinters: bool = False) -> None:
super().__init__(parent) super().__init__(parent)
self._show_cloud_printers = False self._show_cloud_printers = showCloudPrinters
self._machines_filter = machines_filter self._machines_filter = machines_filter
self._catalog = i18nCatalog("cura") self._catalog = i18nCatalog("cura")
@ -159,3 +159,8 @@ class MachineListModel(ListModel):
"machineCount": machine_count, "machineCount": machine_count,
"catergory": "connected" if is_online else "other", "catergory": "connected" if is_online else "other",
}) })
def getItems(self) -> Dict[str, Any]:
if self.count > 0:
return self.items
return {}

View file

@ -148,7 +148,7 @@ class VariantNode(ContainerNode):
if "empty_material" in self.materials: if "empty_material" in self.materials:
del self.materials["empty_material"] del self.materials["empty_material"]
self.materials[base_file] = MaterialNode(container.getId(), variant = self) self.materials[base_file] = MaterialNode(container.getId(), variant = self, container = container)
self.materials[base_file].materialChanged.connect(self.materialsChanged) self.materials[base_file].materialChanged.connect(self.materialsChanged)
self.materialsChanged.emit(self.materials[base_file]) self.materialsChanged.emit(self.materials[base_file])

View file

@ -16,6 +16,7 @@ from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To downlo
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S" TOKEN_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
REQUEST_TIMEOUT = 5 # Seconds
class AuthorizationHelpers: class AuthorizationHelpers:
@ -40,6 +41,7 @@ class AuthorizationHelpers:
""" """
data = { data = {
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
"client_secret": self._settings.CLIENT_SECRET if self._settings.CLIENT_SECRET is not None else "",
"redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "", "redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "",
"grant_type": "authorization_code", "grant_type": "authorization_code",
"code": authorization_code, "code": authorization_code,
@ -52,7 +54,8 @@ class AuthorizationHelpers:
data = urllib.parse.urlencode(data).encode("UTF-8"), data = urllib.parse.urlencode(data).encode("UTF-8"),
headers_dict = headers, headers_dict = headers,
callback = lambda response: self.parseTokenResponse(response, callback), callback = lambda response: self.parseTokenResponse(response, callback),
error_callback = lambda response, _: self.parseTokenResponse(response, callback) error_callback = lambda response, _: self.parseTokenResponse(response, callback),
timeout = REQUEST_TIMEOUT
) )
def getAccessTokenUsingRefreshToken(self, refresh_token: str, callback: Callable[[AuthenticationResponse], None]) -> None: def getAccessTokenUsingRefreshToken(self, refresh_token: str, callback: Callable[[AuthenticationResponse], None]) -> None:
@ -64,6 +67,7 @@ class AuthorizationHelpers:
Logger.log("d", "Refreshing the access token for [%s]", self._settings.OAUTH_SERVER_URL) Logger.log("d", "Refreshing the access token for [%s]", self._settings.OAUTH_SERVER_URL)
data = { data = {
"client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "",
"client_secret": self._settings.CLIENT_SECRET if self._settings.CLIENT_SECRET is not None else "",
"redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "", "redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "",
"grant_type": "refresh_token", "grant_type": "refresh_token",
"refresh_token": refresh_token, "refresh_token": refresh_token,
@ -75,7 +79,9 @@ class AuthorizationHelpers:
data = urllib.parse.urlencode(data).encode("UTF-8"), data = urllib.parse.urlencode(data).encode("UTF-8"),
headers_dict = headers, headers_dict = headers,
callback = lambda response: self.parseTokenResponse(response, callback), callback = lambda response: self.parseTokenResponse(response, callback),
error_callback = lambda response, _: self.parseTokenResponse(response, callback) error_callback = lambda response, _: self.parseTokenResponse(response, callback),
urgent = True,
timeout = REQUEST_TIMEOUT
) )
def parseTokenResponse(self, token_response: QNetworkReply, callback: Callable[[AuthenticationResponse], None]) -> None: def parseTokenResponse(self, token_response: QNetworkReply, callback: Callable[[AuthenticationResponse], None]) -> None:
@ -120,7 +126,8 @@ class AuthorizationHelpers:
check_token_url, check_token_url,
headers_dict = headers, headers_dict = headers,
callback = lambda reply: self._parseUserProfile(reply, success_callback, failed_callback), callback = lambda reply: self._parseUserProfile(reply, success_callback, failed_callback),
error_callback = lambda _, _2: failed_callback() if failed_callback is not None else None error_callback = lambda _, _2: failed_callback() if failed_callback is not None else None,
timeout = REQUEST_TIMEOUT
) )
def _parseUserProfile(self, reply: QNetworkReply, success_callback: Optional[Callable[[UserProfile], None]], failed_callback: Optional[Callable[[], None]] = None) -> None: def _parseUserProfile(self, reply: QNetworkReply, success_callback: Optional[Callable[[UserProfile], None]], failed_callback: Optional[Callable[[], None]] = None) -> None:

View file

@ -1,4 +1,4 @@
# Copyright (c) 2021 Ultimaker B.V. # Copyright (c) 2024 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import json import json
@ -6,13 +6,14 @@ from datetime import datetime, timedelta
from typing import Callable, Dict, Optional, TYPE_CHECKING, Union from typing import Callable, Dict, Optional, TYPE_CHECKING, Union
from urllib.parse import urlencode, quote_plus from urllib.parse import urlencode, quote_plus
from PyQt6.QtCore import QUrl from PyQt6.QtCore import QUrl, QTimer
from PyQt6.QtGui import QDesktopServices from PyQt6.QtGui import QDesktopServices
from UM.Logger import Logger from UM.Logger import Logger
from UM.Message import Message from UM.Message import Message
from UM.Signal import Signal from UM.Signal import Signal
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To download log-in tokens.
from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers, TOKEN_TIMESTAMP_FORMAT from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers, TOKEN_TIMESTAMP_FORMAT
from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer
from cura.OAuth2.Models import AuthenticationResponse, BaseModel from cura.OAuth2.Models import AuthenticationResponse, BaseModel
@ -25,26 +26,32 @@ if TYPE_CHECKING:
MYCLOUD_LOGOFF_URL = "https://account.ultimaker.com/logoff?utm_source=cura&utm_medium=software&utm_campaign=change-account-before-adding-printers" MYCLOUD_LOGOFF_URL = "https://account.ultimaker.com/logoff?utm_source=cura&utm_medium=software&utm_campaign=change-account-before-adding-printers"
REFRESH_TOKEN_MAX_RETRIES = 15
REFRESH_TOKEN_RETRY_INTERVAL = 1000
class AuthorizationService: class AuthorizationService:
"""The authorization service is responsible for handling the login flow, storing user credentials and providing """The authorization service is responsible for handling the login flow, storing user credentials and providing
account information. account information.
""" """
def __init__(self,
settings: "OAuth2Settings",
preferences: Optional["Preferences"] = None,
get_user_profile: bool = True) -> None:
# Emit signal when authentication is completed. # Emit signal when authentication is completed.
onAuthStateChanged = Signal() self.onAuthStateChanged = Signal()
# Emit signal when authentication failed. # Emit signal when authentication failed.
onAuthenticationError = Signal() self.onAuthenticationError = Signal()
accessTokenChanged = Signal() self.accessTokenChanged = Signal()
def __init__(self, settings: "OAuth2Settings", preferences: Optional["Preferences"] = None) -> None:
self._settings = settings self._settings = settings
self._auth_helpers = AuthorizationHelpers(settings) self._auth_helpers = AuthorizationHelpers(settings)
self._auth_url = "{}/authorize".format(self._settings.OAUTH_SERVER_URL) self._auth_url = "{}/authorize".format(self._settings.OAUTH_SERVER_URL)
self._auth_data: Optional[AuthenticationResponse] = None self._auth_data: Optional[AuthenticationResponse] = None
self._user_profile: Optional["UserProfile"] = None self._user_profile: Optional["UserProfile"] = None
self._get_user_profile: bool = get_user_profile
self._preferences = preferences self._preferences = preferences
self._server = LocalAuthorizationServer(self._auth_helpers, self._onAuthStateChanged, daemon=True) self._server = LocalAuthorizationServer(self._auth_helpers, self._onAuthStateChanged, daemon=True)
self._currently_refreshing_token = False # Whether we are currently in the process of refreshing auth. Don't make new requests while busy. self._currently_refreshing_token = False # Whether we are currently in the process of refreshing auth. Don't make new requests while busy.
@ -53,6 +60,12 @@ class AuthorizationService:
self.onAuthStateChanged.connect(self._authChanged) self.onAuthStateChanged.connect(self._authChanged)
self._refresh_token_retries = 0
self._refresh_token_retry_timer = QTimer()
self._refresh_token_retry_timer.setInterval(REFRESH_TOKEN_RETRY_INTERVAL)
self._refresh_token_retry_timer.setSingleShot(True)
self._refresh_token_retry_timer.timeout.connect(self.refreshAccessToken)
def _authChanged(self, logged_in): def _authChanged(self, logged_in):
if logged_in and self._unable_to_get_data_message is not None: if logged_in and self._unable_to_get_data_message is not None:
self._unable_to_get_data_message.hide() self._unable_to_get_data_message.hide()
@ -163,16 +176,29 @@ class AuthorizationService:
return return
def process_auth_data(response: AuthenticationResponse) -> None: def process_auth_data(response: AuthenticationResponse) -> None:
self._currently_refreshing_token = False
if response.success: if response.success:
self._refresh_token_retries = 0
self._storeAuthData(response) self._storeAuthData(response)
HttpRequestManager.getInstance().setDelayRequests(False)
self.onAuthStateChanged.emit(logged_in = True) self.onAuthStateChanged.emit(logged_in = True)
else: else:
Logger.warning("Failed to get a new access token from the server.") if self._refresh_token_retries >= REFRESH_TOKEN_MAX_RETRIES:
self._refresh_token_retries = 0
Logger.warning("Failed to get a new access token from the server, giving up.")
HttpRequestManager.getInstance().setDelayRequests(False)
self.onAuthStateChanged.emit(logged_in = False) self.onAuthStateChanged.emit(logged_in = False)
else:
# Retry a bit later, network may be offline right now and will hopefully be back soon
Logger.warning("Failed to get a new access token from the server, retrying later.")
self._refresh_token_retries += 1
self._refresh_token_retry_timer.start()
if self._currently_refreshing_token: if self._currently_refreshing_token:
Logger.debug("Was already busy refreshing token. Do not start a new request.") Logger.debug("Was already busy refreshing token. Do not start a new request.")
return return
HttpRequestManager.getInstance().setDelayRequests(True)
self._currently_refreshing_token = True self._currently_refreshing_token = True
self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token, process_auth_data) self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token, process_auth_data)
@ -279,6 +305,7 @@ class AuthorizationService:
message_type = Message.MessageType.ERROR) message_type = Message.MessageType.ERROR)
Logger.warning("Unable to get user profile using auth data from preferences.") Logger.warning("Unable to get user profile using auth data from preferences.")
self._unable_to_get_data_message.show() self._unable_to_get_data_message.show()
if self._get_user_profile:
self.getUserProfile(callback) self.getUserProfile(callback)
except (ValueError, TypeError): except (ValueError, TypeError):
Logger.logException("w", "Could not load auth data from preferences") Logger.logException("w", "Could not load auth data from preferences")
@ -294,6 +321,7 @@ class AuthorizationService:
self._auth_data = auth_data self._auth_data = auth_data
self._currently_refreshing_token = False self._currently_refreshing_token = False
if auth_data: if auth_data:
if self._get_user_profile:
self.getUserProfile() self.getUserProfile()
self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(auth_data.dump())) self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(auth_data.dump()))
else: else:

View file

@ -16,6 +16,7 @@ class OAuth2Settings(BaseModel):
CALLBACK_PORT = None # type: Optional[int] CALLBACK_PORT = None # type: Optional[int]
OAUTH_SERVER_URL = None # type: Optional[str] OAUTH_SERVER_URL = None # type: Optional[str]
CLIENT_ID = None # type: Optional[str] CLIENT_ID = None # type: Optional[str]
CLIENT_SECRET = None # type: Optional[str]
CLIENT_SCOPES = None # type: Optional[str] CLIENT_SCOPES = None # type: Optional[str]
CALLBACK_URL = None # type: Optional[str] CALLBACK_URL = None # type: Optional[str]
AUTH_DATA_PREFERENCE_KEY = "" # type: str AUTH_DATA_PREFERENCE_KEY = "" # type: str

View file

@ -7,6 +7,11 @@ from UM.Scene.Iterator import Iterator
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from functools import cmp_to_key from functools import cmp_to_key
from cura.HitChecker import HitChecker
from cura.PrintOrderManager import PrintOrderManager
from cura.Scene.CuraSceneNode import CuraSceneNode
class OneAtATimeIterator(Iterator.Iterator): class OneAtATimeIterator(Iterator.Iterator):
"""Iterator that returns a list of nodes in the order that they need to be printed """Iterator that returns a list of nodes in the order that they need to be printed
@ -16,8 +21,6 @@ class OneAtATimeIterator(Iterator.Iterator):
def __init__(self, scene_node) -> None: def __init__(self, scene_node) -> None:
super().__init__(scene_node) # Call super to make multiple inheritance work. super().__init__(scene_node) # Call super to make multiple inheritance work.
self._hit_map = [[]] # type: List[List[bool]] # For each node, which other nodes this hits. A grid of booleans on which nodes hit which.
self._original_node_list = [] # type: List[SceneNode] # The nodes that need to be checked for collisions.
def _fillStack(self) -> None: def _fillStack(self) -> None:
"""Fills the ``_node_stack`` with a list of scene nodes that need to be printed in order. """ """Fills the ``_node_stack`` with a list of scene nodes that need to be printed in order. """
@ -38,104 +41,50 @@ class OneAtATimeIterator(Iterator.Iterator):
self._node_stack = node_list[:] self._node_stack = node_list[:]
return return
# Copy the list hit_checker = HitChecker(node_list)
self._original_node_list = node_list[:]
# Initialise the hit map (pre-compute all hits between all objects) if PrintOrderManager.isUserDefinedPrintOrderEnabled():
self._hit_map = [[self._checkHit(i, j) for i in node_list] for j in node_list] self._node_stack = self._getNodesOrderedByUser(hit_checker, node_list)
else:
self._node_stack = self._getNodesOrderedAutomatically(hit_checker, node_list)
# Check if we have to files that block each other. If this is the case, there is no solution! # update print orders so that user can try to arrange the nodes automatically first
for a in range(0, len(node_list)): # and if result is not satisfactory he/she can switch to manual mode and change it
for b in range(0, len(node_list)): for index, node in enumerate(self._node_stack):
if a != b and self._hit_map[a][b] and self._hit_map[b][a]: node.printOrder = index + 1
return
@staticmethod
def _getNodesOrderedByUser(hit_checker: HitChecker, node_list: List[CuraSceneNode]) -> List[CuraSceneNode]:
nodes_ordered_by_user = sorted(node_list, key=lambda n: n.printOrder)
if hit_checker.canPrintNodesInProvidedOrder(nodes_ordered_by_user):
return nodes_ordered_by_user
return [] # No solution
@staticmethod
def _getNodesOrderedAutomatically(hit_checker: HitChecker, node_list: List[CuraSceneNode]) -> List[CuraSceneNode]:
# Check if we have two files that block each other. If this is the case, there is no solution!
if hit_checker.anyTwoNodesBlockEachOther(node_list):
return [] # No solution
# Sort the original list so that items that block the most other objects are at the beginning. # Sort the original list so that items that block the most other objects are at the beginning.
# This does not decrease the worst case running time, but should improve it in most cases. # This does not decrease the worst case running time, but should improve it in most cases.
sorted(node_list, key = cmp_to_key(self._calculateScore)) node_list = sorted(node_list, key = cmp_to_key(hit_checker.calculateScore))
todo_node_list = [_ObjectOrder([], node_list)] todo_node_list = [_ObjectOrder([], node_list)]
while len(todo_node_list) > 0: while len(todo_node_list) > 0:
current = todo_node_list.pop() current = todo_node_list.pop()
for node in current.todo: for node in current.todo:
# Check if the object can be placed with what we have and still allows for a solution in the future # Check if the object can be placed with what we have and still allows for a solution in the future
if not self._checkHitMultiple(node, current.order) and not self._checkBlockMultiple(node, current.todo): if hit_checker.canPrintAfter(node, current.order) and hit_checker.canPrintBefore(node, current.todo):
# We found a possible result. Create new todo & order list. # We found a possible result. Create new todo & order list.
new_todo_list = current.todo[:] new_todo_list = current.todo[:]
new_todo_list.remove(node) new_todo_list.remove(node)
new_order = current.order[:] + [node] new_order = current.order[:] + [node]
if len(new_todo_list) == 0: if len(new_todo_list) == 0:
# We have no more nodes to check, so quit looking. # We have no more nodes to check, so quit looking.
self._node_stack = new_order return new_order # Solution found!
return
todo_node_list.append(_ObjectOrder(new_order, new_todo_list)) todo_node_list.append(_ObjectOrder(new_order, new_todo_list))
self._node_stack = [] #No result found! return [] # No result found!
# Check if first object can be printed before the provided list (using the hit map)
def _checkHitMultiple(self, node: SceneNode, other_nodes: List[SceneNode]) -> bool:
node_index = self._original_node_list.index(node)
for other_node in other_nodes:
other_node_index = self._original_node_list.index(other_node)
if self._hit_map[node_index][other_node_index]:
return True
return False
def _checkBlockMultiple(self, node: SceneNode, other_nodes: List[SceneNode]) -> bool:
"""Check for a node whether it hits any of the other nodes.
:param node: The node to check whether it collides with the other nodes.
:param other_nodes: The nodes to check for collisions.
:return: returns collision between nodes
"""
node_index = self._original_node_list.index(node)
for other_node in other_nodes:
other_node_index = self._original_node_list.index(other_node)
if self._hit_map[other_node_index][node_index] and node_index != other_node_index:
return True
return False
def _calculateScore(self, a: SceneNode, b: SceneNode) -> int:
"""Calculate score simply sums the number of other objects it 'blocks'
:param a: node
:param b: node
:return: sum of the number of other objects
"""
score_a = sum(self._hit_map[self._original_node_list.index(a)])
score_b = sum(self._hit_map[self._original_node_list.index(b)])
return score_a - score_b
def _checkHit(self, a: SceneNode, b: SceneNode) -> bool:
"""Checks if a can be printed before b
:param a: node
:param b: node
:return: true if a can be printed before b
"""
if a == b:
return False
a_hit_hull = a.callDecoration("getConvexHullBoundary")
b_hit_hull = b.callDecoration("getConvexHullHeadFull")
overlap = a_hit_hull.intersectsPolygon(b_hit_hull)
if overlap:
return True
# Adhesion areas must never overlap, regardless of printing order
# This would cause over-extrusion
a_hit_hull = a.callDecoration("getAdhesionArea")
b_hit_hull = b.callDecoration("getAdhesionArea")
overlap = a_hit_hull.intersectsPolygon(b_hit_hull)
if overlap:
return True
else:
return False
class _ObjectOrder: class _ObjectOrder:

View file

@ -39,6 +39,11 @@ class PlatformPhysics:
Application.getInstance().getPreferences().addPreference("physics/automatic_push_free", False) Application.getInstance().getPreferences().addPreference("physics/automatic_push_free", False)
Application.getInstance().getPreferences().addPreference("physics/automatic_drop_down", True) Application.getInstance().getPreferences().addPreference("physics/automatic_drop_down", True)
self._app_all_model_drop = False
def setAppAllModelDropDown(self):
self._app_all_model_drop = True
self._onChangeTimerFinished()
def _onSceneChanged(self, source): def _onSceneChanged(self, source):
if not source.callDecoration("isSliceable"): if not source.callDecoration("isSliceable"):
@ -80,9 +85,9 @@ class PlatformPhysics:
# Move it downwards if bottom is above platform # Move it downwards if bottom is above platform
move_vector = Vector() move_vector = Vector()
if node.getSetting(SceneNodeSettings.AutoDropDown, app_automatic_drop_down) and not (node.getParent() and node.getParent().callDecoration("isGroup") or node.getParent() != root) and node.isEnabled(): #If an object is grouped, don't move it down if (node.getSetting(SceneNodeSettings.AutoDropDown, app_automatic_drop_down) or self._app_all_model_drop) and not (node.getParent() and node.getParent().callDecoration("isGroup") or node.getParent() != root) and node.isEnabled():
z_offset = node.callDecoration("getZOffset") if node.getDecorator(ZOffsetDecorator.ZOffsetDecorator) else 0 z_offset = node.callDecoration("getZOffset") if node.getDecorator(ZOffsetDecorator.ZOffsetDecorator) else 0
move_vector = move_vector.set(y = -bbox.bottom + z_offset) move_vector = move_vector.set(y=-bbox.bottom + z_offset)
# If there is no convex hull for the node, start calculating it and continue. # If there is no convex hull for the node, start calculating it and continue.
if not node.getDecorator(ConvexHullDecorator) and not node.callDecoration("isNonPrintingMesh") and node.callDecoration("getLayerData") is None: if not node.getDecorator(ConvexHullDecorator) and not node.callDecoration("isNonPrintingMesh") and node.callDecoration("getLayerData") is None:
@ -168,6 +173,8 @@ class PlatformPhysics:
op = PlatformPhysicsOperation.PlatformPhysicsOperation(node, move_vector) op = PlatformPhysicsOperation.PlatformPhysicsOperation(node, move_vector)
op.push() op.push()
# setting this drop to model same as app_automatic_drop_down
self._app_all_model_drop = False
# After moving, we have to evaluate the boundary checks for nodes # After moving, we have to evaluate the boundary checks for nodes
build_volume.updateNodeBoundaryCheck() build_volume.updateNodeBoundaryCheck()

174
cura/PrintOrderManager.py Normal file
View file

@ -0,0 +1,174 @@
from typing import List, Callable, Optional, Any
from PyQt6.QtCore import pyqtProperty, pyqtSignal, QObject, pyqtSlot
from UM.Application import Application
from UM.Scene.Selection import Selection
from cura.Scene.CuraSceneNode import CuraSceneNode
class PrintOrderManager(QObject):
"""Allows to order the object list to set the print sequence manually"""
def __init__(self, get_nodes: Callable[[], List[CuraSceneNode]]) -> None:
super().__init__()
self._get_nodes = get_nodes
self._configureEvents()
_settingsChanged = pyqtSignal()
_uiActionsOutdated = pyqtSignal()
printOrderChanged = pyqtSignal()
@pyqtSlot()
def swapSelectedAndPreviousNodes(self) -> None:
selected_node, previous_node, next_node = self._getSelectedAndNeighborNodes()
self._swapPrintOrders(selected_node, previous_node)
@pyqtSlot()
def swapSelectedAndNextNodes(self) -> None:
selected_node, previous_node, next_node = self._getSelectedAndNeighborNodes()
self._swapPrintOrders(selected_node, next_node)
@pyqtProperty(str, notify=_uiActionsOutdated)
def previousNodeName(self) -> str:
selected_node, previous_node, next_node = self._getSelectedAndNeighborNodes()
return self._getNodeName(previous_node)
@pyqtProperty(str, notify=_uiActionsOutdated)
def nextNodeName(self) -> str:
selected_node, previous_node, next_node = self._getSelectedAndNeighborNodes()
return self._getNodeName(next_node)
@pyqtProperty(bool, notify=_uiActionsOutdated)
def shouldEnablePrintBeforeAction(self) -> bool:
selected_node, previous_node, next_node = self._getSelectedAndNeighborNodes()
can_swap_with_previous_node = selected_node is not None and previous_node is not None
return can_swap_with_previous_node
@pyqtProperty(bool, notify=_uiActionsOutdated)
def shouldEnablePrintAfterAction(self) -> bool:
selected_node, previous_node, next_node = self._getSelectedAndNeighborNodes()
can_swap_with_next_node = selected_node is not None and next_node is not None
return can_swap_with_next_node
@pyqtProperty(bool, notify=_settingsChanged)
def shouldShowEditPrintOrderActions(self) -> bool:
return PrintOrderManager.isUserDefinedPrintOrderEnabled()
@staticmethod
def isUserDefinedPrintOrderEnabled() -> bool:
stack = Application.getInstance().getGlobalContainerStack()
is_enabled = stack and \
stack.getProperty("print_sequence", "value") == "one_at_a_time" and \
stack.getProperty("user_defined_print_order_enabled", "value")
return bool(is_enabled)
@staticmethod
def initializePrintOrders(nodes: List[CuraSceneNode]) -> None:
"""Just created (loaded from file) nodes have print order 0.
This method initializes print orders with max value to put nodes at the end of object list"""
max_print_order = max(map(lambda n: n.printOrder, nodes), default=0)
for node in nodes:
if node.printOrder == 0:
max_print_order += 1
node.printOrder = max_print_order
@staticmethod
def updatePrintOrdersAfterGroupOperation(
all_nodes: List[CuraSceneNode],
group_node: CuraSceneNode,
grouped_nodes: List[CuraSceneNode]
) -> None:
group_node.printOrder = min(map(lambda n: n.printOrder, grouped_nodes))
all_nodes.append(group_node)
for node in grouped_nodes:
all_nodes.remove(node)
# reassign print orders so there won't be gaps like 1 2 5 6 7
sorted_nodes = sorted(all_nodes, key=lambda n: n.printOrder)
for i, node in enumerate(sorted_nodes):
node.printOrder = i + 1
@staticmethod
def updatePrintOrdersAfterUngroupOperation(
all_nodes: List[CuraSceneNode],
group_node: CuraSceneNode,
ungrouped_nodes: List[CuraSceneNode]
) -> None:
all_nodes.remove(group_node)
nodes_to_update_print_order = filter(lambda n: n.printOrder > group_node.printOrder, all_nodes)
for node in nodes_to_update_print_order:
node.printOrder += len(ungrouped_nodes) - 1
for i, child in enumerate(ungrouped_nodes):
child.printOrder = group_node.printOrder + i
all_nodes.append(child)
def _swapPrintOrders(self, node1: CuraSceneNode, node2: CuraSceneNode) -> None:
if node1 and node2:
node1.printOrder, node2.printOrder = node2.printOrder, node1.printOrder # swap print orders
self.printOrderChanged.emit() # update object list first
self._uiActionsOutdated.emit() # then update UI actions
def _getSelectedAndNeighborNodes(self
) -> (Optional[CuraSceneNode], Optional[CuraSceneNode], Optional[CuraSceneNode]):
nodes = self._get_nodes()
ordered_nodes = sorted(nodes, key=lambda n: n.printOrder)
for i, node in enumerate(ordered_nodes, 1):
node.printOrder = i
selected_node = PrintOrderManager._getSingleSelectedNode()
if selected_node and selected_node in ordered_nodes:
selected_node_index = ordered_nodes.index(selected_node)
else:
selected_node_index = None
if selected_node_index is not None and selected_node_index - 1 >= 0:
previous_node = ordered_nodes[selected_node_index - 1]
else:
previous_node = None
if selected_node_index is not None and selected_node_index + 1 < len(ordered_nodes):
next_node = ordered_nodes[selected_node_index + 1]
else:
next_node = None
return selected_node, previous_node, next_node
@staticmethod
def _getNodeName(node: CuraSceneNode, max_length: int = 30) -> str:
node_name = node.getName() if node else ""
truncated_node_name = node_name[:max_length]
return truncated_node_name
@staticmethod
def _getSingleSelectedNode() -> Optional[CuraSceneNode]:
if len(Selection.getAllSelectedObjects()) == 1:
selected_node = Selection.getSelectedObject(0)
return selected_node
return None
def _configureEvents(self) -> None:
Selection.selectionChanged.connect(self._onSelectionChanged)
self._global_stack = None
Application.getInstance().globalContainerStackChanged.connect(self._onGlobalStackChanged)
self._onGlobalStackChanged()
def _onGlobalStackChanged(self) -> None:
if self._global_stack:
self._global_stack.propertyChanged.disconnect(self._onSettingsChanged)
self._global_stack.containersChanged.disconnect(self._onSettingsChanged)
self._global_stack = Application.getInstance().getGlobalContainerStack()
if self._global_stack:
self._global_stack.propertyChanged.connect(self._onSettingsChanged)
self._global_stack.containersChanged.connect(self._onSettingsChanged)
def _onSettingsChanged(self, *args: Any) -> None:
self._settingsChanged.emit()
def _onSelectionChanged(self) -> None:
self._uiActionsOutdated.emit()

View file

@ -10,8 +10,8 @@ class MaterialOutputModel(QObject):
def __init__(self, guid: Optional[str], type: str, color: str, brand: str, name: str, parent = None) -> None: def __init__(self, guid: Optional[str], type: str, color: str, brand: str, name: str, parent = None) -> None:
super().__init__(parent) super().__init__(parent)
name, guid = MaterialOutputModel.getMaterialFromDefinition(guid,type, brand, name) name, guid = MaterialOutputModel.getMaterialFromDefinition(guid, type, brand, name)
self._guid =guid self._guid = guid
self._type = type self._type = type
self._color = color self._color = color
self._brand = brand self._brand = brand
@ -24,22 +24,22 @@ class MaterialOutputModel(QObject):
@staticmethod @staticmethod
def getMaterialFromDefinition(guid, type, brand, name): def getMaterialFromDefinition(guid, type, brand, name):
_MATERIAL_MAP = { "abs" :{"name" :"abs_175" ,"guid": "2780b345-577b-4a24-a2c5-12e6aad3e690"}, _MATERIAL_MAP = { "abs" :{"name" :"ABS" ,"guid": "2780b345-577b-4a24-a2c5-12e6aad3e690"},
"abs-wss1" :{"name" :"absr_175" ,"guid": "88c8919c-6a09-471a-b7b6-e801263d862d"}, "abs-cf10" :{"name": "ABS-CF" ,"guid": "495a0ce5-9daf-4a16-b7b2-06856d82394d"},
"asa" :{"name" :"asa_175" ,"guid": "416eead4-0d8e-4f0b-8bfc-a91a519befa5"}, "abs-wss1" :{"name" :"ABS-R" ,"guid": "88c8919c-6a09-471a-b7b6-e801263d862d"},
"nylon-cf" :{"name" :"cffpa_175" ,"guid": "85bbae0e-938d-46fb-989f-c9b3689dc4f0"}, "asa" :{"name" :"ASA" ,"guid": "f79bc612-21eb-482e-ad6c-87d75bdde066"},
"nylon" :{"name" :"nylon_175" ,"guid": "283d439a-3490-4481-920c-c51d8cdecf9c"}, "nylon12-cf":{"name": "Nylon 12 CF" ,"guid": "3c6f2877-71cc-4760-84e6-4b89ab243e3b"},
"pc" :{"name" :"pc_175" ,"guid": "62414577-94d1-490d-b1e4-7ef3ec40db02"}, "nylon" :{"name" :"Nylon" ,"guid": "283d439a-3490-4481-920c-c51d8cdecf9c"},
"petg" :{"name" :"petg_175" ,"guid": "69386c85-5b6c-421a-bec5-aeb1fb33f060"}, "pc" :{"name" :"PC" ,"guid": "62414577-94d1-490d-b1e4-7ef3ec40db02"},
"pla" :{"name" :"pla_175" ,"guid": "0ff92885-617b-4144-a03c-9989872454bc"}, "petg" :{"name" :"PETG" ,"guid": "69386c85-5b6c-421a-bec5-aeb1fb33f060"},
"pva" :{"name" :"pva_175" ,"guid": "a4255da2-cb2a-4042-be49-4a83957a2f9a"}, "pla" :{"name" :"PLA" ,"guid": "0ff92885-617b-4144-a03c-9989872454bc"},
"wss1" :{"name" :"rapidrinse_175","guid": "a140ef8f-4f26-4e73-abe0-cfc29d6d1024"}, "pva" :{"name" :"PVA" ,"guid": "a4255da2-cb2a-4042-be49-4a83957a2f9a"},
"sr30" :{"name" :"sr30_175" ,"guid": "77873465-83a9-4283-bc44-4e542b8eb3eb"}, "wss1" :{"name" :"RapidRinse" ,"guid": "a140ef8f-4f26-4e73-abe0-cfc29d6d1024"},
"im-pla" :{"name" :"tough_pla_175" ,"guid": "96fca5d9-0371-4516-9e96-8e8182677f3c"}, "sr30" :{"name" :"SR-30" ,"guid": "77873465-83a9-4283-bc44-4e542b8eb3eb"},
"bvoh" :{"name" :"bvoh_175" ,"guid": "923e604c-8432-4b09-96aa-9bbbd42207f4"}, "bvoh" :{"name" :"BVOH" ,"guid": "923e604c-8432-4b09-96aa-9bbbd42207f4"},
"cpe" :{"name" :"cpe_175" ,"guid": "da1872c1-b991-4795-80ad-bdac0f131726"}, "cpe" :{"name" :"CPE" ,"guid": "da1872c1-b991-4795-80ad-bdac0f131726"},
"hips" :{"name" :"hips_175" ,"guid": "a468d86a-220c-47eb-99a5-bbb47e514eb0"}, "hips" :{"name" :"HIPS" ,"guid": "a468d86a-220c-47eb-99a5-bbb47e514eb0"},
"tpu" :{"name" :"tpu_175" ,"guid": "19baa6a9-94ff-478b-b4a1-8157b74358d2"} "tpu" :{"name" :"TPU 95A" ,"guid": "19baa6a9-94ff-478b-b4a1-8157b74358d2"}
} }

View file

@ -11,6 +11,7 @@ from UM.Scene.SceneNode import SceneNode
from UM.Scene.SceneNodeDecorator import SceneNodeDecorator # To cast the deepcopy of every decorator back to SceneNodeDecorator. from UM.Scene.SceneNodeDecorator import SceneNodeDecorator # To cast the deepcopy of every decorator back to SceneNodeDecorator.
import cura.CuraApplication # To get the build plate. import cura.CuraApplication # To get the build plate.
from UM.Scene.SceneNodeSettings import SceneNodeSettings
from cura.Settings.ExtruderStack import ExtruderStack # For typing. from cura.Settings.ExtruderStack import ExtruderStack # For typing.
from cura.Settings.SettingOverrideDecorator import SettingOverrideDecorator # For per-object settings. from cura.Settings.SettingOverrideDecorator import SettingOverrideDecorator # For per-object settings.
@ -25,13 +26,26 @@ class CuraSceneNode(SceneNode):
if not no_setting_override: if not no_setting_override:
self.addDecorator(SettingOverrideDecorator()) # Now we always have a getActiveExtruderPosition, unless explicitly disabled self.addDecorator(SettingOverrideDecorator()) # Now we always have a getActiveExtruderPosition, unless explicitly disabled
self._outside_buildarea = False self._outside_buildarea = False
self._print_order = 0
def setOutsideBuildArea(self, new_value: bool) -> None: def setOutsideBuildArea(self, new_value: bool) -> None:
self._outside_buildarea = new_value self._outside_buildarea = new_value
@property
def printOrder(self):
return self._print_order
@printOrder.setter
def printOrder(self, new_value):
self._print_order = new_value
def isOutsideBuildArea(self) -> bool: def isOutsideBuildArea(self) -> bool:
return self._outside_buildarea or self.callDecoration("getBuildPlateNumber") < 0 return self._outside_buildarea or self.callDecoration("getBuildPlateNumber") < 0
@property
def isDropDownEnabled(self) ->bool:
return self.getSetting(SceneNodeSettings.AutoDropDown, Application.getInstance().getPreferences().getValue("physics/automatic_drop_down"))
def isVisible(self) -> bool: def isVisible(self) -> bool:
return super().isVisible() and self.callDecoration("getBuildPlateNumber") == cura.CuraApplication.CuraApplication.getInstance().getMultiBuildPlateModel().activeBuildPlate return super().isVisible() and self.callDecoration("getBuildPlateNumber") == cura.CuraApplication.CuraApplication.getInstance().getMultiBuildPlateModel().activeBuildPlate
@ -157,3 +171,6 @@ class CuraSceneNode(SceneNode):
def transformChanged(self) -> None: def transformChanged(self) -> None:
self._transformChanged() self._transformChanged()
def __repr__(self) -> str:
return "{print_order}. {name}".format(print_order = self._print_order, name = self.getName())

View file

@ -316,7 +316,13 @@ class ExtruderManager(QObject):
# Starts with the adhesion extruder. # Starts with the adhesion extruder.
adhesion_type = global_stack.getProperty("adhesion_type", "value") adhesion_type = global_stack.getProperty("adhesion_type", "value")
if adhesion_type in {"skirt", "brim"}: if adhesion_type in {"skirt", "brim"}:
return max(0, int(global_stack.getProperty("skirt_brim_extruder_nr", "value"))) # optional skirt/brim extruder defaults to zero skirt_brim_extruder_nr = global_stack.getProperty("skirt_brim_extruder_nr", "value")
# if the skirt_brim_extruder_nr is -1, then we use the first used extruder
if skirt_brim_extruder_nr == -1:
used_extruders = self.getUsedExtruderStacks()
return used_extruders[0].position
else:
return skirt_brim_extruder_nr
if adhesion_type == "raft": if adhesion_type == "raft":
return global_stack.getProperty("raft_base_extruder_nr", "value") return global_stack.getProperty("raft_base_extruder_nr", "value")

View file

@ -1,6 +1,6 @@
# Copyright (c) 2017 Ultimaker B.V. # Copyright (c) 2017 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import List, Optional, TYPE_CHECKING from typing import List, Optional, Set, TYPE_CHECKING
from PyQt6.QtCore import QObject, QTimer, pyqtProperty, pyqtSignal from PyQt6.QtCore import QObject, QTimer, pyqtProperty, pyqtSignal
from UM.FlameProfiler import pyqtSlot from UM.FlameProfiler import pyqtSlot
@ -168,37 +168,26 @@ class SettingInheritanceManager(QObject):
def settingsWithInheritanceWarning(self) -> List[str]: def settingsWithInheritanceWarning(self) -> List[str]:
return self._settings_with_inheritance_warning return self._settings_with_inheritance_warning
def _settingIsOverwritingInheritance(self, key: str, stack: ContainerStack = None) -> bool: def _userSettingIsOverwritingInheritance(self, key: str, stack: ContainerStack, all_keys: Set[str] = set()) -> bool:
"""Check if a setting has an inheritance function that is overwritten""" """Check if a setting known as having a User state has an inheritance function that is overwritten"""
has_setting_function = False has_setting_function = False
if not stack:
stack = self._active_container_stack
if not stack: # No active container stack yet!
return False
if self._active_container_stack is None:
return False
all_keys = self._active_container_stack.getAllKeys()
containers = [] # type: List[ContainerInterface] containers = [] # type: List[ContainerInterface]
has_user_state = stack.getProperty(key, "state") == InstanceState.User
"""Check if the setting has a user state. If not, it is never overwritten."""
if not has_user_state:
return False
# If a setting is not enabled, don't label it as overwritten (It's never visible anyway). # If a setting is not enabled, don't label it as overwritten (It's never visible anyway).
if not stack.getProperty(key, "enabled"): if not stack.getProperty(key, "enabled"):
return False return False
user_container = stack.getTop() user_container = stack.getTop()
"""Also check if the top container is not a setting function (this happens if the inheritance is restored).""" # Also check if the top container is not a setting function (this happens if the inheritance is restored).
if user_container and isinstance(user_container.getProperty(key, "value"), SettingFunction): if user_container and isinstance(user_container.getProperty(key, "value"), SettingFunction):
return False return False
if not all_keys:
all_keys = self._active_container_stack.getAllKeys()
## Mash all containers for all the stacks together. ## Mash all containers for all the stacks together.
while stack: while stack:
containers.extend(stack.getContainers()) containers.extend(stack.getContainers())
@ -229,17 +218,35 @@ class SettingInheritanceManager(QObject):
break # There is a setting function somewhere, stop looking deeper. break # There is a setting function somewhere, stop looking deeper.
return has_setting_function and has_non_function_value return has_setting_function and has_non_function_value
def _settingIsOverwritingInheritance(self, key: str, stack: ContainerStack = None) -> bool:
"""Check if a setting has an inheritance function that is overwritten"""
if not stack:
stack = self._active_container_stack
if not stack: # No active container stack yet!
return False
if self._active_container_stack is None:
return False
has_user_state = stack.getProperty(key, "state") == InstanceState.User
if not has_user_state:
return False
return self._userSettingIsOverwritingInheritance(key, stack)
def _update(self) -> None: def _update(self) -> None:
self._settings_with_inheritance_warning = [] # Reset previous data. self._settings_with_inheritance_warning = [] # Reset previous data.
# Make sure that the GlobalStack is not None. sometimes the globalContainerChanged signal gets here late. # Make sure that the GlobalStack is not None. sometimes the globalContainerChanged signal gets here late.
if self._global_container_stack is None: if self._global_container_stack is None or self._active_container_stack is None:
return return
# Check all setting keys that we know of and see if they are overridden. # Check all user setting keys that we know of and see if they are overridden.
for setting_key in self._global_container_stack.getAllKeys(): all_keys = self._active_container_stack.getAllKeys()
override = self._settingIsOverwritingInheritance(setting_key) for setting_key in self._active_container_stack.getAllKeysWithUserState():
if override: if self._userSettingIsOverwritingInheritance(setting_key, self._active_container_stack, all_keys):
self._settings_with_inheritance_warning.append(setting_key) self._settings_with_inheritance_warning.append(setting_key)
# Check all the categories if any of their children have their inheritance overwritten. # Check all the categories if any of their children have their inheritance overwritten.

View file

@ -5,16 +5,18 @@ import json
import os import os
from typing import List, Optional from typing import List, Optional
from PyQt6.QtCore import QUrl
from PyQt6.QtNetwork import QLocalServer, QLocalSocket from PyQt6.QtNetwork import QLocalServer, QLocalSocket
from UM.Qt.QtApplication import QtApplication #For typing. from UM.Qt.QtApplication import QtApplication # For typing.
from UM.Logger import Logger from UM.Logger import Logger
class SingleInstance: class SingleInstance:
def __init__(self, application: QtApplication, files_to_open: Optional[List[str]]) -> None: def __init__(self, application: QtApplication, files_to_open: Optional[List[str]], url_to_open: Optional[List[str]]) -> None:
self._application = application self._application = application
self._files_to_open = files_to_open self._files_to_open = files_to_open
self._url_to_open = url_to_open
self._single_instance_server = None self._single_instance_server = None
@ -33,7 +35,7 @@ class SingleInstance:
return False return False
# We only send the files that need to be opened. # We only send the files that need to be opened.
if not self._files_to_open: if not self._files_to_open and not self._url_to_open:
Logger.log("i", "No file need to be opened, do nothing.") Logger.log("i", "No file need to be opened, do nothing.")
return True return True
@ -55,8 +57,12 @@ class SingleInstance:
payload = {"command": "open", "filePath": os.path.abspath(filename)} payload = {"command": "open", "filePath": os.path.abspath(filename)}
single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding = "ascii")) single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding = "ascii"))
for url in self._url_to_open:
payload = {"command": "open-url", "urlPath": url.toString()}
single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding="ascii"))
payload = {"command": "close-connection"} payload = {"command": "close-connection"}
single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding = "ascii")) single_instance_socket.write(bytes(json.dumps(payload) + "\n", encoding="ascii"))
single_instance_socket.flush() single_instance_socket.flush()
single_instance_socket.waitForDisconnected() single_instance_socket.waitForDisconnected()
@ -72,7 +78,7 @@ class SingleInstance:
def _onClientConnected(self) -> None: def _onClientConnected(self) -> None:
Logger.log("i", "New connection received on our single-instance server") Logger.log("i", "New connection received on our single-instance server")
connection = None #type: Optional[QLocalSocket] connection = None # type: Optional[QLocalSocket]
if self._single_instance_server: if self._single_instance_server:
connection = self._single_instance_server.nextPendingConnection() connection = self._single_instance_server.nextPendingConnection()
@ -94,6 +100,12 @@ class SingleInstance:
elif command == "open": elif command == "open":
self._application.callLater(lambda f = payload["filePath"]: self._application._openFile(f)) self._application.callLater(lambda f = payload["filePath"]: self._application._openFile(f))
#command: Load a url link in Cura
elif command == "open-url":
url = QUrl(payload["urlPath"])
self._application.callLater(lambda: self._application._openUrl(url))
# Command: Activate the window and bring it to the top. # Command: Activate the window and bring it to the top.
elif command == "focus": elif command == "focus":
# Operating systems these days prevent windows from moving around by themselves. # Operating systems these days prevent windows from moving around by themselves.

View file

@ -21,23 +21,31 @@ from UM.Scene.SceneNode import SceneNode
from UM.Qt.QtRenderer import QtRenderer from UM.Qt.QtRenderer import QtRenderer
class Snapshot: class Snapshot:
DEFAULT_WIDTH_HEIGHT = 300
MAX_RENDER_DISTANCE = 10000
BOUND_BOX_FACTOR = 1.75
CAMERA_FOVY = 30
ATTEMPTS_FOR_SNAPSHOT = 10
@staticmethod @staticmethod
def getImageBoundaries(image: QImage): def getNonZeroPixels(image: QImage):
# Look at the resulting image to get a good crop.
# Get the pixels as byte array
pixel_array = image.bits().asarray(image.sizeInBytes()) pixel_array = image.bits().asarray(image.sizeInBytes())
width, height = image.width(), image.height() width, height = image.width(), image.height()
# Convert to numpy array, assume it's 32 bit (it should always be)
pixels = numpy.frombuffer(pixel_array, dtype=numpy.uint8).reshape([height, width, 4]) pixels = numpy.frombuffer(pixel_array, dtype=numpy.uint8).reshape([height, width, 4])
# Find indices of non zero pixels # Find indices of non zero pixels
nonzero_pixels = numpy.nonzero(pixels) return numpy.nonzero(pixels)
@staticmethod
def getImageBoundaries(image: QImage):
nonzero_pixels = Snapshot.getNonZeroPixels(image)
min_y, min_x, min_a_ = numpy.amin(nonzero_pixels, axis=1) # type: ignore min_y, min_x, min_a_ = numpy.amin(nonzero_pixels, axis=1) # type: ignore
max_y, max_x, max_a_ = numpy.amax(nonzero_pixels, axis=1) # type: ignore max_y, max_x, max_a_ = numpy.amax(nonzero_pixels, axis=1) # type: ignore
return min_x, max_x, min_y, max_y return min_x, max_x, min_y, max_y
@staticmethod @staticmethod
def isometricSnapshot(width: int = 300, height: int = 300, *, node: Optional[SceneNode] = None) -> Optional[QImage]: def isometricSnapshot(width: int = DEFAULT_WIDTH_HEIGHT, height: int = DEFAULT_WIDTH_HEIGHT, *, node: Optional[SceneNode] = None) -> Optional[QImage]:
""" """
Create an isometric snapshot of the scene. Create an isometric snapshot of the scene.
@ -92,8 +100,8 @@ class Snapshot:
camera_width / 2, camera_width / 2,
-camera_height / 2, -camera_height / 2,
camera_height / 2, camera_height / 2,
-10000, -Snapshot.MAX_RENDER_DISTANCE,
10000 Snapshot.MAX_RENDER_DISTANCE
) )
camera.setPerspective(False) camera.setPerspective(False)
camera.setProjectionMatrix(ortho_matrix) camera.setProjectionMatrix(ortho_matrix)
@ -112,14 +120,17 @@ class Snapshot:
return render_pass.getOutput() return render_pass.getOutput()
@staticmethod
def isNodeRenderable(node):
return not getattr(node, "_outside_buildarea", False) and node.callDecoration(
"isSliceable") and node.getMeshData() and node.isVisible() and not node.callDecoration(
"isNonThumbnailVisibleMesh")
@staticmethod @staticmethod
def nodeBounds(root_node: SceneNode) -> Optional[AxisAlignedBox]: def nodeBounds(root_node: SceneNode) -> Optional[AxisAlignedBox]:
axis_aligned_box = None axis_aligned_box = None
for node in DepthFirstIterator(root_node): for node in DepthFirstIterator(root_node):
if not getattr(node, "_outside_buildarea", False): if Snapshot.isNodeRenderable(node):
if node.callDecoration(
"isSliceable") and node.getMeshData() and node.isVisible() and not node.callDecoration(
"isNonThumbnailVisibleMesh"):
if axis_aligned_box is None: if axis_aligned_box is None:
axis_aligned_box = node.getBoundingBox() axis_aligned_box = node.getBoundingBox()
else: else:
@ -127,7 +138,7 @@ class Snapshot:
return axis_aligned_box return axis_aligned_box
@staticmethod @staticmethod
def snapshot(width = 300, height = 300): def snapshot(width = DEFAULT_WIDTH_HEIGHT, height = DEFAULT_WIDTH_HEIGHT, number_of_attempts = ATTEMPTS_FOR_SNAPSHOT):
"""Return a QImage of the scene """Return a QImage of the scene
Uses PreviewPass that leaves out some elements Aspect ratio assumes a square Uses PreviewPass that leaves out some elements Aspect ratio assumes a square
@ -163,13 +174,13 @@ class Snapshot:
looking_from_offset = Vector(-1, 1, 2) looking_from_offset = Vector(-1, 1, 2)
if size > 0: if size > 0:
# determine the watch distance depending on the size # determine the watch distance depending on the size
looking_from_offset = looking_from_offset * size * 1.75 looking_from_offset = looking_from_offset * size * Snapshot.BOUND_BOX_FACTOR
camera.setPosition(look_at + looking_from_offset) camera.setPosition(look_at + looking_from_offset)
camera.lookAt(look_at) camera.lookAt(look_at)
satisfied = False satisfied = False
size = None size = None
fovy = 30 fovy = Snapshot.CAMERA_FOVY
while not satisfied: while not satisfied:
if size is not None: if size is not None:
@ -184,9 +195,14 @@ class Snapshot:
pixel_output = preview_pass.getOutput() pixel_output = preview_pass.getOutput()
try: try:
min_x, max_x, min_y, max_y = Snapshot.getImageBoundaries(pixel_output) min_x, max_x, min_y, max_y = Snapshot.getImageBoundaries(pixel_output)
except (ValueError, AttributeError): except (ValueError, AttributeError) as e:
Logger.logException("w", "Failed to crop the snapshot!") if number_of_attempts == 0:
Logger.warning( f"Failed to crop the snapshot even after {Snapshot.ATTEMPTS_FOR_SNAPSHOT} attempts!")
return None return None
else:
number_of_attempts = number_of_attempts - 1
Logger.info("Trying to get the snapshot again.")
return Snapshot.snapshot(width, height, number_of_attempts)
size = max((max_x - min_x) / render_width, (max_y - min_y) / render_height) size = max((max_x - min_x) / render_width, (max_y - min_y) / render_height)
if size > 0.5 or satisfied: if size > 0.5 or satisfied:

View file

@ -14,6 +14,9 @@ from UM.Scene.SceneNode import SceneNode
from UM.Scene.Selection import Selection from UM.Scene.Selection import Selection
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from cura.PrintOrderManager import PrintOrderManager
from cura.Scene.CuraSceneNode import CuraSceneNode
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
@ -76,6 +79,9 @@ class ObjectsModel(ListModel):
self._build_plate_number = nr self._build_plate_number = nr
self._update() self._update()
def getNodes(self) -> List[CuraSceneNode]:
return list(map(lambda n: n["node"], self.items))
def _updateSceneDelayed(self, source) -> None: def _updateSceneDelayed(self, source) -> None:
if not isinstance(source, Camera): if not isinstance(source, Camera):
self._update_timer.start() self._update_timer.start()
@ -175,6 +181,10 @@ class ObjectsModel(ListModel):
all_nodes = self._renameNodes(name_to_node_info_dict) all_nodes = self._renameNodes(name_to_node_info_dict)
user_defined_print_order_enabled = PrintOrderManager.isUserDefinedPrintOrderEnabled()
if user_defined_print_order_enabled:
PrintOrderManager.initializePrintOrders(all_nodes)
for node in all_nodes: for node in all_nodes:
if hasattr(node, "isOutsideBuildArea"): if hasattr(node, "isOutsideBuildArea"):
is_outside_build_area = node.isOutsideBuildArea() # type: ignore is_outside_build_area = node.isOutsideBuildArea() # type: ignore
@ -223,8 +233,13 @@ class ObjectsModel(ListModel):
# for anti overhang meshes and groups the extruder nr is irrelevant # for anti overhang meshes and groups the extruder nr is irrelevant
extruder_number = -1 extruder_number = -1
if not user_defined_print_order_enabled:
name = node.getName()
else:
name = "{print_order}. {name}".format(print_order = node.printOrder, name = node.getName())
nodes.append({ nodes.append({
"name": node.getName(), "name": name,
"selected": Selection.isSelected(node), "selected": Selection.isSelected(node),
"outside_build_area": is_outside_build_area, "outside_build_area": is_outside_build_area,
"buildplate_number": node_build_plate_number, "buildplate_number": node_build_plate_number,
@ -234,5 +249,5 @@ class ObjectsModel(ListModel):
"node": node "node": node
}) })
nodes = sorted(nodes, key=lambda n: n["name"]) nodes = sorted(nodes, key=lambda n: n["name"] if not user_defined_print_order_enabled else n["node"].printOrder)
self.setItems(nodes) self.setItems(nodes)

View file

@ -15,6 +15,10 @@ if "" in sys.path:
import argparse import argparse
import faulthandler import faulthandler
import os import os
# set the environment variable QT_QUICK_FLICKABLE_WHEEL_DECELERATION to 5000 as mentioned in qt6.6 update log to overcome scroll related issues
os.environ["QT_QUICK_FLICKABLE_WHEEL_DECELERATION"] = str(int(os.environ.get("QT_QUICK_FLICKABLE_WHEEL_DECELERATION", "5000")))
if sys.platform != "linux": # Turns out the Linux build _does_ use this, but we're not making an Enterprise release for that system anyway. if sys.platform != "linux": # Turns out the Linux build _does_ use this, but we're not making an Enterprise release for that system anyway.
os.environ["QT_PLUGIN_PATH"] = "" # Security workaround: Don't need it, and introduces an attack vector, so set to nul. os.environ["QT_PLUGIN_PATH"] = "" # Security workaround: Don't need it, and introduces an attack vector, so set to nul.
os.environ["QML2_IMPORT_PATH"] = "" # Security workaround: Don't need it, and introduces an attack vector, so set to nul. os.environ["QML2_IMPORT_PATH"] = "" # Security workaround: Don't need it, and introduces an attack vector, so set to nul.

View file

@ -0,0 +1,62 @@
# Copyright (c) 2024 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt6.QtCore import Qt, pyqtSignal
from UM import i18nCatalog
from UM.Logger import Logger
from UM.Settings.SettingDefinition import SettingDefinition
from UM.Qt.ListModel import ListModel
class SpecificSettingsModel(ListModel):
CategoryRole = Qt.ItemDataRole.UserRole + 1
LabelRole = Qt.ItemDataRole.UserRole + 2
ValueRole = Qt.ItemDataRole.UserRole + 3
def __init__(self, parent = None):
super().__init__(parent = parent)
self.addRoleName(self.CategoryRole, "category")
self.addRoleName(self.LabelRole, "label")
self.addRoleName(self.ValueRole, "value")
self._settings_catalog = i18nCatalog("fdmprinter.def.json")
self._update()
modelChanged = pyqtSignal()
def addSettingsFromStack(self, stack, category, settings):
for setting, value in settings.items():
unit = stack.getProperty(setting, "unit")
setting_type = stack.getProperty(setting, "type")
if setting_type is not None:
# This is not very good looking, but will do for now
value = str(SettingDefinition.settingValueToString(setting_type, value))
if unit:
value += " " + str(unit)
if setting_type == "enum":
options = stack.getProperty(setting, "options")
value_msgctxt = f"{str(setting)} option {str(value)}"
value_msgid = options[stack.getProperty(setting, "value")]
value = self._settings_catalog.i18nc(value_msgctxt, value_msgid)
else:
value = str(value)
label_msgctxt = f"{str(setting)} label"
label_msgid = stack.getProperty(setting, "label")
label = self._settings_catalog.i18nc(label_msgctxt, label_msgid)
self.appendItem({
"category": category,
"label": label,
"value": value
})
self.modelChanged.emit()
def _update(self):
Logger.debug(f"Updating {self.__class__.__name__}")
self.setItems([])
self.modelChanged.emit()
return

View file

@ -16,6 +16,7 @@ from UM.Mesh.MeshReader import MeshReader
from UM.MimeTypeDatabase import MimeTypeDatabase, MimeType from UM.MimeTypeDatabase import MimeTypeDatabase, MimeType
from UM.Scene.GroupDecorator import GroupDecorator from UM.Scene.GroupDecorator import GroupDecorator
from UM.Scene.SceneNode import SceneNode # For typing. from UM.Scene.SceneNode import SceneNode # For typing.
from UM.Scene.SceneNodeSettings import SceneNodeSettings
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
from cura.Machines.ContainerTree import ContainerTree from cura.Machines.ContainerTree import ContainerTree
from cura.Scene.BuildPlateDecorator import BuildPlateDecorator from cura.Scene.BuildPlateDecorator import BuildPlateDecorator
@ -41,7 +42,7 @@ class ThreeMFReader(MeshReader):
MimeTypeDatabase.addMimeType( MimeTypeDatabase.addMimeType(
MimeType( MimeType(
name = "application/vnd.ms-package.3dmanufacturing-3dmodel+xml", name="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
comment="3MF", comment="3MF",
suffixes=["3mf"] suffixes=["3mf"]
) )
@ -177,6 +178,12 @@ class ThreeMFReader(MeshReader):
else: else:
Logger.log("w", "Unable to find extruder in position %s", setting_value) Logger.log("w", "Unable to find extruder in position %s", setting_value)
continue continue
if key == "print_order":
um_node.printOrder = int(setting_value)
continue
if key =="drop_to_buildplate":
um_node.setSetting(SceneNodeSettings.AutoDropDown, eval(setting_value))
continue
if key in known_setting_keys: if key in known_setting_keys:
setting_container.setProperty(key, "value", setting_value) setting_container.setProperty(key, "value", setting_value)
else: else:

View file

@ -5,10 +5,13 @@ from configparser import ConfigParser
import zipfile import zipfile
import os import os
import json import json
import re
from typing import cast, Dict, List, Optional, Tuple, Any, Set from typing import cast, Dict, List, Optional, Tuple, Any, Set
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from UM.Math.AxisAlignedBox import AxisAlignedBox
from UM.Math.Vector import Vector
from UM.Util import parseBool from UM.Util import parseBool
from UM.Workspace.WorkspaceReader import WorkspaceReader from UM.Workspace.WorkspaceReader import WorkspaceReader
from UM.Application import Application from UM.Application import Application
@ -57,6 +60,7 @@ _ignored_machine_network_metadata: Set[str] = {
"is_abstract_machine" "is_abstract_machine"
} }
USER_SETTINGS_PATH = "Cura/user-settings.json"
class ContainerInfo: class ContainerInfo:
def __init__(self, file_name: Optional[str], serialized: Optional[str], parser: Optional[ConfigParser]) -> None: def __init__(self, file_name: Optional[str], serialized: Optional[str], parser: Optional[ConfigParser]) -> None:
@ -115,6 +119,7 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
self._supported_extensions = [".3mf"] self._supported_extensions = [".3mf"]
self._dialog = WorkspaceDialog() self._dialog = WorkspaceDialog()
self._3mf_mesh_reader = None self._3mf_mesh_reader = None
self._is_ucp = None
self._container_registry = ContainerRegistry.getInstance() self._container_registry = ContainerRegistry.getInstance()
# suffixes registered with the MimeTypes don't start with a dot '.' # suffixes registered with the MimeTypes don't start with a dot '.'
@ -141,10 +146,16 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
self._old_new_materials: Dict[str, str] = {} self._old_new_materials: Dict[str, str] = {}
self._machine_info = None self._machine_info = None
self._user_settings: Dict[str, Dict[str, Any]] = {}
def _clearState(self): def _clearState(self):
self._id_mapping = {} self._id_mapping = {}
self._old_new_materials = {} self._old_new_materials = {}
self._machine_info = None self._machine_info = None
self._user_settings = {}
def clearOpenAsUcp(self):
self._is_ucp = None
def getNewId(self, old_id: str): def getNewId(self, old_id: str):
"""Get a unique name based on the old_id. This is different from directly calling the registry in that it caches results. """Get a unique name based on the old_id. This is different from directly calling the registry in that it caches results.
@ -200,6 +211,16 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
return global_stack_file_list[0], extruder_stack_file_list return global_stack_file_list[0], extruder_stack_file_list
def _isProjectUcp(self, file_name) -> bool:
if self._is_ucp == None:
archive = zipfile.ZipFile(file_name, "r")
cura_file_names = [name for name in archive.namelist() if name.startswith("Cura/")]
self._is_ucp =True if USER_SETTINGS_PATH in cura_file_names else False
def getIsProjectUcp(self) -> bool:
return self._is_ucp
def preRead(self, file_name, show_dialog=True, *args, **kwargs): def preRead(self, file_name, show_dialog=True, *args, **kwargs):
"""Read some info so we can make decisions """Read some info so we can make decisions
@ -208,7 +229,7 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
we don't want to show a dialog. we don't want to show a dialog.
""" """
self._clearState() self._clearState()
self._isProjectUcp(file_name)
self._3mf_mesh_reader = Application.getInstance().getMeshFileHandler().getReaderForFile(file_name) self._3mf_mesh_reader = Application.getInstance().getMeshFileHandler().getReaderForFile(file_name)
if self._3mf_mesh_reader and self._3mf_mesh_reader.preRead(file_name) == WorkspaceReader.PreReadResult.accepted: if self._3mf_mesh_reader and self._3mf_mesh_reader.preRead(file_name) == WorkspaceReader.PreReadResult.accepted:
pass pass
@ -228,11 +249,14 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
self._resolve_strategies = {k: None for k in resolve_strategy_keys} self._resolve_strategies = {k: None for k in resolve_strategy_keys}
containers_found_dict = {k: False for k in resolve_strategy_keys} containers_found_dict = {k: False for k in resolve_strategy_keys}
# Check whether the file is a UCP, which changes some import options
is_ucp = USER_SETTINGS_PATH in cura_file_names
# #
# Read definition containers # Read definition containers
# #
machine_definition_id = None machine_definition_id = None
updatable_machines = [] updatable_machines = None if self._is_ucp else []
machine_definition_container_count = 0 machine_definition_container_count = 0
extruder_definition_container_count = 0 extruder_definition_container_count = 0
definition_container_files = [name for name in cura_file_names if name.endswith(self._definition_container_suffix)] definition_container_files = [name for name in cura_file_names if name.endswith(self._definition_container_suffix)]
@ -250,7 +274,7 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
if definition_container_type == "machine": if definition_container_type == "machine":
machine_definition_id = container_id machine_definition_id = container_id
machine_definition_containers = self._container_registry.findDefinitionContainers(id = machine_definition_id) machine_definition_containers = self._container_registry.findDefinitionContainers(id = machine_definition_id)
if machine_definition_containers: if machine_definition_containers and updatable_machines is not None:
updatable_machines = [machine for machine in self._container_registry.findContainerStacks(type = "machine") if machine.definition == machine_definition_containers[0]] updatable_machines = [machine for machine in self._container_registry.findContainerStacks(type = "machine") if machine.definition == machine_definition_containers[0]]
machine_type = definition_container["name"] machine_type = definition_container["name"]
variant_type_name = definition_container.get("variants_name", variant_type_name) variant_type_name = definition_container.get("variants_name", variant_type_name)
@ -597,6 +621,39 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
package_metadata = self._parse_packages_metadata(archive) package_metadata = self._parse_packages_metadata(archive)
missing_package_metadata = self._filter_missing_package_metadata(package_metadata) missing_package_metadata = self._filter_missing_package_metadata(package_metadata)
# Load the user specifically exported settings
self._dialog.exportedSettingModel.clear()
self._dialog.setCurrentMachineName("")
if self._is_ucp:
try:
self._user_settings = json.loads(archive.open("Cura/user-settings.json").read().decode("utf-8"))
any_extruder_stack = ExtruderManager.getInstance().getExtruderStack(0)
actual_global_stack = CuraApplication.getInstance().getGlobalContainerStack()
self._dialog.setCurrentMachineName(actual_global_stack.id)
for stack_name, settings in self._user_settings.items():
if stack_name == 'global':
self._dialog.exportedSettingModel.addSettingsFromStack(actual_global_stack, i18n_catalog.i18nc("@label", "Global"), settings)
else:
extruder_match = re.fullmatch('extruder_([0-9]+)', stack_name)
if extruder_match is not None:
extruder_nr = int(extruder_match.group(1))
self._dialog.exportedSettingModel.addSettingsFromStack(any_extruder_stack,
i18n_catalog.i18nc("@label",
"Extruder {0}", extruder_nr + 1),
settings)
except KeyError as e:
# If there is no user settings file, it's not a UCP, so notify user of failure.
Logger.log("w", "File %s is not a valid UCP.", file_name)
message = Message(
i18n_catalog.i18nc("@info:error Don't translate the XML tags <filename> or <message>!",
"Project file <filename>{0}</filename> is corrupt: <message>{1}</message>.",
file_name, str(e)),
title=i18n_catalog.i18nc("@info:title", "Can't Open Project File"),
message_type=Message.MessageType.ERROR)
message.show()
return WorkspaceReader.PreReadResult.failed
# Show the dialog, informing the user what is about to happen. # Show the dialog, informing the user what is about to happen.
self._dialog.setMachineConflict(machine_conflict) self._dialog.setMachineConflict(machine_conflict)
self._dialog.setIsPrinterGroup(is_printer_group) self._dialog.setIsPrinterGroup(is_printer_group)
@ -617,12 +674,15 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
self._dialog.setVariantType(variant_type_name) self._dialog.setVariantType(variant_type_name)
self._dialog.setHasObjectsOnPlate(Application.getInstance().platformActivity) self._dialog.setHasObjectsOnPlate(Application.getInstance().platformActivity)
self._dialog.setMissingPackagesMetadata(missing_package_metadata) self._dialog.setMissingPackagesMetadata(missing_package_metadata)
self._dialog.setAllowCreatemachine(not self._is_ucp)
self._dialog.setIsUcp(self._is_ucp)
self._dialog.show() self._dialog.show()
# Choosing the initially selected printer in MachineSelector # Choosing the initially selected printer in MachineSelector
is_networked_machine = False is_networked_machine = False
is_abstract_machine = False is_abstract_machine = False
if global_stack and isinstance(global_stack, GlobalStack): if global_stack and isinstance(global_stack, GlobalStack) and not self._is_ucp:
# The machine included in the project file exists locally already, no need to change selected printers. # The machine included in the project file exists locally already, no need to change selected printers.
is_networked_machine = global_stack.hasNetworkedConnection() is_networked_machine = global_stack.hasNetworkedConnection()
is_abstract_machine = parseBool(existing_global_stack.getMetaDataEntry("is_abstract_machine", False)) is_abstract_machine = parseBool(existing_global_stack.getMetaDataEntry("is_abstract_machine", False))
@ -631,7 +691,7 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
elif self._dialog.updatableMachinesModel.count > 0: elif self._dialog.updatableMachinesModel.count > 0:
# The machine included in the project file does not exist. There is another machine of the same type. # The machine included in the project file does not exist. There is another machine of the same type.
# This will always default to an abstract machine first. # This will always default to an abstract machine first.
machine = self._dialog.updatableMachinesModel.getItem(0) machine = self._dialog.updatableMachinesModel.getItem(self._dialog.currentMachinePositionIndex)
machine_name = machine["name"] machine_name = machine["name"]
is_networked_machine = machine["isNetworked"] is_networked_machine = machine["isNetworked"]
is_abstract_machine = machine["isAbstractMachine"] is_abstract_machine = machine["isAbstractMachine"]
@ -648,6 +708,7 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
self._dialog.setIsNetworkedMachine(is_networked_machine) self._dialog.setIsNetworkedMachine(is_networked_machine)
self._dialog.setIsAbstractMachine(is_abstract_machine) self._dialog.setIsAbstractMachine(is_abstract_machine)
self._dialog.setMachineName(machine_name) self._dialog.setMachineName(machine_name)
self._dialog.updateCompatibleMachine()
# Block until the dialog is closed. # Block until the dialog is closed.
self._dialog.waitForClose() self._dialog.waitForClose()
@ -669,7 +730,6 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
if key not in containers_found_dict or strategy is not None: if key not in containers_found_dict or strategy is not None:
continue continue
self._resolve_strategies[key] = "override" if containers_found_dict[key] else "new" self._resolve_strategies[key] = "override" if containers_found_dict[key] else "new"
return WorkspaceReader.PreReadResult.accepted return WorkspaceReader.PreReadResult.accepted
@call_on_qt_thread @call_on_qt_thread
@ -777,6 +837,7 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
for stack in extruder_stacks: for stack in extruder_stacks:
stack.setNextStack(global_stack, connect_signals = False) stack.setNextStack(global_stack, connect_signals = False)
if not self._is_ucp:
Logger.log("d", "Workspace loading is checking definitions...") Logger.log("d", "Workspace loading is checking definitions...")
# Get all the definition files & check if they exist. If not, add them. # Get all the definition files & check if they exist. If not, add them.
definition_container_files = [name for name in cura_file_names if name.endswith(self._definition_container_suffix)] definition_container_files = [name for name in cura_file_names if name.endswith(self._definition_container_suffix)]
@ -850,6 +911,7 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
QCoreApplication.processEvents() # Ensure that the GUI does not freeze. QCoreApplication.processEvents() # Ensure that the GUI does not freeze.
if global_stack: if global_stack:
if not self._is_ucp:
# Handle quality changes if any # Handle quality changes if any
self._processQualityChanges(global_stack) self._processQualityChanges(global_stack)
@ -864,16 +926,40 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
# function is running on the main thread (Qt thread), although those "changed" signals have been emitted, but # function is running on the main thread (Qt thread), although those "changed" signals have been emitted, but
# they won't take effect until this function is done. # they won't take effect until this function is done.
# To solve this, we schedule _updateActiveMachine() for later so it will have the latest data. # To solve this, we schedule _updateActiveMachine() for later so it will have the latest data.
self._updateActiveMachine(global_stack) self._updateActiveMachine(global_stack)
if self._is_ucp:
# Now we have switched, apply the user settings
self._applyUserSettings(global_stack, extruder_stack_dict, self._user_settings)
# Load all the nodes / mesh data of the workspace # Load all the nodes / mesh data of the workspace
nodes = self._3mf_mesh_reader.read(file_name) nodes = self._3mf_mesh_reader.read(file_name)
if nodes is None: if nodes is None:
nodes = [] nodes = []
if self._is_ucp:
# We might be on a different printer than the one this project was made on.
# The offset to the printers' center isn't saved; instead, try to just fit everything on the buildplate.
full_extents = None
for node in nodes:
extents = node.getMeshData().getExtents() if node.getMeshData() else None
if extents is not None:
pos = node.getPosition()
node_box = AxisAlignedBox(extents.minimum + pos, extents.maximum + pos)
if full_extents is None:
full_extents = node_box
else:
full_extents = full_extents + node_box
if full_extents and full_extents.isValid():
for node in nodes:
pos = node.getPosition()
node.setPosition(Vector(pos.x - full_extents.center.x, pos.y, pos.z - full_extents.center.z))
base_file_name = os.path.basename(file_name) base_file_name = os.path.basename(file_name)
self.setWorkspaceName(base_file_name) self.setWorkspaceName(base_file_name)
self._is_ucp = None
return nodes, self._loadMetadata(file_name) return nodes, self._loadMetadata(file_name)
@staticmethod @staticmethod
@ -1159,7 +1245,7 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
node = machine_node.variants.get(machine_node.preferred_variant_name, next(iter(machine_node.variants.values()))) node = machine_node.variants.get(machine_node.preferred_variant_name, next(iter(machine_node.variants.values())))
else: else:
variant_name = extruder_info.variant_info.parser["general"]["name"] variant_name = extruder_info.variant_info.parser["general"]["name"]
node = ContainerTree.getInstance().machines[global_stack.definition.getId()].variants[variant_name] node = ContainerTree.getInstance().machines[global_stack.definition.getId()].variants.get(variant_name, next(iter(machine_node.variants.values())))
extruder_stack.variant = node.container extruder_stack.variant = node.container
def _applyMaterials(self, global_stack, extruder_stack_dict): def _applyMaterials(self, global_stack, extruder_stack_dict):
@ -1174,24 +1260,50 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
root_material_id = extruder_info.root_material_id root_material_id = extruder_info.root_material_id
root_material_id = self._old_new_materials.get(root_material_id, root_material_id) root_material_id = self._old_new_materials.get(root_material_id, root_material_id)
material_node = machine_node.variants[extruder_stack.variant.getName()].materials[root_material_id] available_materials = machine_node.variants[extruder_stack.variant.getName()].materials
if root_material_id not in available_materials:
continue
material_node = available_materials[root_material_id]
extruder_stack.material = material_node.container extruder_stack.material = material_node.container
def _applyChangesToMachine(self, global_stack, extruder_stack_dict): def _clearMachineSettings(self, global_stack, extruder_stack_dict):
# Clear all first
self._clearStack(global_stack) self._clearStack(global_stack)
for extruder_stack in extruder_stack_dict.values(): for extruder_stack in extruder_stack_dict.values():
self._clearStack(extruder_stack) self._clearStack(extruder_stack)
self._quality_changes_to_apply = None
self._quality_type_to_apply = None
self._intent_category_to_apply = None
self._user_settings_to_apply = None
def _applyUserSettings(self, global_stack, extruder_stack_dict, user_settings):
for stack_name, settings in user_settings.items():
if stack_name == 'global':
ThreeMFWorkspaceReader._applyUserSettingsOnStack(global_stack, settings)
else:
extruder_match = re.fullmatch('extruder_([0-9]+)', stack_name)
if extruder_match is not None:
extruder_nr = extruder_match.group(1)
if extruder_nr in extruder_stack_dict:
ThreeMFWorkspaceReader._applyUserSettingsOnStack(extruder_stack_dict[extruder_nr], settings)
@staticmethod
def _applyUserSettingsOnStack(stack, user_settings):
user_settings_container = stack.userChanges
for setting_to_import, setting_value in user_settings.items():
user_settings_container.setProperty(setting_to_import, 'value', setting_value)
def _applyChangesToMachine(self, global_stack, extruder_stack_dict):
# Clear all first
self._clearMachineSettings(global_stack, extruder_stack_dict)
self._applyDefinitionChanges(global_stack, extruder_stack_dict) self._applyDefinitionChanges(global_stack, extruder_stack_dict)
self._applyUserChanges(global_stack, extruder_stack_dict) self._applyUserChanges(global_stack, extruder_stack_dict)
self._applyVariants(global_stack, extruder_stack_dict) self._applyVariants(global_stack, extruder_stack_dict)
self._applyMaterials(global_stack, extruder_stack_dict) self._applyMaterials(global_stack, extruder_stack_dict)
# prepare the quality to select # prepare the quality to select
self._quality_changes_to_apply = None
self._quality_type_to_apply = None
self._intent_category_to_apply = None
if self._machine_info.quality_changes_info is not None: if self._machine_info.quality_changes_info is not None:
self._quality_changes_to_apply = self._machine_info.quality_changes_info.name self._quality_changes_to_apply = self._machine_info.quality_changes_info.name
else: else:
@ -1229,11 +1341,12 @@ class ThreeMFWorkspaceReader(WorkspaceReader):
machine_manager.setActiveMachine(global_stack.getId()) machine_manager.setActiveMachine(global_stack.getId())
# Set metadata fields that are missing from the global stack # Set metadata fields that are missing from the global stack
if not self._is_ucp:
for key, value in self._machine_info.metadata_dict.items(): for key, value in self._machine_info.metadata_dict.items():
if key not in global_stack.getMetaData() and key not in _ignored_machine_network_metadata: if key not in global_stack.getMetaData() and key not in _ignored_machine_network_metadata:
global_stack.setMetaDataEntry(key, value) global_stack.setMetaDataEntry(key, value)
if self._quality_changes_to_apply: if self._quality_changes_to_apply !=None:
quality_changes_group_list = container_tree.getCurrentQualityChangesGroups() quality_changes_group_list = container_tree.getCurrentQualityChangesGroups()
quality_changes_group = next((qcg for qcg in quality_changes_group_list if qcg.name == self._quality_changes_to_apply), None) quality_changes_group = next((qcg for qcg in quality_changes_group_list if qcg.name == self._quality_changes_to_apply), None)
if not quality_changes_group: if not quality_changes_group:

View file

@ -22,6 +22,8 @@ import time
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
from .SpecificSettingsModel import SpecificSettingsModel
i18n_catalog = i18nCatalog("cura") i18n_catalog = i18nCatalog("cura")
@ -61,16 +63,23 @@ class WorkspaceDialog(QObject):
self._machine_name = "" self._machine_name = ""
self._machine_type = "" self._machine_type = ""
self._variant_type = "" self._variant_type = ""
self._current_machine_name = ""
self._material_labels = [] self._material_labels = []
self._extruders = [] self._extruders = []
self._objects_on_plate = False self._objects_on_plate = False
self._is_printer_group = False self._is_printer_group = False
self._updatable_machines_model = MachineListModel(self, listenToChanges=False) self._updatable_machines_model = MachineListModel(self, listenToChanges = False, showCloudPrinters = True)
self._missing_package_metadata: List[Dict[str, str]] = [] self._missing_package_metadata: List[Dict[str, str]] = []
self._plugin_registry: PluginRegistry = CuraApplication.getInstance().getPluginRegistry() self._plugin_registry: PluginRegistry = CuraApplication.getInstance().getPluginRegistry()
self._install_missing_package_dialog: Optional[QObject] = None self._install_missing_package_dialog: Optional[QObject] = None
self._is_abstract_machine = False self._is_abstract_machine = False
self._is_networked_machine = False self._is_networked_machine = False
self._is_compatible_machine = False
self._allow_create_machine = True
self._exported_settings_model = SpecificSettingsModel()
self._exported_settings_model.modelChanged.connect(self.exportedSettingModelChanged.emit)
self._current_machine_pos_index = 0
self._is_ucp = False
machineConflictChanged = pyqtSignal() machineConflictChanged = pyqtSignal()
qualityChangesConflictChanged = pyqtSignal() qualityChangesConflictChanged = pyqtSignal()
@ -94,6 +103,9 @@ class WorkspaceDialog(QObject):
extrudersChanged = pyqtSignal() extrudersChanged = pyqtSignal()
isPrinterGroupChanged = pyqtSignal() isPrinterGroupChanged = pyqtSignal()
missingPackagesChanged = pyqtSignal() missingPackagesChanged = pyqtSignal()
isCompatibleMachineChanged = pyqtSignal()
isUcpChanged = pyqtSignal()
exportedSettingModelChanged = pyqtSignal()
@pyqtProperty(bool, notify = isPrinterGroupChanged) @pyqtProperty(bool, notify = isPrinterGroupChanged)
def isPrinterGroup(self) -> bool: def isPrinterGroup(self) -> bool:
@ -166,8 +178,30 @@ class WorkspaceDialog(QObject):
self._machine_name = machine_name self._machine_name = machine_name
self.machineNameChanged.emit() self.machineNameChanged.emit()
def setCurrentMachineName(self, machine: str) -> None:
self._current_machine_name = machine
@pyqtProperty(str, notify = machineNameChanged)
def currentMachineName(self) -> str:
return self._current_machine_name
@staticmethod
def getIndexOfCurrentMachine(list_of_dicts, key, value, defaultIndex):
for i, d in enumerate(list_of_dicts):
if d.get(key) == value: # found the dictionary
return i
return defaultIndex
@pyqtProperty(int, notify = machineNameChanged)
def currentMachinePositionIndex(self):
return self._current_machine_pos_index
@pyqtProperty(QObject, notify = updatableMachinesChanged) @pyqtProperty(QObject, notify = updatableMachinesChanged)
def updatableMachinesModel(self) -> MachineListModel: def updatableMachinesModel(self) -> MachineListModel:
if self._current_machine_name != "":
self._current_machine_pos_index = self.getIndexOfCurrentMachine(self._updatable_machines_model.getItems(), "id", self._current_machine_name, defaultIndex = 0)
else:
self._current_machine_pos_index = 0
return cast(MachineListModel, self._updatable_machines_model) return cast(MachineListModel, self._updatable_machines_model)
def setUpdatableMachines(self, updatable_machines: List[GlobalStack]) -> None: def setUpdatableMachines(self, updatable_machines: List[GlobalStack]) -> None:
@ -292,7 +326,49 @@ class WorkspaceDialog(QObject):
@pyqtSlot(str) @pyqtSlot(str)
def setMachineToOverride(self, machine_name: str) -> None: def setMachineToOverride(self, machine_name: str) -> None:
self._override_machine = machine_name self._override_machine = machine_name
self.updateCompatibleMachine()
def updateCompatibleMachine(self):
registry = ContainerRegistry.getInstance()
containers_expected = registry.findDefinitionContainers(name=self._machine_type)
containers_selected = registry.findContainerStacks(id=self._override_machine)
if len(containers_expected) == 1 and len(containers_selected) == 1:
new_compatible_machine = (containers_expected[0] == containers_selected[0].definition)
if new_compatible_machine != self._is_compatible_machine:
self._is_compatible_machine = new_compatible_machine
self.isCompatibleMachineChanged.emit()
@pyqtProperty(bool, notify = isCompatibleMachineChanged)
def isCompatibleMachine(self) -> bool:
return self._is_compatible_machine
def setIsUcp(self, isUcp: bool) -> None:
if isUcp != self._is_ucp:
self._is_ucp = isUcp
self.isUcpChanged.emit()
@pyqtProperty(bool, notify=isUcpChanged)
def isUcp(self):
return self._is_ucp
def setAllowCreatemachine(self, allow_create_machine):
self._allow_create_machine = allow_create_machine
@pyqtProperty(bool, constant = True)
def allowCreateMachine(self):
return self._allow_create_machine
@pyqtProperty(QObject, notify=exportedSettingModelChanged)
def exportedSettingModel(self):
return self._exported_settings_model
@pyqtProperty("QVariantList", notify=exportedSettingModelChanged)
def exportedSettingModelItems(self):
return self._exported_settings_model.items
@pyqtProperty(int, notify=exportedSettingModelChanged)
def exportedSettingModelRowCount(self):
return self._exported_settings_model.rowCount()
@pyqtSlot() @pyqtSlot()
def closeBackend(self) -> None: def closeBackend(self) -> None:
"""Close the backend: otherwise one could end up with "Slicing...""" """Close the backend: otherwise one could end up with "Slicing..."""

View file

@ -6,13 +6,13 @@ import QtQuick.Controls 2.3
import QtQuick.Layouts 1.3 import QtQuick.Layouts 1.3
import QtQuick.Window 2.2 import QtQuick.Window 2.2
import UM 1.5 as UM import UM 1.6 as UM
import Cura 1.1 as Cura import Cura 1.1 as Cura
UM.Dialog UM.Dialog
{ {
id: workspaceDialog id: workspaceDialog
title: catalog.i18nc("@title:window", "Open Project") title: manager.isUcp? catalog.i18nc("@title:window Don't translate 'Universal Cura Project'", "Open Universal Cura Project (UCP)"): catalog.i18nc("@title:window", "Open Project")
margin: UM.Theme.getSize("default_margin").width margin: UM.Theme.getSize("default_margin").width
minimumWidth: UM.Theme.getSize("modal_window_minimum").width minimumWidth: UM.Theme.getSize("modal_window_minimum").width
@ -24,16 +24,34 @@ UM.Dialog
{ {
height: childrenRect.height + 2 * UM.Theme.getSize("default_margin").height height: childrenRect.height + 2 * UM.Theme.getSize("default_margin").height
color: UM.Theme.getColor("main_background") color: UM.Theme.getColor("main_background")
ColumnLayout
{
id: headerColumn
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: UM.Theme.getSize("default_margin").height
anchors.leftMargin: UM.Theme.getSize("default_margin").width
anchors.rightMargin: anchors.leftMargin
RowLayout
{
UM.Label UM.Label
{ {
id: titleLabel id: titleLabel
text: catalog.i18nc("@action:title", "Summary - Cura Project") text: manager.isUcp? catalog.i18nc("@action:title Don't translate 'Universal Cura Project'", "Summary - Open Universal Cura Project (UCP)"): catalog.i18nc("@action:title", "Summary - Cura Project")
font: UM.Theme.getFont("large") font: UM.Theme.getFont("large")
anchors.top: parent.top }
anchors.left: parent.left Cura.TertiaryButton
anchors.topMargin: UM.Theme.getSize("default_margin").height {
anchors.leftMargin: UM.Theme.getSize("default_margin").height id: learnMoreButton
visible: manager.isUcp
text: catalog.i18nc("@button", "Learn more")
iconSource: UM.Theme.getIcon("LinkExternal")
isIconOnRightSide: true
onClicked: Qt.openUrlExternally("https://support.ultimaker.com/s/article/000002979")
}
}
} }
} }
@ -96,7 +114,7 @@ UM.Dialog
WorkspaceRow WorkspaceRow
{ {
leftLabelText: catalog.i18nc("@action:label", manager.isPrinterGroup ? "Printer Group" : "Printer Name") leftLabelText: catalog.i18nc("@action:label", manager.isPrinterGroup ? "Printer Group" : "Printer Name")
rightLabelText: manager.machineName == catalog.i18nc("@button", "Create new") ? "" : manager.machineName rightLabelText: manager.isUcp? manager.machineType: manager.machineName == catalog.i18nc("@button", "Create new") ? "" : manager.machineName
} }
} }
@ -120,13 +138,17 @@ UM.Dialog
minDropDownWidth: machineSelector.width minDropDownWidth: machineSelector.width
buttons: [ Component
{
id: componentNewPrinter
Cura.SecondaryButton Cura.SecondaryButton
{ {
id: createNewPrinter id: createNewPrinter
text: catalog.i18nc("@button", "Create new") text: catalog.i18nc("@button", "Create new")
fixedWidthMode: true fixedWidthMode: true
width: parent.width - leftPadding * 1.5 width: parent.width - leftPadding * 1.5
visible: manager.allowCreateMachine
onClicked: onClicked:
{ {
toggleContent() toggleContent()
@ -136,7 +158,9 @@ UM.Dialog
manager.setIsNetworkedMachine(false) manager.setIsNetworkedMachine(false)
} }
} }
] }
buttons: manager.allowCreateMachine ? [componentNewPrinter.createObject()] : []
onSelectPrinter: function(machine) onSelectPrinter: function(machine)
{ {
@ -152,39 +176,56 @@ UM.Dialog
WorkspaceSection WorkspaceSection
{ {
id: profileSection id: ucpProfileSection
title: catalog.i18nc("@action:label", "Profile settings") visible: manager.isUcp
iconSource: UM.Theme.getIcon("Sliders") title: catalog.i18nc("@action:label", "Settings Loaded from UCP file")
iconSource: UM.Theme.getIcon("Settings")
content: Column content: Column
{ {
id: profileSettingsValuesTable id: ucpProfileSettingsValuesTable
spacing: UM.Theme.getSize("default_margin").height spacing: UM.Theme.getSize("default_margin").height
leftPadding: UM.Theme.getSize("medium_button_icon").width + UM.Theme.getSize("default_margin").width leftPadding: UM.Theme.getSize("medium_button_icon").width + UM.Theme.getSize("default_margin").width
WorkspaceRow WorkspaceRow
{ {
leftLabelText: catalog.i18nc("@action:label", "Name") id: numberOfOverrides
rightLabelText: manager.qualityName leftLabelText: catalog.i18nc("@action:label", "Settings Loaded from UCP file")
rightLabelText: catalog.i18ncp("@action:label", "%1 override", "%1 overrides", manager.exportedSettingModelRowCount).arg(manager.exportedSettingModelRowCount)
buttonText: tableViewSpecificSettings.shouldBeVisible ? catalog.i18nc("@action:button", "Hide settings") : catalog.i18nc("@action:button", "Show settings")
onButtonClicked: tableViewSpecificSettings.shouldBeVisible = !tableViewSpecificSettings.shouldBeVisible
}
Cura.TableView
{
id: tableViewSpecificSettings
width: parent.width - parent.leftPadding - UM.Theme.getSize("default_margin").width
height: UM.Theme.getSize("card").height
visible: shouldBeVisible && manager.isUcp
property bool shouldBeVisible: true
columnHeaders:
[
catalog.i18nc("@title:column", "Applies on"),
catalog.i18nc("@title:column", "Setting"),
catalog.i18nc("@title:column", "Value")
]
model: UM.TableModel
{
id: tableModel
headers: ["category", "label", "value"]
rows: manager.exportedSettingModelItems
} }
WorkspaceRow Connections
{ {
leftLabelText: catalog.i18nc("@action:label", "Intent") target: manager
rightLabelText: manager.intentName function onExportedSettingModelChanged()
{
tableModel.clear()
tableModel.rows = manager.exportedSettingModelItems
} }
WorkspaceRow
{
leftLabelText: catalog.i18nc("@action:label", "Not in profile")
rightLabelText: catalog.i18ncp("@action:label", "%1 override", "%1 overrides", manager.numUserSettings).arg(manager.numUserSettings)
visible: manager.numUserSettings != 0
} }
WorkspaceRow
{
leftLabelText: catalog.i18nc("@action:label", "Derivative from")
rightLabelText: catalog.i18ncp("@action:label", "%1, %2 override", "%1, %2 overrides", manager.numSettingsOverridenByQualityChanges).arg(manager.qualityType).arg(manager.numSettingsOverridenByQualityChanges)
visible: manager.numSettingsOverridenByQualityChanges != 0
} }
} }
@ -194,7 +235,7 @@ UM.Dialog
id: qualityChangesResolveComboBox id: qualityChangesResolveComboBox
model: resolveStrategiesModel model: resolveStrategiesModel
textRole: "label" textRole: "label"
visible: manager.qualityChangesConflict visible: manager.qualityChangesConflict && !manager.isUcp
contentLeftPadding: UM.Theme.getSize("default_margin").width + UM.Theme.getSize("narrow_margin").width contentLeftPadding: UM.Theme.getSize("default_margin").width + UM.Theme.getSize("narrow_margin").width
textFont: UM.Theme.getFont("medium") textFont: UM.Theme.getFont("medium")
@ -220,10 +261,51 @@ UM.Dialog
} }
} }
WorkspaceSection
{
id: profileSection
title: manager.isUcp? catalog.i18nc("@action:label", "Suggested Profile settings"):catalog.i18nc("@action:label", "Profile settings")
iconSource: UM.Theme.getIcon("Sliders")
content: Column
{
id: profileSettingsValuesTable
spacing: UM.Theme.getSize("default_margin").height
leftPadding: UM.Theme.getSize("medium_button_icon").width + UM.Theme.getSize("default_margin").width
WorkspaceRow
{
leftLabelText: catalog.i18nc("@action:label", "Name")
rightLabelText: manager.qualityName
visible: manager.isCompatibleMachine
}
WorkspaceRow
{
leftLabelText: catalog.i18nc("@action:label", "Intent")
rightLabelText: manager.intentName
visible: manager.isCompatibleMachine
}
WorkspaceRow
{
leftLabelText: catalog.i18nc("@action:label", "Not in profile")
rightLabelText: catalog.i18ncp("@action:label", "%1 override", "%1 overrides", manager.numUserSettings).arg(manager.numUserSettings)
visible: manager.numUserSettings != 0 && !manager.isUcp
}
WorkspaceRow
{
leftLabelText: catalog.i18nc("@action:label", "Derivative from")
rightLabelText: catalog.i18ncp("@action:label", "%1, %2 override", "%1, %2 overrides", manager.numSettingsOverridenByQualityChanges).arg(manager.qualityType).arg(manager.numSettingsOverridenByQualityChanges)
visible: manager.numSettingsOverridenByQualityChanges != 0 && manager.isCompatibleMachine
}
}
}
WorkspaceSection WorkspaceSection
{ {
id: materialSection id: materialSection
title: catalog.i18nc("@action:label", "Material settings") title: manager.isUcp? catalog.i18nc("@action:label", "Suggested Material settings"): catalog.i18nc("@action:label", "Material settings")
iconSource: UM.Theme.getIcon("Spool") iconSource: UM.Theme.getIcon("Spool")
content: Column content: Column
{ {
@ -248,7 +330,7 @@ UM.Dialog
id: materialResolveComboBox id: materialResolveComboBox
model: resolveStrategiesModel model: resolveStrategiesModel
textRole: "label" textRole: "label"
visible: manager.materialConflict visible: manager.materialConflict && !manager.isUcp
contentLeftPadding: UM.Theme.getSize("default_margin").width + UM.Theme.getSize("narrow_margin").width contentLeftPadding: UM.Theme.getSize("default_margin").width + UM.Theme.getSize("narrow_margin").width
textFont: UM.Theme.getFont("medium") textFont: UM.Theme.getFont("medium")
@ -279,6 +361,7 @@ UM.Dialog
id: visibilitySection id: visibilitySection
title: catalog.i18nc("@action:label", "Setting visibility") title: catalog.i18nc("@action:label", "Setting visibility")
iconSource: UM.Theme.getIcon("Eye") iconSource: UM.Theme.getIcon("Eye")
visible : !manager.isUcp
content: Column content: Column
{ {
spacing: UM.Theme.getSize("default_margin").height spacing: UM.Theme.getSize("default_margin").height
@ -416,12 +499,13 @@ UM.Dialog
{ {
if (visible) if (visible)
{ {
// Force relead the comboboxes // Force reload the comboboxes
// Since this dialog is only created once the first time you open it, these comboxes need to be reloaded // Since this dialog is only created once the first time you open it, these comboxes need to be reloaded
// each time it is shown after the first time so that the indexes will update correctly. // each time it is shown after the first time so that the indexes will update correctly.
materialSection.reloadValues() materialSection.reloadValues()
profileSection.reloadValues() profileSection.reloadValues()
printerSection.reloadValues() printerSection.reloadValues()
ucpProfileSection.reloadValues()
} }
} }
} }

View file

@ -9,26 +9,38 @@ import QtQuick.Window 2.2
import UM 1.5 as UM import UM 1.5 as UM
import Cura 1.1 as Cura import Cura 1.1 as Cura
Row RowLayout
{ {
id: root
property alias leftLabelText: leftLabel.text property alias leftLabelText: leftLabel.text
property alias rightLabelText: rightLabel.text property alias rightLabelText: rightLabel.text
property alias buttonText: button.text
signal buttonClicked
width: parent.width width: parent.width
height: visible ? childrenRect.height : 0
UM.Label UM.Label
{ {
id: leftLabel id: leftLabel
text: catalog.i18nc("@action:label", "Type") text: catalog.i18nc("@action:label", "Type")
width: Math.round(parent.width / 4) Layout.preferredWidth: Math.round(parent.width / 4)
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
} }
UM.Label UM.Label
{ {
id: rightLabel id: rightLabel
text: manager.machineType text: manager.machineType
width: Math.round(parent.width / 3)
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
} }
Cura.TertiaryButton
{
id: button
visible: !text.isEmpty
Layout.maximumHeight: leftLabel.implicitHeight
Layout.fillWidth: true
onClicked: root.buttonClicked()
}
} }

View file

@ -5,7 +5,7 @@ import QtQuick 2.10
import QtQuick.Controls 2.3 import QtQuick.Controls 2.3
import UM 1.5 as UM import UM 1.8 as UM
Item Item
@ -80,42 +80,22 @@ Item
sourceComponent: combobox sourceComponent: combobox
} }
MouseArea UM.HelpIcon
{ {
id: helpIconMouseArea
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: comboboxLabel.verticalCenter anchors.verticalCenter: comboboxLabel.verticalCenter
width: childrenRect.width color: UM.Theme.getColor("small_button_text")
height: childrenRect.height icon: UM.Theme.getIcon("Information")
hoverEnabled: true
UM.ColorImage
{
width: UM.Theme.getSize("section_icon").width
height: width
visible: comboboxTooltipText != ""
source: UM.Theme.getIcon("Help")
color: UM.Theme.getColor("text")
UM.ToolTip
{
text: comboboxTooltipText text: comboboxTooltipText
visible: helpIconMouseArea.containsMouse visible: comboboxTooltipText != ""
targetPoint: Qt.point(parent.x + Math.round(parent.width / 2), parent.y)
x: 0
y: parent.y + parent.height + UM.Theme.getSize("default_margin").height
width: UM.Theme.getSize("tooltip").width
} }
} }
}
}
Loader Loader
{ {
width: parent.width width: parent.width
height: content.height height: content.height
z: -1
anchors.top: sectionTitleRow.bottom anchors.top: sectionTitleRow.bottom
sourceComponent: content sourceComponent: content
} }

View file

@ -0,0 +1,48 @@
# Copyright (c) 2024 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt6.QtCore import QObject, pyqtProperty, pyqtSignal
class SettingExport(QObject):
def __init__(self, id, name, value, value_name, selectable, show):
super().__init__()
self.id = id
self._name = name
self._value = value
self._value_name = value_name
self._selected = selectable
self._selectable = selectable
self._show_in_menu = show
@pyqtProperty(str, constant=True)
def name(self):
return self._name
@pyqtProperty(str, constant=True)
def value(self):
return self._value
@pyqtProperty(str, constant=True)
def valuename(self):
return str(self._value_name)
selectedChanged = pyqtSignal(bool)
def setSelected(self, selected):
if selected != self._selected:
self._selected = selected
self.selectedChanged.emit(self._selected)
@pyqtProperty(bool, fset = setSelected, notify = selectedChanged)
def selected(self):
return self._selected
@pyqtProperty(bool, constant=True)
def selectable(self):
return self._selectable
@pyqtProperty(bool, constant=True)
def isVisible(self):
return self._show_in_menu

View file

@ -0,0 +1,39 @@
// Copyright (c) 2024 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.10
import QtQuick.Controls 2.3
import QtQuick.Layouts 1.3
import QtQuick.Window 2.2
import UM 1.8 as UM
import Cura 1.1 as Cura
RowLayout
{
id: settingSelection
UM.CheckBox
{
text: modelData.name
Layout.preferredWidth: UM.Theme.getSize("setting").width
checked: modelData.selected
onClicked: modelData.selected = checked
tooltip: modelData.selectable ? "" :catalog.i18nc("@tooltip Don't translate 'Universal Cura Project'", "This setting may not perform well while exporting to Universal Cura Project. Users are asked to add it at their own risk.")
}
UM.Label
{
text: modelData.valuename
}
UM.HelpIcon
{
UM.I18nCatalog { id: catalog; name: "cura" }
text: catalog.i18nc("@tooltip Don't translate 'Universal Cura Project'",
"This setting may not perform well while exporting to Universal Cura Project, Users are asked to add it at their own risk.")
visible: !modelData.selectable
}
}

View file

@ -0,0 +1,56 @@
# Copyright (c) 2024 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from enum import IntEnum
from PyQt6.QtCore import QObject, pyqtProperty, pyqtEnum
class SettingsExportGroup(QObject):
@pyqtEnum
class Category(IntEnum):
Global = 0
Extruder = 1
Model = 2
def __init__(self, stack, name, category, settings, category_details = '', extruder_index = 0, extruder_color = ''):
super().__init__()
self.stack = stack
self._name = name
self._settings = settings
self._category = category
self._category_details = category_details
self._extruder_index = extruder_index
self._extruder_color = extruder_color
self._visible_settings = []
@pyqtProperty(str, constant=True)
def name(self):
return self._name
@pyqtProperty(list, constant=True)
def settings(self):
return self._settings
@pyqtProperty(list, constant=True)
def visibleSettings(self):
if self._visible_settings == []:
self._visible_settings = list(filter(lambda item : item.isVisible, self._settings))
return self._visible_settings
@pyqtProperty(int, constant=True)
def category(self):
return self._category
@pyqtProperty(str, constant=True)
def category_details(self):
return self._category_details
@pyqtProperty(int, constant=True)
def extruder_index(self):
return self._extruder_index
@pyqtProperty(str, constant=True)
def extruder_color(self):
return self._extruder_color

View file

@ -0,0 +1,150 @@
# Copyright (c) 2024 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from dataclasses import asdict
from typing import Optional, cast, List, Dict, Pattern, Set
from PyQt6.QtCore import QObject, pyqtProperty
from UM import i18nCatalog
from UM.Settings.SettingDefinition import SettingDefinition
from UM.Settings.InstanceContainer import InstanceContainer
from UM.Settings.SettingFunction import SettingFunction
from cura.CuraApplication import CuraApplication
from cura.Settings.ExtruderManager import ExtruderManager
from cura.Settings.GlobalStack import GlobalStack
from .SettingsExportGroup import SettingsExportGroup
from .SettingExport import SettingExport
class SettingsExportModel(QObject):
EXPORTABLE_SETTINGS = {'infill_sparse_density',
'adhesion_type',
'support_enable',
'infill_pattern',
'support_type',
'support_structure',
'support_angle',
'support_infill_rate',
'ironing_enabled',
'fill_outline_gaps',
'coasting_enable',
'skin_monotonic',
'z_seam_position',
'infill_before_walls',
'ironing_only_highest_layer',
'xy_offset',
'adaptive_layer_height_enabled',
'brim_gap',
'support_offset',
'brim_location',
'magic_spiralize',
'slicing_tolerance',
'outer_inset_first',
'magic_fuzzy_skin_outside_only',
'conical_overhang_enabled',
'min_infill_area',
'small_hole_max_size',
'magic_mesh_surface_mode',
'carve_multiple_volumes',
'meshfix_union_all_remove_holes',
'support_tree_rest_preference',
'small_feature_max_length',
'draft_shield_enabled',
'brim_smart_ordering',
'ooze_shield_enabled',
'bottom_skin_preshrink',
'skin_edge_support_thickness',
'alternate_carve_order',
'top_skin_preshrink',
'interlocking_enable'}
PER_MODEL_EXPORTABLE_SETTINGS_KEYS = {"anti_overhang_mesh",
"infill_mesh",
"cutting_mesh",
"support_mesh"}
def __init__(self, parent=None):
super().__init__(parent)
self._settings_groups = []
application = CuraApplication.getInstance()
self._appendGlobalSettings(application)
self._appendExtruderSettings(application)
self._appendModelSettings(application)
def _appendGlobalSettings(self, application):
global_stack = application.getGlobalContainerStack()
self._settings_groups.append(SettingsExportGroup(
global_stack, "Global settings", SettingsExportGroup.Category.Global, self._exportSettings(global_stack)))
def _appendExtruderSettings(self, application):
extruders_stacks = ExtruderManager.getInstance().getUsedExtruderStacks()
for extruder_stack in extruders_stacks:
color = extruder_stack.material.getMetaDataEntry("color_code") if extruder_stack.material else ""
self._settings_groups.append(SettingsExportGroup(
extruder_stack, "Extruder settings", SettingsExportGroup.Category.Extruder,
self._exportSettings(extruder_stack), extruder_index=extruder_stack.position, extruder_color=color))
def _appendModelSettings(self, application):
scene = application.getController().getScene()
for scene_node in scene.getRoot().getChildren():
self._appendNodeSettings(scene_node, "Model settings", SettingsExportGroup.Category.Model)
def _appendNodeSettings(self, node, title_prefix, category):
stack = node.callDecoration("getStack")
if stack:
self._settings_groups.append(SettingsExportGroup(
stack, f"{title_prefix}", category, self._exportSettings(stack), node.getName()))
for child in node.getChildren():
self._appendNodeSettings(child, f"Children of {node.getName()}", SettingsExportGroup.Category.Model)
@pyqtProperty(list, constant=True)
def settingsGroups(self) -> List[SettingsExportGroup]:
return self._settings_groups
@staticmethod
def _exportSettings(settings_stack):
settings_catalog = i18nCatalog("fdmprinter.def.json")
user_settings_container = settings_stack.userChanges
user_keys = user_settings_container.getAllKeys()
exportable_settings = SettingsExportModel.EXPORTABLE_SETTINGS
settings_export = []
# Check whether any of the user keys exist in PER_MODEL_EXPORTABLE_SETTINGS_KEYS
is_exportable = any(key in SettingsExportModel.PER_MODEL_EXPORTABLE_SETTINGS_KEYS for key in user_keys)
for setting_to_export in user_keys:
show_in_menu = setting_to_export not in SettingsExportModel.PER_MODEL_EXPORTABLE_SETTINGS_KEYS
label_msgtxt = f"{str(setting_to_export)} label"
label_msgid = settings_stack.getProperty(setting_to_export, "label")
label = settings_catalog.i18nc(label_msgtxt, label_msgid)
value = settings_stack.getProperty(setting_to_export, "value")
unit = settings_stack.getProperty(setting_to_export, "unit")
setting_type = settings_stack.getProperty(setting_to_export, "type")
value_name = str(SettingDefinition.settingValueToString(setting_type, value))
if unit:
value_name += " " + str(unit)
if setting_type == "enum":
options = settings_stack.getProperty(setting_to_export, "options")
value_msgctxt = f"{str(setting_to_export)} option {str(value)}"
value_msgid = options.get(value, "")
value_name = settings_catalog.i18nc(value_msgctxt, value_msgid)
if setting_type is not None:
value = f"{str(SettingDefinition.settingValueToString(setting_type, value))} {unit}"
else:
value = str(value)
settings_export.append(SettingExport(setting_to_export,
label,
value,
value_name,
is_exportable or setting_to_export in exportable_settings,
show_in_menu))
return settings_export

View file

@ -0,0 +1,86 @@
// Copyright (c) 2024 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.10
import QtQuick.Controls 2.3
import QtQuick.Layouts 1.3
import QtQuick.Window 2.2
import UM 1.5 as UM
import Cura 1.1 as Cura
import ThreeMFWriter 1.0 as ThreeMFWriter
ColumnLayout
{
id: settingsGroup
spacing: UM.Theme.getSize("narrow_margin").width
RowLayout
{
id: settingsGroupTitleRow
spacing: UM.Theme.getSize("default_margin").width
Item
{
id: icon
height: UM.Theme.getSize("medium_button_icon").height
width: height
UM.ColorImage
{
id: settingsMainImage
anchors.fill: parent
source:
{
switch(modelData.category)
{
case ThreeMFWriter.SettingsExportGroup.Global:
return UM.Theme.getIcon("Sliders")
case ThreeMFWriter.SettingsExportGroup.Model:
return UM.Theme.getIcon("View3D")
default:
return ""
}
}
color: UM.Theme.getColor("text")
}
Cura.ExtruderIcon
{
id: settingsExtruderIcon
anchors.fill: parent
visible: modelData.category === ThreeMFWriter.SettingsExportGroup.Extruder
text: (modelData.extruder_index + 1).toString()
font: UM.Theme.getFont("tiny_emphasis")
materialColor: modelData.extruder_color
}
}
UM.Label
{
id: settingsTitle
text: modelData.name + (modelData.category_details ? ' (%1)'.arg(modelData.category_details) : '')
font: UM.Theme.getFont("default_bold")
}
}
ListView
{
id: settingsExportList
Layout.fillWidth: true
Layout.preferredHeight: contentHeight
spacing: 0
model: modelData.visibleSettings
visible: modelData.visibleSettings.length > 0
delegate: SettingSelection { }
}
UM.Label
{
UM.I18nCatalog { id: catalog; name: "cura" }
text: catalog.i18nc("@label", "No specific value has been set")
visible: modelData.visibleSettings.length === 0
}
}

View file

@ -1,9 +1,13 @@
# Copyright (c) 2020 Ultimaker B.V. # Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional
import configparser import configparser
from io import StringIO from io import StringIO
from threading import Lock
import zipfile import zipfile
from typing import Dict, Any
from UM.Application import Application from UM.Application import Application
from UM.Logger import Logger from UM.Logger import Logger
@ -13,15 +17,23 @@ from UM.Workspace.WorkspaceWriter import WorkspaceWriter
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
from cura.Utils.Threading import call_on_qt_thread from .ThreeMFWriter import ThreeMFWriter
from .SettingsExportModel import SettingsExportModel
from .SettingsExportGroup import SettingsExportGroup
USER_SETTINGS_PATH = "Cura/user-settings.json"
class ThreeMFWorkspaceWriter(WorkspaceWriter): class ThreeMFWorkspaceWriter(WorkspaceWriter):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._ucp_model: Optional[SettingsExportModel] = None
@call_on_qt_thread def setExportModel(self, model: SettingsExportModel) -> None:
def write(self, stream, nodes, mode=WorkspaceWriter.OutputMode.BinaryMode): if self._ucp_model != model:
self._ucp_model = model
def _write(self, stream, nodes, mode=WorkspaceWriter.OutputMode.BinaryMode):
application = Application.getInstance() application = Application.getInstance()
machine_manager = application.getMachineManager() machine_manager = application.getMachineManager()
@ -34,20 +46,20 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter):
global_stack = machine_manager.activeMachine global_stack = machine_manager.activeMachine
if global_stack is None: if global_stack is None:
self.setInformation(catalog.i18nc("@error", "There is no workspace yet to write. Please add a printer first.")) self.setInformation(
catalog.i18nc("@error", "There is no workspace yet to write. Please add a printer first."))
Logger.error("Tried to write a 3MF workspace before there was a global stack.") Logger.error("Tried to write a 3MF workspace before there was a global stack.")
return False return False
# Indicate that the 3mf mesh writer should not close the archive just yet (we still need to add stuff to it). # Indicate that the 3mf mesh writer should not close the archive just yet (we still need to add stuff to it).
mesh_writer.setStoreArchive(True) mesh_writer.setStoreArchive(True)
if not mesh_writer.write(stream, nodes, mode): if not mesh_writer.write(stream, nodes, mode, self._ucp_model):
self.setInformation(mesh_writer.getInformation()) self.setInformation(mesh_writer.getInformation())
return False return False
archive = mesh_writer.getArchive() archive = mesh_writer.getArchive()
if archive is None: # This happens if there was no mesh data to write. if archive is None: # This happens if there was no mesh data to write.
archive = zipfile.ZipFile(stream, "w", compression = zipfile.ZIP_DEFLATED) archive = zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED)
try: try:
# Add global container stack data to the archive. # Add global container stack data to the archive.
@ -62,15 +74,21 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter):
self._writeContainerToArchive(extruder_stack, archive) self._writeContainerToArchive(extruder_stack, archive)
for container in extruder_stack.getContainers(): for container in extruder_stack.getContainers():
self._writeContainerToArchive(container, archive) self._writeContainerToArchive(container, archive)
# Write user settings data
if self._ucp_model is not None:
user_settings_data = self._getUserSettings(self._ucp_model)
ThreeMFWriter._storeMetadataJson(user_settings_data, archive, USER_SETTINGS_PATH)
except PermissionError: except PermissionError:
self.setInformation(catalog.i18nc("@error:zip", "No permission to write the workspace here.")) self.setInformation(catalog.i18nc("@error:zip", "No permission to write the workspace here."))
Logger.error("No permission to write workspace to this stream.") Logger.error("No permission to write workspace to this stream.")
return False return False
# Write preferences to archive # Write preferences to archive
original_preferences = Application.getInstance().getPreferences() #Copy only the preferences that we use to the workspace. original_preferences = Application.getInstance().getPreferences() # Copy only the preferences that we use to the workspace.
temp_preferences = Preferences() temp_preferences = Preferences()
for preference in {"general/visible_settings", "cura/active_mode", "cura/categories_expanded", "metadata/setting_version"}: for preference in {"general/visible_settings", "cura/active_mode", "cura/categories_expanded",
"metadata/setting_version"}:
temp_preferences.addPreference(preference, None) temp_preferences.addPreference(preference, None)
temp_preferences.setValue(preference, original_preferences.getValue(preference)) temp_preferences.setValue(preference, original_preferences.getValue(preference))
preferences_string = StringIO() preferences_string = StringIO()
@ -81,7 +99,7 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter):
# Save Cura version # Save Cura version
version_file = zipfile.ZipInfo("Cura/version.ini") version_file = zipfile.ZipInfo("Cura/version.ini")
version_config_parser = configparser.ConfigParser(interpolation = None) version_config_parser = configparser.ConfigParser(interpolation=None)
version_config_parser.add_section("versions") version_config_parser.add_section("versions")
version_config_parser.set("versions", "cura_version", application.getVersion()) version_config_parser.set("versions", "cura_version", application.getVersion())
version_config_parser.set("versions", "build_type", application.getBuildType()) version_config_parser.set("versions", "build_type", application.getBuildType())
@ -101,11 +119,17 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter):
return False return False
except EnvironmentError as e: except EnvironmentError as e:
self.setInformation(catalog.i18nc("@error:zip", str(e))) self.setInformation(catalog.i18nc("@error:zip", str(e)))
Logger.error("EnvironmentError when writing workspace to this stream: {err}".format(err = str(e))) Logger.error("EnvironmentError when writing workspace to this stream: {err}".format(err=str(e)))
return False return False
mesh_writer.setStoreArchive(False) mesh_writer.setStoreArchive(False)
return True return True
def write(self, stream, nodes, mode=WorkspaceWriter.OutputMode.BinaryMode):
success = self._write(stream, nodes, mode=WorkspaceWriter.OutputMode.BinaryMode)
self._ucp_model = None
return success
@staticmethod @staticmethod
def _writePluginMetadataToArchive(archive: zipfile.ZipFile) -> None: def _writePluginMetadataToArchive(archive: zipfile.ZipFile) -> None:
file_name_template = "%s/plugin_metadata.json" file_name_template = "%s/plugin_metadata.json"
@ -166,3 +190,26 @@ class ThreeMFWorkspaceWriter(WorkspaceWriter):
except (FileNotFoundError, EnvironmentError): except (FileNotFoundError, EnvironmentError):
Logger.error("File became inaccessible while writing to it: {archive_filename}".format(archive_filename = archive.fp.name)) Logger.error("File became inaccessible while writing to it: {archive_filename}".format(archive_filename = archive.fp.name))
return return
@staticmethod
def _getUserSettings(model: SettingsExportModel) -> Dict[str, Dict[str, Any]]:
user_settings = {}
for group in model.settingsGroups:
category = ''
if group.category == SettingsExportGroup.Category.Global:
category = 'global'
elif group.category == SettingsExportGroup.Category.Extruder:
category = f"extruder_{group.extruder_index}"
if len(category) > 0:
settings_values = {}
stack = group.stack
for setting in group.settings:
if setting.selected:
settings_values[setting.id] = stack.getProperty(setting.id, "value")
user_settings[category] = settings_values
return user_settings

View file

@ -2,6 +2,7 @@
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import json import json
import re import re
import threading
from typing import Optional, cast, List, Dict, Pattern, Set from typing import Optional, cast, List, Dict, Pattern, Set
@ -10,22 +11,24 @@ from UM.Math.Vector import Vector
from UM.Logger import Logger from UM.Logger import Logger
from UM.Math.Matrix import Matrix from UM.Math.Matrix import Matrix
from UM.Application import Application from UM.Application import Application
from UM.OutputDevice import OutputDeviceError
from UM.Message import Message from UM.Message import Message
from UM.Resources import Resources from UM.Resources import Resources
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from UM.Settings.ContainerRegistry import ContainerRegistry from UM.Settings.ContainerRegistry import ContainerRegistry
from UM.Settings.EmptyInstanceContainer import EmptyInstanceContainer
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
from cura.CuraPackageManager import CuraPackageManager from cura.CuraPackageManager import CuraPackageManager
from cura.Settings import CuraContainerStack from cura.Settings import CuraContainerStack
from cura.Utils.Threading import call_on_qt_thread from cura.Utils.Threading import call_on_qt_thread
from cura.Scene.CuraSceneNode import CuraSceneNode
from cura.Snapshot import Snapshot from cura.Snapshot import Snapshot
from PyQt6.QtCore import QBuffer from PyQt6.QtCore import Qt, QBuffer
from PyQt6.QtGui import QImage, QPainter
import pySavitar as Savitar import pySavitar as Savitar
from .UCPDialog import UCPDialog
import numpy import numpy
import datetime import datetime
@ -40,6 +43,9 @@ except ImportError:
import zipfile import zipfile
import UM.Application import UM.Application
from .SettingsExportModel import SettingsExportModel
from .SettingsExportGroup import SettingsExportGroup
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")
@ -60,6 +66,7 @@ class ThreeMFWriter(MeshWriter):
self._unit_matrix_string = ThreeMFWriter._convertMatrixToString(Matrix()) self._unit_matrix_string = ThreeMFWriter._convertMatrixToString(Matrix())
self._archive: Optional[zipfile.ZipFile] = None self._archive: Optional[zipfile.ZipFile] = None
self._store_archive = False self._store_archive = False
self._lock = threading.Lock()
@staticmethod @staticmethod
def _convertMatrixToString(matrix): def _convertMatrixToString(matrix):
@ -87,7 +94,9 @@ class ThreeMFWriter(MeshWriter):
self._store_archive = store_archive self._store_archive = store_archive
@staticmethod @staticmethod
def _convertUMNodeToSavitarNode(um_node, transformation=Matrix()): def _convertUMNodeToSavitarNode(um_node,
transformation = Matrix(),
exported_settings: Optional[Dict[str, Set[str]]] = None):
"""Convenience function that converts an Uranium SceneNode object to a SavitarSceneNode """Convenience function that converts an Uranium SceneNode object to a SavitarSceneNode
:returns: Uranium Scene node. :returns: Uranium Scene node.
@ -129,6 +138,7 @@ class ThreeMFWriter(MeshWriter):
if stack is not None: if stack is not None:
changed_setting_keys = stack.getTop().getAllKeys() changed_setting_keys = stack.getTop().getAllKeys()
if exported_settings is None:
# Ensure that we save the extruder used for this object in a multi-extrusion setup # Ensure that we save the extruder used for this object in a multi-extrusion setup
if stack.getProperty("machine_extruder_count", "value") > 1: if stack.getProperty("machine_extruder_count", "value") > 1:
changed_setting_keys.add("extruder_nr") changed_setting_keys.add("extruder_nr")
@ -136,6 +146,18 @@ class ThreeMFWriter(MeshWriter):
# Get values for all changed settings & save them. # Get values for all changed settings & save them.
for key in changed_setting_keys: for key in changed_setting_keys:
savitar_node.setSetting("cura:" + key, str(stack.getProperty(key, "value"))) savitar_node.setSetting("cura:" + key, str(stack.getProperty(key, "value")))
else:
# We want to export only the specified settings
if um_node.getName() in exported_settings:
model_exported_settings = exported_settings[um_node.getName()]
# Get values for all exported settings & save them.
for key in model_exported_settings:
savitar_node.setSetting("cura:" + key, str(stack.getProperty(key, "value")))
if isinstance(um_node, CuraSceneNode):
savitar_node.setSetting("cura:print_order", str(um_node.printOrder))
savitar_node.setSetting("cura:drop_to_buildplate", str(um_node.isDropDownEnabled))
# Store the metadata. # Store the metadata.
for key, value in um_node.metadata.items(): for key, value in um_node.metadata.items():
@ -145,7 +167,8 @@ class ThreeMFWriter(MeshWriter):
# only save the nodes on the active build plate # only save the nodes on the active build plate
if child_node.callDecoration("getBuildPlateNumber") != active_build_plate_nr: if child_node.callDecoration("getBuildPlateNumber") != active_build_plate_nr:
continue continue
savitar_child_node = ThreeMFWriter._convertUMNodeToSavitarNode(child_node) savitar_child_node = ThreeMFWriter._convertUMNodeToSavitarNode(child_node,
exported_settings = exported_settings)
if savitar_child_node is not None: if savitar_child_node is not None:
savitar_node.addChild(savitar_child_node) savitar_node.addChild(savitar_child_node)
@ -154,7 +177,24 @@ class ThreeMFWriter(MeshWriter):
def getArchive(self): def getArchive(self):
return self._archive return self._archive
def write(self, stream, nodes, mode = MeshWriter.OutputMode.BinaryMode) -> bool: def _addLogoToThumbnail(self, primary_image, logo_name):
# Load the icon png image
icon_image = QImage(Resources.getPath(Resources.Images, logo_name))
# Resize icon_image to be 1/4 of primary_image size
new_width = int(primary_image.width() / 4)
new_height = int(primary_image.height() / 4)
icon_image = icon_image.scaled(new_width, new_height, Qt.AspectRatioMode.KeepAspectRatio)
# Create a QPainter to draw on the image
painter = QPainter(primary_image)
# Draw the icon in the top-left corner (adjust coordinates as needed)
icon_position = (10, 10)
painter.drawImage(icon_position[0], icon_position[1], icon_image)
painter.end()
def write(self, stream, nodes, mode = MeshWriter.OutputMode.BinaryMode, export_settings_model = None) -> bool:
self._archive = None # Reset archive self._archive = None # Reset archive
archive = zipfile.ZipFile(stream, "w", compression = zipfile.ZIP_DEFLATED) archive = zipfile.ZipFile(stream, "w", compression = zipfile.ZIP_DEFLATED)
try: try:
@ -178,6 +218,10 @@ class ThreeMFWriter(MeshWriter):
# Attempt to add a thumbnail # Attempt to add a thumbnail
snapshot = self._createSnapshot() snapshot = self._createSnapshot()
if snapshot: if snapshot:
if export_settings_model != None:
self._addLogoToThumbnail(snapshot, "cura-share.png")
elif export_settings_model == None and self._store_archive:
self._addLogoToThumbnail(snapshot, "cura-icon.png")
thumbnail_buffer = QBuffer() thumbnail_buffer = QBuffer()
thumbnail_buffer.open(QBuffer.OpenModeFlag.ReadWrite) thumbnail_buffer.open(QBuffer.OpenModeFlag.ReadWrite)
snapshot.save(thumbnail_buffer, "PNG") snapshot.save(thumbnail_buffer, "PNG")
@ -232,14 +276,20 @@ class ThreeMFWriter(MeshWriter):
transformation_matrix.preMultiply(translation_matrix) transformation_matrix.preMultiply(translation_matrix)
root_node = UM.Application.Application.getInstance().getController().getScene().getRoot() root_node = UM.Application.Application.getInstance().getController().getScene().getRoot()
exported_model_settings = ThreeMFWriter._extractModelExportedSettings(export_settings_model) if export_settings_model != None else None
for node in nodes: for node in nodes:
if node == root_node: if node == root_node:
for root_child in node.getChildren(): for root_child in node.getChildren():
savitar_node = ThreeMFWriter._convertUMNodeToSavitarNode(root_child, transformation_matrix) savitar_node = ThreeMFWriter._convertUMNodeToSavitarNode(root_child,
transformation_matrix,
exported_model_settings)
if savitar_node: if savitar_node:
savitar_scene.addSceneNode(savitar_node) savitar_scene.addSceneNode(savitar_node)
else: else:
savitar_node = self._convertUMNodeToSavitarNode(node, transformation_matrix) savitar_node = self._convertUMNodeToSavitarNode(node,
transformation_matrix,
exported_model_settings)
if savitar_node: if savitar_node:
savitar_scene.addSceneNode(savitar_node) savitar_scene.addSceneNode(savitar_node)
@ -375,6 +425,7 @@ class ThreeMFWriter(MeshWriter):
@call_on_qt_thread # must be called from the main thread because of OpenGL @call_on_qt_thread # must be called from the main thread because of OpenGL
def _createSnapshot(self): def _createSnapshot(self):
Logger.log("d", "Creating thumbnail image...") Logger.log("d", "Creating thumbnail image...")
self._lock.acquire()
if not CuraApplication.getInstance().isVisible: if not CuraApplication.getInstance().isVisible:
Logger.log("w", "Can't create snapshot when renderer not initialized.") Logger.log("w", "Can't create snapshot when renderer not initialized.")
return None return None
@ -383,6 +434,7 @@ class ThreeMFWriter(MeshWriter):
except: except:
Logger.logException("w", "Failed to create snapshot image") Logger.logException("w", "Failed to create snapshot image")
return None return None
finally: self._lock.release()
return snapshot return snapshot
@ -395,3 +447,24 @@ class ThreeMFWriter(MeshWriter):
parser = Savitar.ThreeMFParser() parser = Savitar.ThreeMFParser()
scene_string = parser.sceneToString(savitar_scene) scene_string = parser.sceneToString(savitar_scene)
return scene_string return scene_string
@staticmethod
def _extractModelExportedSettings(model: Optional[SettingsExportModel]) -> Dict[str, Set[str]]:
extra_settings = {}
if model is not None:
for group in model.settingsGroups:
if group.category == SettingsExportGroup.Category.Model:
exported_model_settings = set()
for exported_setting in group.settings:
if exported_setting.selected:
exported_model_settings.add(exported_setting.id)
extra_settings[group.category_details] = exported_model_settings
return extra_settings
def exportUcp(self):
self._config_dialog = UCPDialog()
self._config_dialog.show()

View file

@ -0,0 +1,114 @@
# Copyright (c) 2024 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import os
from PyQt6.QtCore import pyqtSignal, QObject
import UM
from UM.FlameProfiler import pyqtSlot
from UM.OutputDevice import OutputDeviceError
from UM.Workspace.WorkspaceWriter import WorkspaceWriter
from UM.i18n import i18nCatalog
from UM.Logger import Logger
from UM.Message import Message
from cura.CuraApplication import CuraApplication
from .SettingsExportModel import SettingsExportModel
i18n_catalog = i18nCatalog("cura")
class UCPDialog(QObject):
finished = pyqtSignal(bool)
def __init__(self, parent = None) -> None:
super().__init__(parent)
plugin_path = os.path.dirname(__file__)
dialog_path = os.path.join(plugin_path, 'UCPDialog.qml')
self._model = SettingsExportModel()
self._view = CuraApplication.getInstance().createQmlComponent(
dialog_path,
{
"manager": self,
"settingsExportModel": self._model
}
)
self._view.accepted.connect(self._onAccepted)
self._view.rejected.connect(self._onRejected)
self._finished = False
self._accepted = False
def show(self) -> None:
self._finished = False
self._accepted = False
self._view.show()
def getModel(self) -> SettingsExportModel:
return self._model
@pyqtSlot()
def notifyClosed(self):
self._onFinished()
def save3mf(self):
application = CuraApplication.getInstance()
workspace_handler = application.getInstance().getWorkspaceFileHandler()
# Set the model to the workspace writer
mesh_writer = workspace_handler.getWriter("3MFWriter")
mesh_writer.setExportModel(self._model)
# Open file dialog and write the file
device = application.getOutputDeviceManager().getOutputDevice("local_file")
nodes = [application.getController().getScene().getRoot()]
device.writeError.connect(lambda: self._onRejected())
device.writeSuccess.connect(lambda: self._onSuccess())
device.writeFinished.connect(lambda: self._onFinished())
file_name = f"UCP_{CuraApplication.getInstance().getPrintInformation().baseName}"
try:
device.requestWrite(
nodes,
file_name,
["application/x-ucp"],
workspace_handler,
preferred_mimetype_list="application/x-ucp"
)
except OutputDeviceError.UserCanceledError:
self._onRejected()
except Exception as e:
message = Message(
i18n_catalog.i18nc("@info:error", "Unable to write to file: {0}", file_name),
title=i18n_catalog.i18nc("@info:title", "Error"),
message_type=Message.MessageType.ERROR
)
message.show()
Logger.logException("e", "Unable to write to file %s: %s", file_name, e)
self._onRejected()
def _onAccepted(self):
self.save3mf()
def _onRejected(self):
self._onFinished()
def _onSuccess(self):
self._accepted = True
self._onFinished()
def _onFinished(self):
# Make sure we don't send the finished signal twice, whatever happens
if self._finished:
return
self._finished = True
# Reset the model to the workspace writer
mesh_writer = CuraApplication.getInstance().getInstance().getWorkspaceFileHandler().getWriter("3MFWriter")
mesh_writer.setExportModel(None)
self.finished.emit(self._accepted)

View file

@ -0,0 +1,109 @@
// Copyright (c) 2024 Ultimaker B.V.
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick 2.10
import QtQuick.Controls 2.3
import QtQuick.Layouts 1.3
import QtQuick.Window 2.2
import UM 1.5 as UM
import Cura 1.1 as Cura
UM.Dialog
{
id: exportDialog
title: catalog.i18nc("@title:window Don't translate 'Universal Cura Project'", "Export Universal Cura Project")
margin: UM.Theme.getSize("default_margin").width
minimumWidth: UM.Theme.getSize("modal_window_minimum").width
minimumHeight: UM.Theme.getSize("modal_window_minimum").height
backgroundColor: UM.Theme.getColor("detail_background")
headerComponent: Rectangle
{
height: childrenRect.height + 2 * UM.Theme.getSize("default_margin").height
color: UM.Theme.getColor("main_background")
ColumnLayout
{
id: headerColumn
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: UM.Theme.getSize("default_margin").height
anchors.leftMargin: UM.Theme.getSize("default_margin").width
anchors.rightMargin: anchors.leftMargin
RowLayout
{
UM.Label
{
id: titleLabel
text: catalog.i18nc("@action:title Don't translate 'Universal Cura Project'", "Summary - Universal Cura Project")
font: UM.Theme.getFont("large")
}
Cura.TertiaryButton
{
id: learnMoreButton
text: catalog.i18nc("@button", "Learn more")
iconSource: UM.Theme.getIcon("LinkExternal")
isIconOnRightSide: true
onClicked: Qt.openUrlExternally("https://support.ultimaker.com/s/article/000002979")
}
}
UM.Label
{
id: descriptionLabel
text: catalog.i18nc("@action:description Don't translate 'Universal Cura Project'", "Universal Cura Project files can be printed on different 3D printers while retaining positional data and selected settings. When exported, all models present on the build plate will be included along with their current position, orientation, and scale. You can also select which per-extruder or per-model settings should be included to ensure proper printing.")
font: UM.Theme.getFont("default")
wrapMode: Text.Wrap
Layout.maximumWidth: headerColumn.width
}
}
}
Rectangle
{
anchors.fill: parent
color: UM.Theme.getColor("main_background")
UM.I18nCatalog { id: catalog; name: "cura" }
ListView
{
id: settingsExportList
anchors.fill: parent
anchors.margins: UM.Theme.getSize("default_margin").width
spacing: UM.Theme.getSize("thick_margin").height
model: settingsExportModel.settingsGroups
clip: true
ScrollBar.vertical: UM.ScrollBar { id: verticalScrollBar }
delegate: SettingsSelectionGroup { Layout.margins: 0 }
}
}
rightButtons:
[
Cura.TertiaryButton
{
text: catalog.i18nc("@action:button", "Cancel")
onClicked: reject()
},
Cura.PrimaryButton
{
text: catalog.i18nc("@action:button", "Save project")
onClicked: accept()
}
]
buttonSpacing: UM.Theme.getSize("wide_margin").width
onClosing:
{
manager.notifyClosed()
}
}

View file

@ -2,9 +2,12 @@
# Uranium is released under the terms of the LGPLv3 or higher. # Uranium is released under the terms of the LGPLv3 or higher.
import sys import sys
from PyQt6.QtQml import qmlRegisterType
from UM.Logger import Logger from UM.Logger import Logger
try: try:
from . import ThreeMFWriter from . import ThreeMFWriter
from .SettingsExportGroup import SettingsExportGroup
threemf_writer_was_imported = True threemf_writer_was_imported = True
except ImportError: except ImportError:
Logger.log("w", "Could not import ThreeMFWriter; libSavitar may be missing") Logger.log("w", "Could not import ThreeMFWriter; libSavitar may be missing")
@ -23,20 +26,30 @@ def getMetaData():
if threemf_writer_was_imported: if threemf_writer_was_imported:
metaData["mesh_writer"] = { metaData["mesh_writer"] = {
"output": [{ "output": [
{
"extension": "3mf", "extension": "3mf",
"description": i18n_catalog.i18nc("@item:inlistbox", "3MF file"), "description": i18n_catalog.i18nc("@item:inlistbox", "3MF file"),
"mime_type": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml", "mime_type": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
"mode": ThreeMFWriter.ThreeMFWriter.OutputMode.BinaryMode "mode": ThreeMFWriter.ThreeMFWriter.OutputMode.BinaryMode
}] },
]
} }
metaData["workspace_writer"] = { metaData["workspace_writer"] = {
"output": [{ "output": [
{
"extension": workspace_extension, "extension": workspace_extension,
"description": i18n_catalog.i18nc("@item:inlistbox", "Cura Project 3MF file"), "description": i18n_catalog.i18nc("@item:inlistbox", "Cura Project 3MF file"),
"mime_type": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml", "mime_type": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
"mode": ThreeMFWorkspaceWriter.ThreeMFWorkspaceWriter.OutputMode.BinaryMode "mode": ThreeMFWorkspaceWriter.ThreeMFWorkspaceWriter.OutputMode.BinaryMode
}] },
{
"extension": "3mf",
"description": i18n_catalog.i18nc("@item:inlistbox", "Universal Cura Project"),
"mime_type": "application/x-ucp",
"mode": ThreeMFWorkspaceWriter.ThreeMFWorkspaceWriter.OutputMode.BinaryMode
}
]
} }
return metaData return metaData
@ -44,6 +57,8 @@ def getMetaData():
def register(app): def register(app):
if "3MFWriter.ThreeMFWriter" in sys.modules: if "3MFWriter.ThreeMFWriter" in sys.modules:
qmlRegisterType(SettingsExportGroup, "ThreeMFWriter", 1, 0, "SettingsExportGroup")
return {"mesh_writer": ThreeMFWriter.ThreeMFWriter(), return {"mesh_writer": ThreeMFWriter.ThreeMFWriter(),
"workspace_writer": ThreeMFWorkspaceWriter.ThreeMFWorkspaceWriter()} "workspace_writer": ThreeMFWorkspaceWriter.ThreeMFWorkspaceWriter()}
else: else:

View file

@ -2,7 +2,7 @@
"name": "3MF Writer", "name": "3MF Writer",
"author": "Ultimaker B.V.", "author": "Ultimaker B.V.",
"version": "1.0.1", "version": "1.0.1",
"description": "Provides support for writing 3MF files.", "description": "Provides support for writing 3MF and UCP files.",
"api": 8, "api": 8,
"i18n-catalog": "cura" "i18n-catalog": "cura"
} }

View file

@ -35,6 +35,8 @@ message Slice
repeated EnginePlugin engine_plugins = 5; repeated EnginePlugin engine_plugins = 5;
string sentry_id = 6; // The anonymized Sentry user id that requested the slice string sentry_id = 6; // The anonymized Sentry user id that requested the slice
string cura_version = 7; // The version of Cura that requested the slice string cura_version = 7; // The version of Cura that requested the slice
optional string project_name = 8; // The name of the project that requested the slice
optional string user_name = 9; // The Digital Factory account name of the user that requested the slice
} }
message Extruder message Extruder

View file

@ -76,6 +76,7 @@ class CuraEngineBackend(QObject, Backend):
self._default_engine_location = executable_name self._default_engine_location = executable_name
search_path = [ search_path = [
os.path.abspath(os.path.join(os.path.dirname(sys.executable), "..", "Resources")),
os.path.abspath(os.path.dirname(sys.executable)), os.path.abspath(os.path.dirname(sys.executable)),
os.path.abspath(os.path.join(os.path.dirname(sys.executable), "bin")), os.path.abspath(os.path.join(os.path.dirname(sys.executable), "bin")),
os.path.abspath(os.path.join(os.path.dirname(sys.executable), "..")), os.path.abspath(os.path.join(os.path.dirname(sys.executable), "..")),
@ -164,6 +165,7 @@ class CuraEngineBackend(QObject, Backend):
application.getPreferences().addPreference("general/auto_slice", False) application.getPreferences().addPreference("general/auto_slice", False)
application.getPreferences().addPreference("info/send_engine_crash", True) application.getPreferences().addPreference("info/send_engine_crash", True)
application.getPreferences().addPreference("info/anonymous_engine_crash_report", True)
self._use_timer: bool = False self._use_timer: bool = False
@ -179,7 +181,10 @@ class CuraEngineBackend(QObject, Backend):
application.getPreferences().preferenceChanged.connect(self._onPreferencesChanged) application.getPreferences().preferenceChanged.connect(self._onPreferencesChanged)
self._slicing_error_message = Message( self._slicing_error_message = Message(
text = catalog.i18nc("@message", "Slicing failed with an unexpected error. Please consider reporting a bug on our issue tracker."), text = catalog.i18nc("@message", "Oops! We encountered an unexpected error during your slicing process. "
"Rest assured, we've automatically received the crash logs for analysis, "
"if you have not disabled data sharing in your preferences. To assist us "
"further, consider sharing your project details on our issue tracker."),
title = catalog.i18nc("@message:title", "Slicing failed"), title = catalog.i18nc("@message:title", "Slicing failed"),
message_type = Message.MessageType.ERROR message_type = Message.MessageType.ERROR
) )
@ -1094,14 +1099,14 @@ class CuraEngineBackend(QObject, Backend):
self._change_timer.timeout.disconnect(self.slice) self._change_timer.timeout.disconnect(self.slice)
def _onPreferencesChanged(self, preference: str) -> None: def _onPreferencesChanged(self, preference: str) -> None:
if preference != "general/auto_slice" and preference != "info/send_engine_crash": if preference != "general/auto_slice" and preference != "info/send_engine_crash" and preference != "info/anonymous_engine_crash_report":
return return
if preference == "general/auto_slice": if preference == "general/auto_slice":
auto_slice = self.determineAutoSlicing() auto_slice = self.determineAutoSlicing()
if auto_slice: if auto_slice:
self._change_timer.start() self._change_timer.start()
elif preference == "info/send_engine_crash": elif preference == "info/send_engine_crash":
os.environ["use_sentry"] = "1" if CuraApplication.getInstance().getPreferences().getValue("info/send_engine_crash") else "0" os.environ["USE_SENTRY"] = "1" if CuraApplication.getInstance().getPreferences().getValue("info/send_engine_crash") else "0"
def tickle(self) -> None: def tickle(self) -> None:
"""Tickle the backend so in case of auto slicing, it starts the timer.""" """Tickle the backend so in case of auto slicing, it starts the timer."""

View file

@ -1,4 +1,4 @@
# Copyright (c) 2023 UltiMaker # Copyright (c) 2024 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import uuid import uuid
@ -63,13 +63,12 @@ class GcodeStartEndFormatter(Formatter):
# will be used. Alternatively, if the expression is formatted as "{[expression], [extruder_nr]}", # will be used. Alternatively, if the expression is formatted as "{[expression], [extruder_nr]}",
# then the expression will be evaluated with the extruder stack of the specified extruder_nr. # then the expression will be evaluated with the extruder stack of the specified extruder_nr.
_extruder_regex = re.compile(r"^\s*(?P<expression>.*)\s*,\s*(?P<extruder_nr>\d+)\s*$") _extruder_regex = re.compile(r"^\s*(?P<expression>.*)\s*,\s*(?P<extruder_nr_expr>.*)\s*$")
def __init__(self, default_extruder_nr: int = -1, *, def __init__(self, all_extruder_settings: Dict[str, Any], default_extruder_nr: int = -1) -> None:
additional_per_extruder_settings: Optional[Dict[str, Dict[str, any]]] = None) -> None:
super().__init__() super().__init__()
self._all_extruder_settings: Dict[str, Any] = all_extruder_settings
self._default_extruder_nr: int = default_extruder_nr self._default_extruder_nr: int = default_extruder_nr
self._additional_per_extruder_settings: Optional[Dict[str, Dict[str, any]]] = additional_per_extruder_settings
def get_field(self, field_name, args: [str], kwargs: dict) -> Tuple[str, str]: def get_field(self, field_name, args: [str], kwargs: dict) -> Tuple[str, str]:
# get_field method parses all fields in the format-string and parses them individually to the get_value method. # get_field method parses all fields in the format-string and parses them individually to the get_value method.
@ -88,22 +87,32 @@ class GcodeStartEndFormatter(Formatter):
if expression in post_slice_data_variables: if expression in post_slice_data_variables:
return f"{{{expression}}}" return f"{{{expression}}}"
extruder_nr = self._default_extruder_nr extruder_nr = str(self._default_extruder_nr)
# The settings may specify a specific extruder to use. This is done by # The settings may specify a specific extruder to use. This is done by
# formatting the expression as "{expression}, {extruder_nr}". If the # formatting the expression as "{expression}, {extruder_nr_expr}". If the
# expression is formatted like this, we extract the extruder_nr and use # expression is formatted like this, we extract the extruder_nr and use
# it to get the value from the correct extruder stack. # it to get the value from the correct extruder stack.
match = self._extruder_regex.match(expression) match = self._extruder_regex.match(expression)
if match: if match:
expression = match.group("expression") expression = match.group("expression")
extruder_nr = int(match.group("extruder_nr")) extruder_nr_expr = match.group("extruder_nr_expr")
if self._additional_per_extruder_settings is not None and str( if extruder_nr_expr.isdigit():
extruder_nr) in self._additional_per_extruder_settings: extruder_nr = extruder_nr_expr
additional_variables = self._additional_per_extruder_settings[str(extruder_nr)]
else: else:
additional_variables = dict() # We get the value of the extruder_nr_expr from `_all_extruder_settings` dictionary
# rather than the global container stack. The `_all_extruder_settings["-1"]` is a
# dict-representation of the global container stack, with additional properties such
# as `initial_extruder_nr`. As users may enter such expressions we can't use the
# global container stack.
extruder_nr = str(self._all_extruder_settings["-1"].get(extruder_nr_expr, "-1"))
if extruder_nr in self._all_extruder_settings:
additional_variables = self._all_extruder_settings[extruder_nr].copy()
else:
Logger.warning(f"Extruder {extruder_nr} does not exist, using global settings")
additional_variables = self._all_extruder_settings["-1"].copy()
# Add the arguments and keyword arguments to the additional settings. These # Add the arguments and keyword arguments to the additional settings. These
# are currently _not_ used, but they are added for consistency with the # are currently _not_ used, but they are added for consistency with the
@ -113,15 +122,17 @@ class GcodeStartEndFormatter(Formatter):
for key, value in kwargs.items(): for key, value in kwargs.items():
additional_variables[key] = value additional_variables[key] = value
if extruder_nr == -1: if extruder_nr == "-1":
container_stack = CuraApplication.getInstance().getGlobalContainerStack() container_stack = CuraApplication.getInstance().getGlobalContainerStack()
else: else:
container_stack = ExtruderManager.getInstance().getExtruderStack(extruder_nr) container_stack = ExtruderManager.getInstance().getExtruderStack(extruder_nr)
if not container_stack:
Logger.warning(f"Extruder {extruder_nr} does not exist, using global settings")
container_stack = CuraApplication.getInstance().getGlobalContainerStack()
setting_function = SettingFunction(expression) setting_function = SettingFunction(expression)
value = setting_function(container_stack, additional_variables=additional_variables) value = setting_function(container_stack, additional_variables=additional_variables)
return value return value
@ -131,12 +142,13 @@ class StartSliceJob(Job):
def __init__(self, slice_message: Arcus.PythonMessage) -> None: def __init__(self, slice_message: Arcus.PythonMessage) -> None:
super().__init__() super().__init__()
self._scene = CuraApplication.getInstance().getController().getScene() #type: Scene self._scene: Scene = CuraApplication.getInstance().getController().getScene()
self._slice_message: Arcus.PythonMessage = slice_message self._slice_message: Arcus.PythonMessage = slice_message
self._is_cancelled = False #type: bool self._is_cancelled: bool = False
self._build_plate_number = None #type: Optional[int] self._build_plate_number: Optional[int] = None
self._all_extruders_settings = None #type: Optional[Dict[str, Any]] # cache for all setting values from all stacks (global & extruder) for the current machine # cache for all setting values from all stacks (global & extruder) for the current machine
self._all_extruders_settings: Optional[Dict[str, Any]] = None
def getSliceMessage(self) -> Arcus.PythonMessage: def getSliceMessage(self) -> Arcus.PythonMessage:
return self._slice_message return self._slice_message
@ -337,9 +349,15 @@ class StartSliceJob(Job):
user_id = uuid.getnode() # On all of Cura's supported platforms, this returns the MAC address which is pseudonymical information (!= anonymous). user_id = uuid.getnode() # On all of Cura's supported platforms, this returns the MAC address which is pseudonymical information (!= anonymous).
user_id %= 2 ** 16 # So to make it anonymous, apply a bitmask selecting only the last 16 bits. This prevents it from being traceable to a specific user but still gives somewhat of an idea of whether it's just the same user hitting the same crash over and over again, or if it's widespread. user_id %= 2 ** 16 # So to make it anonymous, apply a bitmask selecting only the last 16 bits. This prevents it from being traceable to a specific user but still gives somewhat of an idea of whether it's just the same user hitting the same crash over and over again, or if it's widespread.
self._slice_message.sentry_id = "{user_id}" self._slice_message.sentry_id = f"{user_id}"
self._slice_message.cura_version = CuraVersion self._slice_message.cura_version = CuraVersion
# Add the project name to the message if the user allows for non-anonymous crash data collection.
account = CuraApplication.getInstance().getCuraAPI().account
if account and account.isLoggedIn and not CuraApplication.getInstance().getPreferences().getValue("info/anonymous_engine_crash_report"):
self._slice_message.project_name = CuraApplication.getInstance().getPrintInformation().baseName
self._slice_message.user_name = account.userName
# Build messages for extruder stacks # Build messages for extruder stacks
for extruder_stack in global_stack.extruderList: for extruder_stack in global_stack.extruderList:
self._buildExtruderMessage(extruder_stack) self._buildExtruderMessage(extruder_stack)
@ -471,10 +489,7 @@ class StartSliceJob(Job):
# Get "replacement-keys" for the extruders. In the formatter the settings stack is used to get the # Get "replacement-keys" for the extruders. In the formatter the settings stack is used to get the
# replacement values for the setting-keys. However, the values for `material_id`, `material_type`, # replacement values for the setting-keys. However, the values for `material_id`, `material_type`,
# etc are not in the settings stack. # etc are not in the settings stack.
additional_per_extruder_settings = self._all_extruders_settings.copy() fmt = GcodeStartEndFormatter(self._all_extruders_settings, default_extruder_nr=default_extruder_nr)
additional_per_extruder_settings["default_extruder_nr"] = default_extruder_nr
fmt = GcodeStartEndFormatter(default_extruder_nr=default_extruder_nr,
additional_per_extruder_settings=additional_per_extruder_settings)
return str(fmt.format(value)) return str(fmt.format(value))
except: except:
Logger.logException("w", "Unable to do token replacement on start/end g-code") Logger.logException("w", "Unable to do token replacement on start/end g-code")

View file

@ -1,7 +1,6 @@
# Copyright (c) 2021 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from UM.MimeTypeDatabase import MimeType, MimeTypeDatabase
from .src import DigitalFactoryFileProvider, DigitalFactoryOutputDevicePlugin, DigitalFactoryController from .src import DigitalFactoryFileProvider, DigitalFactoryOutputDevicePlugin, DigitalFactoryController

View file

@ -3,7 +3,6 @@
import json import json
from json import JSONDecodeError from json import JSONDecodeError
import re
from time import time from time import time
from typing import List, Any, Optional, Union, Type, Tuple, Dict, cast, TypeVar, Callable from typing import List, Any, Optional, Union, Type, Tuple, Dict, cast, TypeVar, Callable

View file

@ -4,7 +4,6 @@ from typing import List, Optional
from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtCore import Qt, pyqtSignal
from UM.Logger import Logger
from UM.Qt.ListModel import ListModel from UM.Qt.ListModel import ListModel
from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse

View file

@ -2,7 +2,6 @@
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from UM.Platform import Platform
from . import GCodeGzWriter from . import GCodeGzWriter

View file

@ -11,7 +11,6 @@ from UM.Settings.InstanceContainer import InstanceContainer
from cura.Machines.ContainerTree import ContainerTree from cura.Machines.ContainerTree import ContainerTree
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from cura.Settings.CuraStackBuilder import CuraStackBuilder
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")

View file

@ -3,12 +3,10 @@
from typing import Optional, TYPE_CHECKING, Dict, List from typing import Optional, TYPE_CHECKING, Dict, List
from .Constants import PACKAGES_URL
from .PackageModel import PackageModel from .PackageModel import PackageModel
from .RemotePackageList import RemotePackageList from .RemotePackageList import RemotePackageList
from PyQt6.QtCore import pyqtSignal, QObject, pyqtProperty, QCoreApplication from PyQt6.QtCore import pyqtSignal, QObject, pyqtProperty, QCoreApplication
from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To request the package list from the API.
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
if TYPE_CHECKING: if TYPE_CHECKING:

View file

@ -2,7 +2,6 @@
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import re import re
from enum import Enum
from typing import Any, cast, Dict, List, Optional from typing import Any, cast, Dict, List, Optional
from PyQt6.QtCore import pyqtProperty, QObject, pyqtSignal, pyqtSlot from PyQt6.QtCore import pyqtProperty, QObject, pyqtSignal, pyqtSlot
@ -12,7 +11,6 @@ from cura.CuraApplication import CuraApplication
from cura.CuraPackageManager import CuraPackageManager from cura.CuraPackageManager import CuraPackageManager
from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To get names of materials we're compatible with. from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To get names of materials we're compatible with.
from UM.i18n import i18nCatalog # To translate placeholder names if data is not present. from UM.i18n import i18nCatalog # To translate placeholder names if data is not present.
from UM.Logger import Logger
from UM.PluginRegistry import PluginRegistry from UM.PluginRegistry import PluginRegistry
catalog = i18nCatalog("cura") catalog = i18nCatalog("cura")

View file

@ -25,7 +25,7 @@ UM.TooltipArea
onClicked: onClicked:
{ {
addedSettingsModel.setVisible(model.key, checked); addedSettingsModel.setVisible(model.key, checked);
UM.ActiveTool.forceUpdate(); UM.Controller.forceUpdate();
} }
} }

View file

@ -11,7 +11,7 @@ from UM.Settings.SettingInstance import SettingInstance
from UM.Logger import Logger from UM.Logger import Logger
import UM.Settings.Models.SettingVisibilityHandler import UM.Settings.Models.SettingVisibilityHandler
from cura.Settings.ExtruderManager import ExtruderManager #To get global-inherits-stack setting values from different extruders. from cura.Settings.ExtruderManager import ExtruderManager # To get global-inherits-stack setting values from different extruders.
from cura.Settings.SettingOverrideDecorator import SettingOverrideDecorator from cura.Settings.SettingOverrideDecorator import SettingOverrideDecorator

View file

@ -23,7 +23,7 @@ Item
readonly property string infillMeshType: "infill_mesh" readonly property string infillMeshType: "infill_mesh"
readonly property string antiOverhangMeshType: "anti_overhang_mesh" readonly property string antiOverhangMeshType: "anti_overhang_mesh"
property var currentMeshType: UM.ActiveTool.properties.getValue("MeshType") property var currentMeshType: UM.Controller.properties.getValue("MeshType")
// Update the view every time the currentMeshType changes // Update the view every time the currentMeshType changes
onCurrentMeshTypeChanged: onCurrentMeshTypeChanged:
@ -56,7 +56,7 @@ Item
function setMeshType(type) function setMeshType(type)
{ {
UM.ActiveTool.setProperty("MeshType", type) UM.Controller.setProperty("MeshType", type)
updateMeshTypeCheckedState(type) updateMeshTypeCheckedState(type)
} }
@ -224,7 +224,7 @@ Item
visibilityHandler: Cura.PerObjectSettingVisibilityHandler visibilityHandler: Cura.PerObjectSettingVisibilityHandler
{ {
id: visibility_handler id: visibility_handler
selectedObjectId: UM.ActiveTool.properties.getValue("SelectedObjectId") selectedObjectId: UM.Controller.properties.getValue("SelectedObjectId")
} }
// For some reason the model object is updated after removing him from the memory and // For some reason the model object is updated after removing him from the memory and
@ -320,7 +320,7 @@ Item
{ {
id: provider id: provider
containerStackId: UM.ActiveTool.properties.getValue("ContainerID") containerStackId: UM.Controller.properties.getValue("ContainerID")
key: model.key key: model.key
watchedProperties: [ "value", "enabled", "validationState" ] watchedProperties: [ "value", "enabled", "validationState" ]
storeIndex: 0 storeIndex: 0
@ -330,7 +330,7 @@ Item
UM.SettingPropertyProvider UM.SettingPropertyProvider
{ {
id: inheritStackProvider id: inheritStackProvider
containerStackId: UM.ActiveTool.properties.getValue("ContainerID") containerStackId: UM.Controller.properties.getValue("ContainerID")
key: model.key key: model.key
watchedProperties: [ "limit_to_extruder" ] watchedProperties: [ "limit_to_extruder" ]
} }
@ -381,22 +381,22 @@ Item
Connections Connections
{ {
target: UM.ActiveTool target: UM.Controller
function onPropertiesChanged() function onPropertiesChanged()
{ {
// the values cannot be bound with UM.ActiveTool.properties.getValue() calls, // the values cannot be bound with UM.Controller.properties.getValue() calls,
// so here we connect to the signal and update the those values. // so here we connect to the signal and update the those values.
if (typeof UM.ActiveTool.properties.getValue("SelectedObjectId") !== "undefined") if (typeof UM.Controller.properties.getValue("SelectedObjectId") !== "undefined")
{ {
const selectedObjectId = UM.ActiveTool.properties.getValue("SelectedObjectId") const selectedObjectId = UM.Controller.properties.getValue("SelectedObjectId")
if (addedSettingsModel.visibilityHandler.selectedObjectId != selectedObjectId) if (addedSettingsModel.visibilityHandler.selectedObjectId != selectedObjectId)
{ {
addedSettingsModel.visibilityHandler.selectedObjectId = selectedObjectId addedSettingsModel.visibilityHandler.selectedObjectId = selectedObjectId
} }
} }
if (typeof UM.ActiveTool.properties.getValue("ContainerID") !== "undefined") if (typeof UM.Controller.properties.getValue("ContainerID") !== "undefined")
{ {
const containerId = UM.ActiveTool.properties.getValue("ContainerID") const containerId = UM.Controller.properties.getValue("ContainerID")
if (provider.containerStackId !== containerId) if (provider.containerStackId !== containerId)
{ {
provider.containerStackId = containerId provider.containerStackId = containerId

View file

@ -120,6 +120,8 @@ UM.Dialog
UM.Label UM.Label
{ {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: height
elide: Text.ElideRight
text: manager.getScriptLabelByKey(modelData.toString()) text: manager.getScriptLabelByKey(modelData.toString())
} }

View file

@ -0,0 +1,837 @@
# Designed in January 2023 by GregValiant (Greg Foresi)
## My design intent was to make this as full featured and "industrial strength" as I could. People printing exotic materials on large custom printers may want to turn the fans off for certain layers, and then back on again later in the print. This script allows that.
# Functions:
## Remove all fan speed lines from the file (optional). This should be enabled for the first instance of the script. It is disabled by default in any following instances.
## "By Layer" allows the user to adjust the fan speed up, or down, or off, within the print. "By Feature" allows different fan speeds for different features (;TYPE:WALL-OUTER, etc.).
## If 'By Feature' then a Start Layer and/or an End Layer can be defined.
## Fan speeds are scaled PWM (0 - 255) or RepRap (0.0 - 1.0) depending on {machine_scale_fan_speed_zero_to_one}.
## A minimum fan speed of 12% is enforced. It is the slowest speed that my cooling fan will turn on so that's what I used. 'M106 S14' (as Cura might insert) was pretty useless.
## If multiple extruders have separate fan circuits the speeds are set at tool changes and conform to the layer or feature setting. There is support for up to 4 layer cooling fan circuits.
## My thanks to @5axes(@CUQ), @fieldOfView(@AHoeben), @Ghostkeeper, and @Torgeir. A special thanks to @RBurema for his patience in reviewing my 'non-pythonic' script.
## 9/14/23 (Greg Foresi) Added support for One-at-a-Time print sequence.
## 12/15/23 (Greg Foresi) Split off 'Single Fan By Layer', 'Multi-fan By Layer', 'Single Fan By Feature', and 'Multi-fan By Feature' from the main 'execute' script.
## 1/5/24 (Greg Foresi) Revised the regex replacements.
from ..Script import Script
from UM.Application import Application
import re
class AddCoolingProfile(Script):
def getSettingDataString(self):
return """{
"name": "Advanced Cooling Fan Control",
"key": "AddCoolingProfile",
"metadata": {},
"version": 2,
"settings":
{
"fan_layer_or_feature":
{
"label": "Cooling Control by:",
"description": "A fan percentage of ''0'' turns the fan off. Minimum Fan is 12% (when on). All layer entries are the Cura Preview number. ''By Layer'': Enter as ''Layer#/Fan%'' (foreslash is the delimiter). Your final layer speed will continue to the end of the Gcode. ''By Feature'': If you enable an 'End Layer' then the ''Final %'' is available and is the speed that will finish the file. 'By Feature' is better for large slow prints than it is for short fast prints.",
"type": "enum",
"options": {
"by_layer": "Layer Numbers",
"by_feature": "Feature Types"},
"default_value": "by_layer"
},
"delete_existing_m106":
{
"label": "Remove M106 lines prior to inserting new.",
"description": "If you have 2 or more instances of 'Advanced Cooling Fan Control' running (to cool a portion of a print differently), then you must uncheck this box or the followup instances will remove all the lines inserted by the first instance. Pay attention to the Start and Stop layers. Regardless of this setting: The script always removes M106 lines starting with the lowest layer number (when 'By Layer') or the starting layer number (when 'By Feature'). If you want to keep the M106 lines that Cura inserted up to the point where this post-processor will start making insertions, then un-check the box.",
"type": "bool",
"enabled": true,
"value": true,
"default_value": true
},
"feature_fan_start_layer":
{
"label": "Starting Layer",
"description": "Layer to start the insertion at. Use the Cura preview numbers. Changes will begin at the start of that layer.",
"type": "int",
"default_value": 5,
"minimum_value": 1,
"unit": "Lay# ",
"enabled": "fan_layer_or_feature == 'by_feature'"
},
"feature_fan_end_layer":
{
"label": "Ending Layer",
"description": "Layer to complete the insertion at. Enter '-1' for the entire file or enter a layer number. Insertions will stop at the END of this layer. If you set an End Layer then you should set the Final % that will finish the file",
"type": "int",
"default_value": -1,
"minimum_value": -1,
"unit": "Lay# ",
"enabled": "fan_layer_or_feature == 'by_feature'"
},
"layer_fan_1":
{
"label": "Layer/Percent #1",
"description": "Enter as: 'LAYER / Percent' Ex: 55/100 with the layer first, then a '/' to delimit, and then the fan percentage. There are up to 8 changes. If you need more then add a second instance of this script and remember to turn off 'Remove M106 lines' in the second instance. The layer numbers in the second instance must start with a layer number higher than the last layer number in a previous script. You can't end the first script with a setting for layer 80 and then start the second script with a setting for layer 40 because 'Remove M106 lines' always starts with the lowest layer number when 'By Layer' is selected.",
"type": "str",
"default_value": "5/30",
"unit": "L#/% ",
"enabled": "fan_layer_or_feature == 'by_layer'"
},
"layer_fan_2":
{
"label": "Layer/Percent #2",
"description": "Enter as: 'LAYER / Percent' Ex: 55/100 with the layer first, then a '/' to delimit, and then the fan percentage.",
"type": "str",
"default_value": "",
"unit": "L#/% ",
"enabled": "fan_layer_or_feature == 'by_layer'"
},
"layer_fan_3":
{
"label": "Layer/Percent #3",
"description": "Enter as: 'LAYER / Percent' Ex: 55/100 with the layer first, then a '/' to delimit, and then the fan percentage.",
"type": "str",
"default_value": "",
"unit": "L#/% ",
"enabled": "fan_layer_or_feature == 'by_layer'"
},
"layer_fan_4":
{
"label": "Layer/Percent #4",
"description": "Enter as: 'LAYER / Percent' Ex: 55/100 with the layer first, then a '/' to delimit, and then the fan percentage.",
"type": "str",
"default_value": "",
"unit": "L#/% ",
"enabled": "fan_layer_or_feature == 'by_layer'"
},
"layer_fan_5":
{
"label": "Layer/Percent #5",
"description": "Enter as: 'LAYER / Percent' Ex: 55/100 with the layer first, then a '/' to delimit, and then the fan percentage.",
"type": "str",
"default_value": "",
"unit": "L#/% ",
"enabled": "fan_layer_or_feature == 'by_layer'"
},
"layer_fan_6":
{
"label": "Layer/Percent #6",
"description": "Enter as: 'LAYER / Percent' Ex: 55/100 with the layer first, then a '/' to delimit, and then the fan percentage.",
"type": "str",
"default_value": "",
"unit": "L#/% ",
"enabled": "fan_layer_or_feature == 'by_layer'"
},
"layer_fan_7":
{
"label": "Layer/Percent #7",
"description": "Enter as: 'LAYER / Percent' Ex: 55/100 with the layer first, then a '/' to delimit, and then the fan percentage.",
"type": "str",
"default_value": "",
"unit": "L#/% ",
"enabled": "fan_layer_or_feature == 'by_layer'"
},
"layer_fan_8":
{
"label": "Layer/Percent #8",
"description": "Enter as: 'LAYER / Percent' Ex: 55/100 with the layer first, then a '/' to delimit, and then the fan percentage.",
"type": "str",
"default_value": "",
"unit": "L#/% ",
"enabled": "fan_layer_or_feature == 'by_layer'"
},
"feature_fan_skirt":
{
"label": "Skirt/Brim/Ooze Shield %",
"description": "Enter the fan percentage for skirt/brim. If you are starting at a layer above 1 then this setting only affects Ooze Shields and from the Start Layer up.",
"type": "int",
"default_value": 0,
"minimum_value": 0,
"maximum_value": 100,
"unit": "% ",
"enabled": "fan_layer_or_feature == 'by_feature'"
},
"feature_fan_wall_inner":
{
"label": "Inner Walls %",
"description": "Enter the fan percentage for the Wall-Inner.",
"type": "int",
"default_value": 35,
"minimum_value": 0,
"maximum_value": 100,
"unit": "% ",
"enabled": "fan_layer_or_feature == 'by_feature'"
},
"feature_fan_wall_outer":
{
"label": "Outer Walls %",
"description": "Enter the fan percentage for the Wall-Outer.",
"type": "int",
"default_value": 75,
"minimum_value": 0,
"maximum_value": 100,
"unit": "% ",
"enabled": "fan_layer_or_feature == 'by_feature'"
},
"feature_fan_fill":
{
"label": "Infill %",
"description": "Enter the fan percentage for the Infill.",
"type": "int",
"default_value": 35,
"minimum_value": 0,
"maximum_value": 100,
"unit": "% ",
"enabled": "fan_layer_or_feature == 'by_feature'"
},
"feature_fan_skin":
{
"label": "Top/Bottom (Skin) %",
"description": "Enter the fan percentage for the Skins.",
"type": "int",
"default_value": 100,
"minimum_value": 0,
"maximum_value": 100,
"unit": "% ",
"enabled": "fan_layer_or_feature == 'by_feature'"
},
"feature_fan_support":
{
"label": "Support %",
"description": "Enter the fan percentage for the Supports.",
"type": "int",
"default_value": 35,
"minimum_value": 0,
"maximum_value": 100,
"unit": "% ",
"enabled": "fan_layer_or_feature == 'by_feature'"
},
"feature_fan_support_interface":
{
"label": "Support Interface %",
"description": "Enter the fan percentage for the Support Interface.",
"type": "int",
"default_value": 100,
"minimum_value": 0,
"maximum_value": 100,
"unit": "% ",
"enabled": "fan_layer_or_feature == 'by_feature'"
},
"feature_fan_prime_tower":
{
"label": "Prime Tower %",
"description": "Enter the fan percentage for the Prime Tower (whether it's used or not).",
"type": "int",
"default_value": 35,
"minimum_value": 0,
"maximum_value": 100,
"unit": "% ",
"enabled": "fan_layer_or_feature == 'by_feature'"
},
"feature_fan_bridge":
{
"label": "Bridge %",
"description": "Enter the fan percentage for any Bridging (whether it's used on not).",
"type": "int",
"default_value": 100,
"minimum_value": 0,
"maximum_value": 100,
"unit": "% ",
"enabled": "fan_layer_or_feature == 'by_feature'"
},
"feature_fan_combing":
{
"label": "Fan 'OFF' during Combing:",
"description": "When checked will set the fan to 0% for combing moves over 5 lines long in the gcode. When un-checked the fan speed during combing is whatever the previous speed is set to.",
"type": "bool",
"enabled": "fan_layer_or_feature == 'by_feature'",
"default_value": true
},
"feature_fan_feature_final":
{
"label": "Final %",
"description": "If you choose an 'End Layer' then this is the fan speed that will carry through to the end of the gcode file. It will go into effect at the 'END' of your End layer.",
"type": "int",
"default_value": 35,
"minimum_value": 0,
"maximum_value": 100,
"unit": "% ",
"enabled": "(int(feature_fan_end_layer) != -1) and (fan_layer_or_feature == 'by_feature')"
},
"fan_enable_raft":
{
"label": "Enable Raft Cooling",
"description": "Enable the fan for the raft layers. When enabled the Raft Fan Speed will continue until another Layer or Feature setting over-rides it.",
"type": "bool",
"default_value": false,
"enabled": true
},
"fan_raft_percent":
{
"label": "Raft Fan %:",
"description": "Enter the percentage for the Raft.",
"type": "int",
"default_value": 35,
"minimum_value": 0,
"maximum_value": 100,
"unit": "% ",
"enabled": "fan_enable_raft"
}
}
}"""
def initialize(self) -> None:
super().initialize()
scripts = Application.getInstance().getGlobalContainerStack().getMetaDataEntry("post_processing_scripts")
if scripts != None:
script_count = scripts.count("AddCoolingProfile")
if script_count > 0:
## Set 'Remove M106 lines' to "false" if there is already an instance of this script running.
self._instance.setProperty("delete_existing_m106", "value", False)
def execute(self, data):
#Initialize variables that are buried in if statements.
mycura = Application.getInstance().getGlobalContainerStack()
t0_fan = " P0"; t1_fan = " P0"; t2_fan = " P0"; t3_fan = " P0"; is_multi_extr_print = True
#Get some information from Cura-----------------------------------
extruder = mycura.extruderList
#This will be true when fan scale is 0-255pwm and false when it's RepRap 0-1 (Cura 5.x)
fan_mode = True
##For 4.x versions that don't have the 0-1 option
try:
fan_mode = not bool(extruder[0].getProperty("machine_scale_fan_speed_zero_to_one", "value"))
except:
pass
bed_adhesion = (extruder[0].getProperty("adhesion_type", "value"))
extruder_count = mycura.getProperty("machine_extruder_count", "value")
print_sequence = str(mycura.getProperty("print_sequence", "value"))
#Assign the fan numbers to the tools------------------------------
if extruder_count == 1:
is_multi_fan = False
is_multi_extr_print = False
if int((extruder[0].getProperty("machine_extruder_cooling_fan_number", "value"))) > 0:
t0_fan = " P" + str((extruder[0].getProperty("machine_extruder_cooling_fan_number", "value")))
else:
#No P parameter if there is a single fan circuit------------------
t0_fan = ""
#Get the cooling fan numbers for each extruder if the printer has multiple extruders
elif extruder_count > 1:
is_multi_fan = True
t0_fan = " P" + str((extruder[0].getProperty("machine_extruder_cooling_fan_number", "value")))
if is_multi_fan:
if extruder_count > 1: t1_fan = " P" + str((extruder[1].getProperty("machine_extruder_cooling_fan_number", "value")))
if extruder_count > 2: t2_fan = " P" + str((extruder[2].getProperty("machine_extruder_cooling_fan_number", "value")))
if extruder_count > 3: t3_fan = " P" + str((extruder[3].getProperty("machine_extruder_cooling_fan_number", "value")))
#Initialize the fan_list with defaults----------------------------
fan_list = ["z"] * 16
for num in range(0,15,2):
fan_list[num] = len(data)
fan_list[num + 1] = "M106 S0"
#Assign the variable values if "By Layer"-------------------------
by_layer_or_feature = self.getSettingValueByKey("fan_layer_or_feature")
if by_layer_or_feature == "by_layer":
## By layer doesn't do any feature search so there is no need to look for combing moves
feature_fan_combing = False
fan_list[0] = self.getSettingValueByKey("layer_fan_1")
fan_list[2] = self.getSettingValueByKey("layer_fan_2")
fan_list[4] = self.getSettingValueByKey("layer_fan_3")
fan_list[6] = self.getSettingValueByKey("layer_fan_4")
fan_list[8] = self.getSettingValueByKey("layer_fan_5")
fan_list[10] = self.getSettingValueByKey("layer_fan_6")
fan_list[12] = self.getSettingValueByKey("layer_fan_7")
fan_list[14] = self.getSettingValueByKey("layer_fan_8")
## If there is no '/' delimiter then ignore the line else put the settings in a list
for num in range(0,15,2):
if "/" in fan_list[num]:
fan_list[num + 1] = self._layer_checker(fan_list[num], "p", fan_mode)
fan_list[num] = self._layer_checker(fan_list[num], "l", fan_mode)
## Assign the variable values if "By Feature"
elif by_layer_or_feature == "by_feature":
the_start_layer = self.getSettingValueByKey("feature_fan_start_layer") - 1
the_end_layer = self.getSettingValueByKey("feature_fan_end_layer")
try:
if int(the_end_layer) != -1:
## Catch a possible input error.
if the_end_layer < the_start_layer:
the_end_layer = the_start_layer
except:
the_end_layer = -1 ## If there is an input error default to the entire gcode file.
## Get the speed for each feature
feature_name_list = []
feature_speed_list = []
feature_speed_list.append(self._feature_checker(self.getSettingValueByKey("feature_fan_skirt"), fan_mode)); feature_name_list.append(";TYPE:SKIRT")
feature_speed_list.append(self._feature_checker(self.getSettingValueByKey("feature_fan_wall_inner"), fan_mode)); feature_name_list.append(";TYPE:WALL-INNER")
feature_speed_list.append(self._feature_checker(self.getSettingValueByKey("feature_fan_wall_outer"), fan_mode)); feature_name_list.append(";TYPE:WALL-OUTER")
feature_speed_list.append(self._feature_checker(self.getSettingValueByKey("feature_fan_fill"), fan_mode)); feature_name_list.append(";TYPE:FILL")
feature_speed_list.append(self._feature_checker(self.getSettingValueByKey("feature_fan_skin"), fan_mode)); feature_name_list.append(";TYPE:SKIN")
feature_speed_list.append(self._feature_checker(self.getSettingValueByKey("feature_fan_support"), fan_mode)); feature_name_list.append(";TYPE:SUPPORT")
feature_speed_list.append(self._feature_checker(self.getSettingValueByKey("feature_fan_support_interface"), fan_mode)); feature_name_list.append(";TYPE:SUPPORT-INTERFACE")
feature_speed_list.append(self._feature_checker(self.getSettingValueByKey("feature_fan_prime_tower"), fan_mode)); feature_name_list.append(";TYPE:PRIME-TOWER")
feature_speed_list.append(self._feature_checker(self.getSettingValueByKey("feature_fan_bridge"), fan_mode)); feature_name_list.append(";BRIDGE")
feature_speed_list.append(self._feature_checker(self.getSettingValueByKey("feature_fan_feature_final"), fan_mode)); feature_name_list.append("FINAL_FAN")
feature_fan_combing = self.getSettingValueByKey("feature_fan_combing")
if the_end_layer > -1 and by_layer_or_feature == "by_feature":
## Required so the final speed input can be determined
the_end_is_enabled = True
else:
## There is no ending layer so do the whole file
the_end_is_enabled = False
if the_end_layer == -1 or the_end_is_enabled == False:
the_end_layer = len(data) + 2
## Find the Layer0Index and the RaftIndex
raft_start_index = 0
number_of_raft_layers = 0
layer_0_index = 0
## Catch the number of raft layers.
for l_num in range(1,10,1):
layer = data[l_num]
if ";LAYER:-" in layer:
number_of_raft_layers += 1
if raft_start_index == 0:
raft_start_index = l_num
if ";LAYER:0" in layer:
layer_0_index = l_num
break
## Is this a single extruder print on a multi-extruder printer? - get the correct fan number for the extruder being used.
if is_multi_fan:
T0_used = False
T1_used = False
T2_used = False
T3_used = False
## Bypass the file header and ending gcode.
for num in range(1,len(data)-1,1):
lines = data[num]
if "T0" in lines:
T0_used = True
if "T1" in lines:
T1_used = True
if "T2" in lines:
T2_used = True
if "T3" in lines:
T3_used = True
is_multi_extr_print = True if sum([T0_used, T1_used, T2_used, T3_used]) > 1 else False
## On a multi-extruder printer and single extruder print find out which extruder starts the file.
init_fan = t0_fan
if not is_multi_extr_print:
startup = data[1]
lines = startup.split("\n")
for line in lines:
if line == "T1":
t0_fan = t1_fan
elif line == "T2":
t0_fan = t2_fan
elif line == "T3":
t0_fan = t3_fan
elif is_multi_extr_print:
## On a multi-extruder printer and multi extruder print find out which extruder starts the file.
startup = data[1]
lines = startup.split("\n")
for line in lines:
if line == "T0":
init_fan = t0_fan
elif line == "T1":
init_fan = t1_fan
elif line == "T2":
init_fan = t2_fan
elif line == "T3":
init_fan = t3_fan
else:
init_fan = ""
## Assign the variable values if "Raft Enabled"
raft_enabled = self.getSettingValueByKey("fan_enable_raft")
if raft_enabled and bed_adhesion == "raft":
fan_sp_raft = self._feature_checker(self.getSettingValueByKey("fan_raft_percent"), fan_mode)
else:
fan_sp_raft = "M106 S0"
# Start to alter the data-----------------------------------------
## Strip the existing M106 lines from the file up to the end of the last layer. If a user wants to use more than one instance of this plugin then they won't want to erase the M106 lines that the preceding plugins inserted so 'delete_existing_m106' is an option.
delete_existing_m106 = self.getSettingValueByKey("delete_existing_m106")
if delete_existing_m106:
## Start deleting from the beginning
start_from = int(raft_start_index)
else:
if by_layer_or_feature == "by_layer":
altered_start_layer = str(len(data))
## The fan list layers don't need to be in ascending order. Get the lowest.
for num in range(0,15,2):
try:
if int(fan_list[num]) < int(altered_start_layer):
altered_start_layer = int(fan_list[num])
except:
pass
elif by_layer_or_feature == "by_feature":
altered_start_layer = int(the_start_layer) - 1
start_from = int(layer_0_index) + int(altered_start_layer)
## Strip the M106 and M107 lines from the file
for l_index in range(int(start_from), len(data) - 1, 1):
data[l_index] = re.sub(re.compile("M106(.*)\n"), "", data[l_index])
data[l_index] = re.sub(re.compile("M107(.*)\n"), "", data[l_index])
## Deal with a raft and with One-At-A-Time print sequence
if raft_enabled and bed_adhesion == "raft":
if print_sequence == "one_at_a_time":
for r_index in range(2,len(data)-2,1):
lines = data[r_index].split("\n")
if not raft_enabled or bed_adhesion != "raft":
if ";LAYER:0" in data[r_index] or ";LAYER:-" in data[r_index]:
lines.insert(1, "M106 S0" + str(t0_fan))
if raft_enabled and bed_adhesion == "raft":
if ";LAYER:-" in data[r_index]:
## Turn the raft fan on
lines.insert(1, fan_sp_raft + str(t0_fan))
## Shut the raft fan off at layer 0
if ";LAYER:0" in data[r_index]:
lines.insert(1,"M106 S0" + str(t0_fan))
data[r_index] = "\n".join(lines)
elif print_sequence == "all_at_once":
layer = data[raft_start_index]
lines = layer.split("\n")
if ";LAYER:-" in layer:
## Turn the raft fan on
lines.insert(1, fan_sp_raft + str(init_fan))
layer = "\n".join(lines)
data[raft_start_index] = layer
layer = data[layer_0_index]
lines = layer.split("\n")
## Shut the raft fan off
lines.insert(1, "M106 S0" + str(init_fan))
data[layer_0_index] = "\n".join(lines)
else:
for r_index in range(2,len(data)-2,1):
lines = data[r_index].split("\n")
if ";LAYER:0" in data[r_index] or ";LAYER:-" in data[r_index]:
if not "0" in fan_list:
lines.insert(1, "M106 S0" + str(t0_fan))
data[r_index] = "\n".join(lines)
## Turn off all fans at the end of data[1]. If more than one instance of this script is running then this will result in multiple M106 lines.
temp_startup = data[1].split("\n")
temp_startup.insert(len(temp_startup)-2,"M106 S0" + str(t0_fan))
## If there are multiple cooling fans shut them all off
if is_multi_fan:
if extruder_count > 1 and t1_fan != t0_fan: temp_startup.insert(len(temp_startup)-2,"M106 S0" + str(t1_fan))
if extruder_count > 2 and t2_fan != t1_fan and t2_fan != t0_fan: temp_startup.insert(len(temp_startup)-2,"M106 S0" + str(t2_fan))
if extruder_count > 3 and t3_fan != t2_fan and t3_fan != t1_fan and t3_fan != t0_fan: temp_startup.insert(len(temp_startup)-2,"M106 S0" + str(t3_fan))
data[1] = "\n".join(temp_startup)
## If 'feature_fan_combing' is True then add additional 'MESH:NONMESH' lines for travel moves over 5 lines long
## For compatibility with 5.3.0 change any MESH:NOMESH to MESH:NONMESH.
if feature_fan_combing:
for layer_num in range(2,len(data)):
layer = data[layer_num]
data[layer_num] = re.sub(";MESH:NOMESH", ";MESH:NONMESH", layer)
data = self._add_travel_comment(data, layer_0_index)
# Single Fan "By Layer"--------------------------------------------
if by_layer_or_feature == "by_layer" and not is_multi_fan:
return self._single_fan_by_layer(data, layer_0_index, fan_list, t0_fan)
# Multi-Fan "By Layer"---------------------------------------------
if by_layer_or_feature == "by_layer" and is_multi_fan:
return self._multi_fan_by_layer(data, layer_0_index, fan_list, t0_fan, t1_fan, t2_fan, t3_fan)
#Single Fan "By Feature"------------------------------------------
if by_layer_or_feature == "by_feature" and (not is_multi_fan or not is_multi_extr_print):
return self._single_fan_by_feature(data, layer_0_index, the_start_layer, the_end_layer, the_end_is_enabled, fan_list, t0_fan, feature_speed_list, feature_name_list, feature_fan_combing)
#Multi Fan "By Feature"-------------------------------------------
if by_layer_or_feature == "by_feature" and is_multi_fan:
return self._multi_fan_by_feature(data, layer_0_index, the_start_layer, the_end_layer, the_end_is_enabled, fan_list, t0_fan, t1_fan, t2_fan, t3_fan, feature_speed_list, feature_name_list, feature_fan_combing)
# The Single Fan "By Layer"----------------------------------------
def _single_fan_by_layer(self, data: str, layer_0_index: int, fan_list: str, t0_fan: str)->str:
layer_number = "0"
single_fan_data = data
for l_index in range(layer_0_index,len(single_fan_data)-1,1):
layer = single_fan_data[l_index]
fan_lines = layer.split("\n")
for fan_line in fan_lines:
if ";LAYER:" in fan_line:
layer_number = str(fan_line.split(":")[1])
## If there is a match for the current layer number make the insertion
for num in range(0,15,2):
if layer_number == str(fan_list[num]):
layer = layer.replace(fan_lines[0],fan_lines[0] + "\n" + fan_list[num + 1] + str(t0_fan))
single_fan_data[l_index] = layer
return single_fan_data
# Multi-Fan "By Layer"-----------------------------------------
def _multi_fan_by_layer(self, data: str, layer_0_index: int, fan_list: str, t0_fan: str, t1_fan: str, t2_fan: str, t3_fan: str)->str:
multi_fan_data = data
layer_number = "0"
current_fan_speed = "0"
prev_fan = str(t0_fan)
this_fan = str(t0_fan)
start_index = str(len(multi_fan_data))
for num in range(0,15,2):
## The fan_list may not be in ascending order. Get the lowest layer number
try:
if int(fan_list[num]) < int(start_index):
start_index = str(fan_list[num])
except:
pass
## Move the start point if delete_existing_m106 is false
start_index = int(start_index) + int(layer_0_index)
## Track the tool number
for num in range(1,int(start_index),1):
layer = multi_fan_data[num]
lines = layer.split("\n")
for line in lines:
if line == "T0":
prev_fan = this_fan
this_fan = t0_fan
elif line == "T1":
prev_fan = this_fan
this_fan = t1_fan
elif line == "T2":
prev_fan = this_fan
this_fan = t2_fan
elif line == "T3":
prev_fan = this_fan
this_fan = t3_fan
for l_index in range(int(start_index),len(multi_fan_data)-1,1):
modified_data = ""
layer = multi_fan_data[l_index]
fan_lines = layer.split("\n")
for fan_line in fan_lines:
## Prepare to shut down the previous fan and start the next one.
if fan_line.startswith("T"):
if fan_line == "T0": this_fan = str(t0_fan)
if fan_line == "T1": this_fan = str(t1_fan)
if fan_line == "T2": this_fan = str(t2_fan)
if fan_line == "T3": this_fan = str(t3_fan)
modified_data += "M106 S0" + prev_fan + "\n"
modified_data += fan_line + "\n"
modified_data += "M106 S" + str(current_fan_speed) + this_fan + "\n"
prev_fan = this_fan
elif ";LAYER:" in fan_line:
modified_data += fan_line + "\n"
layer_number = str(fan_line.split(":")[1])
for num in range(0,15,2):
if layer_number == str(fan_list[num]):
modified_data += fan_list[num + 1] + this_fan + "\n"
current_fan_speed = str(fan_list[num + 1].split("S")[1])
current_fan_speed = str(current_fan_speed.split(" ")[0]) ## Just in case
else:
modified_data += fan_line + "\n"
if modified_data.endswith("\n"): modified_data = modified_data[0:-1]
multi_fan_data[l_index] = modified_data
return multi_fan_data
# Single fan by feature-----------------------------------------------
def _single_fan_by_feature(self, data: str, layer_0_index: int, the_start_layer: str, the_end_layer: str, the_end_is_enabled: str, fan_list: str, t0_fan: str, feature_speed_list: str, feature_name_list: str, feature_fan_combing: bool)->str:
single_fan_data = data
layer_number = "0"
index = 1
## Start with layer:0
for l_index in range(layer_0_index,len(single_fan_data)-1,1):
modified_data = ""
layer = single_fan_data[l_index]
lines = layer.split("\n")
for line in lines:
if ";LAYER:" in line:
layer_number = str(line.split(":")[1])
if int(layer_number) >= int(the_start_layer) and int(layer_number) < int(the_end_layer)-1:
temp = line.split(" ")[0]
try:
name_index = feature_name_list.index(temp)
except:
name_index = -1
if name_index != -1:
modified_data += feature_speed_list[name_index] + t0_fan + "\n"
elif ";MESH:NONMESH" in line:
if feature_fan_combing == True:
modified_data += "M106 S0" + t0_fan + "\n"
modified_data += line + "\n"
## If an End Layer is defined and is less than the last layer then insert the Final Speed
if line == ";LAYER:" + str(the_end_layer) and the_end_is_enabled == True:
modified_data += feature_speed_list[len(feature_speed_list) - 1] + t0_fan + "\n"
if modified_data.endswith("\n"): modified_data = modified_data[0: - 1]
single_fan_data[l_index] = modified_data
return single_fan_data
# Multi-fan by feature------------------------------------------------
def _multi_fan_by_feature(self, data: str, layer_0_index: int, the_start_layer: str, the_end_layer: str, the_end_is_enabled: str, fan_list: str, t0_fan: str, t1_fan: str, t2_fan: str, t3_fan: str, feature_speed_list: str, feature_name_list: str, feature_fan_combing: bool)->str:
multi_fan_data = data
layer_number = "0"
start_index = 1
prev_fan = t0_fan
this_fan = t0_fan
modified_data = ""
current_fan_speed = "0"
for my_index in range(1, len(multi_fan_data) - 1, 1):
layer = multi_fan_data[my_index]
if ";LAYER:" + str(the_start_layer) + "\n" in layer:
start_index = int(my_index) - 1
break
## Track the previous tool changes
for num in range(1,start_index,1):
layer = multi_fan_data[num]
lines = layer.split("\n")
for line in lines:
if line == "T0":
prev_fan = this_fan
this_fan = t0_fan
elif line == "T1":
prev_fan = this_fan
this_fan = t1_fan
elif line == "T2":
prev_fan = this_fan
this_fan = t2_fan
elif line == "T3":
prev_fan = this_fan
this_fan = t3_fan
## Get the current tool.
for l_index in range(start_index,start_index + 1,1):
layer = multi_fan_data[l_index]
lines = layer.split("\n")
for line in lines:
if line.startswith("T"):
if line == "T0": this_fan = t0_fan
if line == "T1": this_fan = t1_fan
if line == "T2": this_fan = t2_fan
if line == "T3": this_fan = t3_fan
prev_fan = this_fan
## Start to make insertions-------------------------------------
for l_index in range(start_index+1,len(multi_fan_data)-1,1):
layer = multi_fan_data[l_index]
lines = layer.split("\n")
for line in lines:
if line.startswith("T"):
if line == "T0": this_fan = t0_fan
if line == "T1": this_fan = t1_fan
if line == "T2": this_fan = t2_fan
if line == "T3": this_fan = t3_fan
## Turn off the prev fan
modified_data += "M106 S0" + prev_fan + "\n"
modified_data += line + "\n"
## Turn on the current fan
modified_data += "M106 S" + str(current_fan_speed) + this_fan + "\n"
prev_fan = this_fan
if ";LAYER:" in line:
layer_number = str(line.split(":")[1])
modified_data += line + "\n"
if int(layer_number) >= int(the_start_layer):
temp = line.split(" ")[0]
try:
name_index = feature_name_list.index(temp)
except:
name_index = -1
if name_index != -1:
modified_data += line + "\n" + feature_speed_list[name_index] + this_fan + "\n"
#modified_data += feature_speed_list[name_index] + this_fan + "\n"
current_fan_speed = str(feature_speed_list[name_index].split("S")[1])
elif ";MESH:NONMESH" in line:
if feature_fan_combing == True:
modified_data += line + "\n"
modified_data += "M106 S0" + this_fan + "\n"
current_fan_speed = "0"
else:
modified_data += line + "\n"
## If an end layer is defined - Insert the final speed and set the other variables to Final Speed to finish the file
## There cannot be a break here because if there are multiple fan numbers they still need to be shut off and turned on.
elif line == ";LAYER:" + str(the_end_layer):
modified_data += feature_speed_list[len(feature_speed_list) - 1] + this_fan + "\n"
for set_speed in range(0, len(feature_speed_list) - 2):
feature_speed_list[set_speed] = feature_speed_list[len(feature_speed_list) - 1]
else:
## Layer and Tool get inserted into modified_data above. All other lines go into modified_data here
if not line.startswith("T") and not line.startswith(";LAYER:"): modified_data += line + "\n"
if modified_data.endswith("\n"): modified_data = modified_data[0: - 1]
multi_fan_data[l_index] = modified_data
modified_data = ""
return multi_fan_data
#Try to catch layer input errors, set the minimum speed to 12%, and put the strings together
def _layer_checker(self, fan_string: str, ty_pe: str, fan_mode: bool) -> str:
fan_string_l = str(fan_string.split("/")[0])
try:
if int(fan_string_l) <= 1: fan_string_l = "1"
if fan_string_l == "": fan_string_l = str(len(data))
except ValueError:
fan_string_l = str(len(data))
fan_string_l = str(int(fan_string_l) - 1)
fan_string_p = str(fan_string.split("/")[1])
if fan_string_p == "": fan_string_p = "0"
try:
if int(fan_string_p) < 0: fan_string_p = "0"
if int(fan_string_p) > 100: fan_string_p = "100"
except ValueError:
fan_string_p = "0"
## Set the minimum fan speed to 12%
if int(fan_string_p) < 12 and int(fan_string_p) != 0:
fan_string_p = "12"
fan_layer_line = str(fan_string_l)
if fan_mode:
fan_percent_line = "M106 S" + str(round(int(fan_string_p) * 2.55))
else:
fan_percent_line = "M106 S" + str(round(int(fan_string_p) / 100, 1))
if ty_pe == "l":
return str(fan_layer_line)
elif ty_pe == "p":
return fan_percent_line
#Try to catch feature input errors, set the minimum speed to 12%, and put the strings together when 'By Feature'
def _feature_checker(self, fan_feat_string: int, fan_mode: bool) -> str:
if fan_feat_string < 0: fan_feat_string = 0
## Set the minimum fan speed to 12%
if fan_feat_string > 0 and fan_feat_string < 12: fan_feat_string = 12
if fan_feat_string > 100: fan_feat_string = 100
if fan_mode:
fan_sp_feat = "M106 S" + str(round(fan_feat_string * 2.55))
else:
fan_sp_feat = "M106 S" + str(round(fan_feat_string / 100, 1))
return fan_sp_feat
# Add additional travel comments to turn the fan off during combing.
def _add_travel_comment(self, comment_data: str, lay_0_index: str) -> str:
for lay_num in range(int(lay_0_index), len(comment_data)-1,1):
layer = comment_data[lay_num]
lines = layer.split("\n")
## Copy the data to new_data and make the insertions there
new_data = lines
g0_count = 0
g0_index = -1
feature_type = ";TYPE:SUPPORT"
is_travel = False
for index, line in enumerate(lines):
insert_index = 0
if ";TYPE:" in line:
feature_type = line
is_travel = False
g0_count = 0
if ";MESH:NONMESH" in line:
is_travel = True
g0_count = 0
if line.startswith("G0 ") and not is_travel:
g0_count += 1
if g0_index == -1:
g0_index = lines.index(line)
elif not line.startswith("G0 ") and not is_travel:
## Add additional 'NONMESH' lines to shut the fan off during long combing moves--------
if g0_count > 5:
if not is_travel:
new_data.insert(g0_index + insert_index, ";MESH:NONMESH")
insert_index += 1
## Add the feature_type at the end of the combing move to turn the fan back on
new_data.insert(g0_index + g0_count + 1, feature_type)
insert_index += 1
g0_count = 0
g0_index = -1
is_travel = False
elif g0_count <= 5:
g0_count = 0
g0_index = -1
is_travel = False
comment_data[lay_num] = "\n".join(new_data)
return comment_data

View file

@ -21,7 +21,7 @@
# M163 - Set Mix Factor # M163 - Set Mix Factor
# M164 - Save Mix - saves to T2 as a unique mix # M164 - Save Mix - saves to T2 as a unique mix
import re #To perform the search and replace. import re # To perform the search and replace.
from ..Script import Script from ..Script import Script
class ColorMix(Script): class ColorMix(Script):

View file

@ -6,7 +6,6 @@
# Description: This plugin is now an option in 'Display Info on LCD' # Description: This plugin is now an option in 'Display Info on LCD'
from ..Script import Script from ..Script import Script
from UM.Application import Application
from UM.Message import Message from UM.Message import Message
class DisplayFilenameAndLayerOnLCD(Script): class DisplayFilenameAndLayerOnLCD(Script):

View file

@ -30,9 +30,6 @@
from ..Script import Script from ..Script import Script
from UM.Application import Application from UM.Application import Application
from UM.Qt.Duration import DurationFormat from UM.Qt.Duration import DurationFormat
import UM.Util
import configparser
from UM.Preferences import Preferences
import time import time
import datetime import datetime
import math import math

View file

@ -7,8 +7,6 @@
from ..Script import Script from ..Script import Script
import re
import datetime
from UM.Message import Message from UM.Message import Message
class DisplayProgressOnLCD(Script): class DisplayProgressOnLCD(Script):

View file

@ -7,7 +7,7 @@
from typing import List from typing import List
from ..Script import Script from ..Script import Script
from UM.Application import Application #To get the current printer's settings. from UM.Application import Application # To get the current printer's settings.
class FilamentChange(Script): class FilamentChange(Script):

View file

@ -9,6 +9,7 @@
# When setting an accel limit on multi-extruder printers ALL extruders are effected. # When setting an accel limit on multi-extruder printers ALL extruders are effected.
# This post does not distinguish between Print Accel and Travel Accel. The limit is the limit for all regardless. Example: Skin Accel = 1000 and Outer Wall accel = 500. If the limit is set to 300 then both Skin and Outer Wall will be Accel = 300. # This post does not distinguish between Print Accel and Travel Accel. The limit is the limit for all regardless. Example: Skin Accel = 1000 and Outer Wall accel = 500. If the limit is set to 300 then both Skin and Outer Wall will be Accel = 300.
# 9/15/2023 added support for RepRap M566 command for Jerk in mm/min # 9/15/2023 added support for RepRap M566 command for Jerk in mm/min
# 2/4/2024 Added a block so the script doesn't run unless Accel Control is enabled in Cura. This should keep users from increasing the Accel Limits.
from ..Script import Script from ..Script import Script
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
@ -46,6 +47,10 @@ class LimitXYAccelJerk(Script):
if ext_count > 1: if ext_count > 1:
Message(text = "<NOTICE> 'Limit the X-Y Accel/Jerk': The post processor treats all extruders the same. If you have multiple extruders they will all be subject to the same Accel and Jerk limits imposed. If you have different Travel and Print Accel they will also be subject to the same limits. If that is not acceptable then you should not use this Post Processor.").show() Message(text = "<NOTICE> 'Limit the X-Y Accel/Jerk': The post processor treats all extruders the same. If you have multiple extruders they will all be subject to the same Accel and Jerk limits imposed. If you have different Travel and Print Accel they will also be subject to the same limits. If that is not acceptable then you should not use this Post Processor.").show()
# Warn the user if Accel Control is not enabled in Cura. This keeps the script from being able to increase the Accel limits-----------
if not bool(extruder[0].getProperty("acceleration_enabled", "value")):
Message(title = "[Limit the X-Y Accel/Jerk]", text = "You must have 'Enable Acceleration Control' checked in Cura or the script will exit.").show()
def getSettingDataString(self): def getSettingDataString(self):
return """{ return """{
"name": "Limit the X-Y Accel/Jerk (all extruders equal)", "name": "Limit the X-Y Accel/Jerk (all extruders equal)",
@ -169,6 +174,13 @@ class LimitXYAccelJerk(Script):
extruder = mycura.extruderList extruder = mycura.extruderList
machine_name = str(mycura.getProperty("machine_name", "value")) machine_name = str(mycura.getProperty("machine_name", "value"))
print_sequence = str(mycura.getProperty("print_sequence", "value")) print_sequence = str(mycura.getProperty("print_sequence", "value"))
acceleration_enabled = bool(extruder[0].getProperty("acceleration_enabled", "value"))
# Exit if acceleration control is not enabled----------------
if not acceleration_enabled:
Message(title = "[Limit the X-Y Accel/Jerk]", text = "DID NOT RUN. You must have 'Enable Acceleration Control' checked in Cura.").show()
data[0] += "; [LimitXYAccelJerk] DID NOT RUN because 'Enable Acceleration Control' is not checked in Cura.\n"
return data
# Exit if 'one_at_a_time' is enabled------------------------- # Exit if 'one_at_a_time' is enabled-------------------------
if print_sequence == "one_at_a_time": if print_sequence == "one_at_a_time":
@ -183,12 +195,8 @@ class LimitXYAccelJerk(Script):
return data return data
type_of_change = str(self.getSettingValueByKey("type_of_change")) type_of_change = str(self.getSettingValueByKey("type_of_change"))
accel_print_enabled = bool(extruder[0].getProperty("acceleration_enabled", "value"))
accel_travel_enabled = bool(extruder[0].getProperty("acceleration_travel_enabled", "value"))
accel_print = extruder[0].getProperty("acceleration_print", "value") accel_print = extruder[0].getProperty("acceleration_print", "value")
accel_travel = extruder[0].getProperty("acceleration_travel", "value") accel_travel = extruder[0].getProperty("acceleration_travel", "value")
jerk_print_enabled = str(extruder[0].getProperty("jerk_enabled", "value"))
jerk_travel_enabled = str(extruder[0].getProperty("jerk_travel_enabled", "value"))
jerk_print_old = extruder[0].getProperty("jerk_print", "value") jerk_print_old = extruder[0].getProperty("jerk_print", "value")
jerk_travel_old = extruder[0].getProperty("jerk_travel", "value") jerk_travel_old = extruder[0].getProperty("jerk_travel", "value")
if int(accel_print) >= int(accel_travel): if int(accel_print) >= int(accel_travel):

View file

@ -7,7 +7,7 @@
from ..Script import Script from ..Script import Script
import re import re
from UM.Application import Application #To get the current printer's settings. from UM.Application import Application # To get the current printer's settings.
from UM.Logger import Logger from UM.Logger import Logger
from typing import List, Tuple from typing import List, Tuple

View file

@ -1,7 +1,7 @@
# Copyright (c) 2017 Ghostkeeper # Copyright (c) 2017 Ghostkeeper
# The PostProcessingPlugin is released under the terms of the LGPLv3 or higher. # The PostProcessingPlugin is released under the terms of the LGPLv3 or higher.
import re #To perform the search and replace. import re # To perform the search and replace.
from ..Script import Script from ..Script import Script

View file

@ -8,7 +8,7 @@ from UM.Application import Application
from UM.Logger import Logger from UM.Logger import Logger
from UM.Message import Message from UM.Message import Message
from UM.FileHandler.WriteFileJob import WriteFileJob from UM.FileHandler.WriteFileJob import WriteFileJob
from UM.FileHandler.FileWriter import FileWriter #To check against the write modes (text vs. binary). from UM.FileHandler.FileWriter import FileWriter # To check against the write modes (text vs. binary).
from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator
from UM.OutputDevice.OutputDevice import OutputDevice from UM.OutputDevice.OutputDevice import OutputDevice
from UM.OutputDevice import OutputDeviceError from UM.OutputDevice import OutputDeviceError

View file

@ -121,6 +121,7 @@ class SimulationPass(RenderPass):
disabled_batch = RenderBatch(self._disabled_shader) disabled_batch = RenderBatch(self._disabled_shader)
head_position = None # Indicates the current position of the print head head_position = None # Indicates the current position of the print head
nozzle_node = None nozzle_node = None
not_a_vector = Vector(math.nan, math.nan, math.nan)
for node in DepthFirstIterator(self._scene.getRoot()): for node in DepthFirstIterator(self._scene.getRoot()):
@ -143,12 +144,17 @@ class SimulationPass(RenderPass):
if self._layer_view.getCurrentLayer() > -1 and ((not self._layer_view._only_show_top_layers) or (not self._layer_view.getCompatibilityMode())): if self._layer_view.getCurrentLayer() > -1 and ((not self._layer_view._only_show_top_layers) or (not self._layer_view.getCompatibilityMode())):
start = 0 start = 0
end = 0 end = 0
vertex_before_head = not_a_vector
vertex_after_head = not_a_vector
vertex_distance_ratio = 0.0
towards_next_vertex = 0
element_counts = layer_data.getElementCounts() element_counts = layer_data.getElementCounts()
for layer in sorted(element_counts.keys()): for layer in sorted(element_counts.keys()):
# In the current layer, we show just the indicated paths # In the current layer, we show just the indicated paths
if layer == self._layer_view._current_layer_num: if layer == self._layer_view._current_layer_num:
# We look for the position of the head, searching the point of the current path # We look for the position of the head, searching the point of the current path
index = int(self._layer_view.getCurrentPath()) index = int(self._layer_view.getCurrentPath()) if not math.isnan(
self._layer_view.getCurrentPath()) else 0
for polygon in layer_data.getLayer(layer).polygons: for polygon in layer_data.getLayer(layer).polygons:
# The size indicates all values in the two-dimension array, and the second dimension is # The size indicates all values in the two-dimension array, and the second dimension is
# always size 3 because we have 3D points. # always size 3 because we have 3D points.
@ -159,6 +165,8 @@ class SimulationPass(RenderPass):
ratio = self._layer_view.getCurrentPath() - math.floor(self._layer_view.getCurrentPath()) ratio = self._layer_view.getCurrentPath() - math.floor(self._layer_view.getCurrentPath())
pos_a = Vector(polygon.data[index][0], polygon.data[index][1], pos_a = Vector(polygon.data[index][0], polygon.data[index][1],
polygon.data[index][2]) polygon.data[index][2])
vertex_before_head = pos_a
vertex_distance_ratio = ratio
if ratio <= 0.0001 or index + 1 == len(polygon.data): if ratio <= 0.0001 or index + 1 == len(polygon.data):
# in case there multiple polygons and polygon changes, the first point has the same value as the last point in the previous polygon # in case there multiple polygons and polygon changes, the first point has the same value as the last point in the previous polygon
head_position = pos_a + node.getWorldPosition() head_position = pos_a + node.getWorldPosition()
@ -168,6 +176,8 @@ class SimulationPass(RenderPass):
polygon.data[index + 1][2]) polygon.data[index + 1][2])
vec = pos_a * (1.0 - ratio) + pos_b * ratio vec = pos_a * (1.0 - ratio) + pos_b * ratio
head_position = vec + node.getWorldPosition() head_position = vec + node.getWorldPosition()
vertex_after_head = pos_b
towards_next_vertex = 2 # Add two to the index to print the current and next vertices as an 'unfinished' line (to the nozzle).
break break
break break
if self._layer_view.getMinimumLayer() > layer: if self._layer_view.getMinimumLayer() > layer:
@ -187,6 +197,11 @@ class SimulationPass(RenderPass):
self._current_shader = self._layer_shader self._current_shader = self._layer_shader
self._switching_layers = True self._switching_layers = True
# reset 'last vertex'
self._layer_shader.setUniformValue("u_last_vertex", not_a_vector)
self._layer_shader.setUniformValue("u_next_vertex", not_a_vector)
self._layer_shader.setUniformValue("u_last_line_ratio", 1.0)
# The first line does not have a previous line: add a MoveCombingType in front for start detection # The first line does not have a previous line: add a MoveCombingType in front for start detection
# this way the first start of the layer can also be drawn # this way the first start of the layer can also be drawn
prev_line_types = numpy.concatenate([numpy.asarray([LayerPolygon.MoveCombingType], dtype = numpy.float32), layer_data._attributes["line_types"]["value"]]) prev_line_types = numpy.concatenate([numpy.asarray([LayerPolygon.MoveCombingType], dtype = numpy.float32), layer_data._attributes["line_types"]["value"]])
@ -203,6 +218,17 @@ class SimulationPass(RenderPass):
current_layer_batch.addItem(node.getWorldTransformation(), layer_data) current_layer_batch.addItem(node.getWorldTransformation(), layer_data)
current_layer_batch.render(self._scene.getActiveCamera()) current_layer_batch.render(self._scene.getActiveCamera())
# Last line may be partial
if vertex_after_head != not_a_vector and vertex_after_head != not_a_vector:
self._layer_shader.setUniformValue("u_last_vertex", vertex_before_head)
self._layer_shader.setUniformValue("u_next_vertex", vertex_after_head)
self._layer_shader.setUniformValue("u_last_line_ratio", vertex_distance_ratio)
last_line_start = current_layer_end
last_line_end = current_layer_end + towards_next_vertex
last_line_batch = RenderBatch(self._layer_shader, type = RenderBatch.RenderType.Solid, mode=RenderBatch.RenderMode.Lines, range = (last_line_start, last_line_end))
last_line_batch.addItem(node.getWorldTransformation(), layer_data)
last_line_batch.render(self._scene.getActiveCamera())
self._old_current_layer = self._layer_view.getCurrentLayer() self._old_current_layer = self._layer_view.getCurrentLayer()
self._old_current_path = self._layer_view.getCurrentPath() self._old_current_path = self._layer_view.getCurrentPath()

View file

@ -1,5 +1,6 @@
# Copyright (c) 2021 Ultimaker B.V. # Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import math
import sys import sys
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
@ -97,7 +98,8 @@ class SimulationView(CuraView):
self._min_line_width = sys.float_info.max self._min_line_width = sys.float_info.max
self._min_flow_rate = sys.float_info.max self._min_flow_rate = sys.float_info.max
self._max_flow_rate = sys.float_info.min self._max_flow_rate = sys.float_info.min
self._cumulative_line_duration = {} self._cumulative_line_duration_layer: Optional[int] = None
self._cumulative_line_duration: List[float] = []
self._global_container_stack: Optional[ContainerStack] = None self._global_container_stack: Optional[ContainerStack] = None
self._proxy = None self._proxy = None
@ -196,7 +198,7 @@ class SimulationView(CuraView):
if len(cumulative_line_duration) > 0: if len(cumulative_line_duration) > 0:
self._current_time = time self._current_time = time
left_i = 0 left_i = 0
right_i = self._max_paths - 1 right_i = len(cumulative_line_duration) - 1
total_duration = cumulative_line_duration[-1] total_duration = cumulative_line_duration[-1]
# make an educated guess about where to start # make an educated guess about where to start
i = int(right_i * max(0.0, min(1.0, self._current_time / total_duration))) i = int(right_i * max(0.0, min(1.0, self._current_time / total_duration)))
@ -212,9 +214,11 @@ class SimulationView(CuraView):
right_value = cumulative_line_duration[i] right_value = cumulative_line_duration[i]
if not (left_value <= self._current_time <= right_value): if not (left_value <= self._current_time <= right_value):
Logger.debug(f"At index {i}: left value {left_value} right value {right_value} and current time is {self._current_time}") Logger.warn(
f"Binary search error (out of bounds): index {i}: left value {left_value} right value {right_value} and current time is {self._current_time}")
fractional_value = (self._current_time - left_value) / (right_value - left_value) segment_duration = right_value - left_value
fractional_value = 0.0 if segment_duration == 0.0 else (self._current_time - left_value) / segment_duration
self.setPath(i + fractional_value) self.setPath(i + fractional_value)
@ -245,21 +249,22 @@ class SimulationView(CuraView):
def cumulativeLineDuration(self) -> List[float]: def cumulativeLineDuration(self) -> List[float]:
# Make sure _cumulative_line_duration is initialized properly # Make sure _cumulative_line_duration is initialized properly
if self.getCurrentLayer() not in self._cumulative_line_duration: if self.getCurrentLayer() != self._cumulative_line_duration_layer:
#clear cache #clear cache
self._cumulative_line_duration = {} self._cumulative_line_duration = []
self._cumulative_line_duration[self.getCurrentLayer()] = []
total_duration = 0.0 total_duration = 0.0
polylines = self.getLayerData() polylines = self.getLayerData()
if polylines is not None: if polylines is not None:
for polyline in polylines.polygons: for polyline in polylines.polygons:
for line_duration in list((polyline.lineLengths / polyline.lineFeedrates)[0]): for line_duration in list((polyline.lineLengths / polyline.lineFeedrates)[0]):
total_duration += line_duration / SimulationView.SIMULATION_FACTOR total_duration += line_duration / SimulationView.SIMULATION_FACTOR
self._cumulative_line_duration[self.getCurrentLayer()].append(total_duration) self._cumulative_line_duration.append(total_duration)
# for tool change we add an extra tool path # for tool change we add an extra tool path
self._cumulative_line_duration[self.getCurrentLayer()].append(total_duration) self._cumulative_line_duration.append(total_duration)
# set current cached layer
self._cumulative_line_duration_layer = self.getCurrentLayer()
return self._cumulative_line_duration[self.getCurrentLayer()] return self._cumulative_line_duration
def getLayerData(self) -> Optional["LayerData"]: def getLayerData(self) -> Optional["LayerData"]:
scene = self.getController().getScene() scene = self.getController().getScene()
@ -369,7 +374,10 @@ class SimulationView(CuraView):
self._minimum_path_num = min(self._minimum_path_num, self._current_path_num) self._minimum_path_num = min(self._minimum_path_num, self._current_path_num)
# update _current time when the path is changed by user # update _current time when the path is changed by user
if self._current_path_num < self._max_paths and round(self._current_path_num)== self._current_path_num: if self._current_path_num < self._max_paths and round(self._current_path_num)== self._current_path_num:
self._current_time = self.cumulativeLineDuration()[int(self._current_path_num)] actual_path_num = int(self._current_path_num)
cumulative_line_duration = self.cumulativeLineDuration()
if actual_path_num < len(cumulative_line_duration):
self._current_time = cumulative_line_duration[actual_path_num]
self._startUpdateTopLayers() self._startUpdateTopLayers()
self.currentPathNumChanged.emit() self.currentPathNumChanged.emit()

View file

@ -19,6 +19,10 @@ vertex41core =
uniform highp mat4 u_normalMatrix; uniform highp mat4 u_normalMatrix;
uniform vec3 u_last_vertex;
uniform vec3 u_next_vertex;
uniform float u_last_line_ratio;
in highp vec4 a_vertex; in highp vec4 a_vertex;
in lowp vec4 a_color; in lowp vec4 a_color;
in lowp vec4 a_material_color; in lowp vec4 a_material_color;
@ -134,6 +138,10 @@ vertex41core =
void main() void main()
{ {
vec4 v1_vertex = a_vertex; vec4 v1_vertex = a_vertex;
if (v1_vertex.xyz == u_next_vertex)
{
v1_vertex.xyz = mix(u_last_vertex, u_next_vertex, u_last_line_ratio);
}
v1_vertex.y -= a_line_dim.y / 2; // half layer down v1_vertex.y -= a_line_dim.y / 2; // half layer down
vec4 world_space_vert = u_modelMatrix * v1_vertex; vec4 world_space_vert = u_modelMatrix * v1_vertex;
@ -427,6 +435,10 @@ u_max_feedrate = 1
u_min_thickness = 0 u_min_thickness = 0
u_max_thickness = 1 u_max_thickness = 1
u_last_vertex = [0.0, 0.0, 0.0]
u_next_vertex = [0.0, 0.0, 0.0]
u_last_line_ratio = 1.0
[bindings] [bindings]
u_modelMatrix = model_matrix u_modelMatrix = model_matrix
u_viewMatrix = view_matrix u_viewMatrix = view_matrix

View file

@ -5,7 +5,7 @@ import json
import os import os
import platform import platform
import time import time
from typing import cast, Optional, Set, TYPE_CHECKING from typing import Optional, Set, TYPE_CHECKING
from PyQt6.QtCore import pyqtSlot, QObject from PyQt6.QtCore import pyqtSlot, QObject
from PyQt6.QtNetwork import QNetworkRequest from PyQt6.QtNetwork import QNetworkRequest
@ -264,6 +264,7 @@ class SliceInfo(QObject, Extension):
# Prime tower settings # Prime tower settings
print_settings["prime_tower_enable"] = global_stack.getProperty("prime_tower_enable", "value") print_settings["prime_tower_enable"] = global_stack.getProperty("prime_tower_enable", "value")
print_settings["prime_tower_mode"] = global_stack.getProperty("prime_tower_mode", "value")
# Infill settings # Infill settings
print_settings["infill_sparse_density"] = global_stack.getProperty("infill_sparse_density", "value") print_settings["infill_sparse_density"] = global_stack.getProperty("infill_sparse_density", "value")

View file

@ -16,8 +16,6 @@ from UM.Application import Application
from UM.Logger import Logger from UM.Logger import Logger
from UM.Message import Message from UM.Message import Message
from UM.Math.Color import Color from UM.Math.Color import Color
from UM.PluginRegistry import PluginRegistry
from UM.Platform import Platform
from UM.Event import Event from UM.Event import Event
from UM.View.RenderBatch import RenderBatch from UM.View.RenderBatch import RenderBatch

View file

@ -22,7 +22,6 @@ from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Scene.SceneNode import SceneNode from UM.Scene.SceneNode import SceneNode
from UM.Settings.InstanceContainer import InstanceContainer from UM.Settings.InstanceContainer import InstanceContainer
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
from cura.Settings.CuraStackBuilder import CuraStackBuilder
from cura.Settings.GlobalStack import GlobalStack from cura.Settings.GlobalStack import GlobalStack
from cura.Utils.Threading import call_on_qt_thread from cura.Utils.Threading import call_on_qt_thread

View file

@ -9,8 +9,8 @@ try:
except ImportError: except ImportError:
Logger.log("w", "Could not import UFPWriter; libCharon may be missing") Logger.log("w", "Could not import UFPWriter; libCharon may be missing")
from UM.i18n import i18nCatalog #To translate the file format description. from UM.i18n import i18nCatalog # To translate the file format description.
from UM.Mesh.MeshWriter import MeshWriter #For the binary mode flag. from UM.Mesh.MeshWriter import MeshWriter # For the binary mode flag.
i18n_catalog = i18nCatalog("cura") i18n_catalog = i18nCatalog("cura")

View file

@ -5,6 +5,7 @@ import urllib.parse
from json import JSONDecodeError from json import JSONDecodeError
from time import time from time import time
from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict, Any, cast from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict, Any, cast
from pathlib import Path
from PyQt6.QtCore import QUrl from PyQt6.QtCore import QUrl
from PyQt6.QtNetwork import QNetworkRequest, QNetworkReply from PyQt6.QtNetwork import QNetworkRequest, QNetworkReply
@ -38,14 +39,17 @@ class CloudApiClient:
# The cloud URL to use for this remote cluster. # The cloud URL to use for this remote cluster.
ROOT_PATH = UltimakerCloudConstants.CuraCloudAPIRoot ROOT_PATH = UltimakerCloudConstants.CuraCloudAPIRoot
CLUSTER_API_ROOT = "{}/connect/v1".format(ROOT_PATH) CLUSTER_API_ROOT = f"{ROOT_PATH}/connect/v1"
CURA_API_ROOT = "{}/cura/v1".format(ROOT_PATH) CURA_API_ROOT = f"{ROOT_PATH}/cura/v1"
DEFAULT_REQUEST_TIMEOUT = 10 # seconds DEFAULT_REQUEST_TIMEOUT = 10 # seconds
# In order to avoid garbage collection we keep the callbacks in this list. # In order to avoid garbage collection we keep the callbacks in this list.
_anti_gc_callbacks = [] # type: List[Callable[[Any], None]] _anti_gc_callbacks = [] # type: List[Callable[[Any], None]]
# Custom machine definition ID to cloud cluster name mapping
_machine_id_to_name: Dict[str, str] = None
def __init__(self, app: CuraApplication, on_error: Callable[[List[CloudError]], None]) -> None: def __init__(self, app: CuraApplication, on_error: Callable[[List[CloudError]], None]) -> None:
"""Initializes a new cloud API client. """Initializes a new cloud API client.
@ -73,10 +77,10 @@ class CloudApiClient:
url = f"{self.CLUSTER_API_ROOT}/clusters?status=active" url = f"{self.CLUSTER_API_ROOT}/clusters?status=active"
self._http.get(url, self._http.get(url,
scope = self._scope, scope=self._scope,
callback = self._parseCallback(on_finished, CloudClusterResponse, failed), callback=self._parseCallback(on_finished, CloudClusterResponse, failed),
error_callback = failed, error_callback=failed,
timeout = self.DEFAULT_REQUEST_TIMEOUT) timeout=self.DEFAULT_REQUEST_TIMEOUT)
def getClustersByMachineType(self, machine_type, on_finished: Callable[[List[CloudClusterWithConfigResponse]], Any], failed: Callable) -> None: def getClustersByMachineType(self, machine_type, on_finished: Callable[[List[CloudClusterWithConfigResponse]], Any], failed: Callable) -> None:
# HACK: There is something weird going on with the API, as it reports printer types in formats like # HACK: There is something weird going on with the API, as it reports printer types in formats like
@ -84,13 +88,9 @@ class CloudApiClient:
# conversion! # conversion!
# API points to "MakerBot Method" for a makerbot printertypes which we already changed to allign with other printer_type # API points to "MakerBot Method" for a makerbot printertypes which we already changed to allign with other printer_type
method_x = { machine_id_to_name = self.getMachineIDMap()
"ultimaker_method":"MakerBot Method", if machine_type in machine_id_to_name:
"ultimaker_methodx":"MakerBot Method X", machine_type = machine_id_to_name[machine_type]
"ultimaker_methodxl":"MakerBot Method XL"
}
if machine_type in method_x:
machine_type = method_x[machine_type]
else: else:
machine_type = machine_type.replace("_plus", "+") machine_type = machine_type.replace("_plus", "+")
machine_type = machine_type.replace("_", " ") machine_type = machine_type.replace("_", " ")
@ -114,9 +114,9 @@ class CloudApiClient:
url = f"{self.CLUSTER_API_ROOT}/clusters/{cluster_id}/status" url = f"{self.CLUSTER_API_ROOT}/clusters/{cluster_id}/status"
self._http.get(url, self._http.get(url,
scope = self._scope, scope=self._scope,
callback = self._parseCallback(on_finished, CloudClusterStatus), callback=self._parseCallback(on_finished, CloudClusterStatus),
timeout = self.DEFAULT_REQUEST_TIMEOUT) timeout=self.DEFAULT_REQUEST_TIMEOUT)
def requestUpload(self, request: CloudPrintJobUploadRequest, def requestUpload(self, request: CloudPrintJobUploadRequest,
on_finished: Callable[[CloudPrintJobResponse], Any]) -> None: on_finished: Callable[[CloudPrintJobResponse], Any]) -> None:
@ -131,10 +131,10 @@ class CloudApiClient:
data = json.dumps({"data": request.toDict()}).encode() data = json.dumps({"data": request.toDict()}).encode()
self._http.put(url, self._http.put(url,
scope = self._scope, scope=self._scope,
data = data, data=data,
callback = self._parseCallback(on_finished, CloudPrintJobResponse), callback=self._parseCallback(on_finished, CloudPrintJobResponse),
timeout = self.DEFAULT_REQUEST_TIMEOUT) timeout=self.DEFAULT_REQUEST_TIMEOUT)
def uploadToolPath(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any], def uploadToolPath(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any],
on_progress: Callable[[int], Any], on_error: Callable[[], Any]): on_progress: Callable[[int], Any], on_error: Callable[[], Any]):
@ -160,11 +160,11 @@ class CloudApiClient:
def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[CloudPrintResponse], Any], on_error) -> None: def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[CloudPrintResponse], Any], on_error) -> None:
url = f"{self.CLUSTER_API_ROOT}/clusters/{cluster_id}/print/{job_id}" url = f"{self.CLUSTER_API_ROOT}/clusters/{cluster_id}/print/{job_id}"
self._http.post(url, self._http.post(url,
scope = self._scope, scope=self._scope,
data = b"", data=b"",
callback = self._parseCallback(on_finished, CloudPrintResponse), callback=self._parseCallback(on_finished, CloudPrintResponse),
error_callback = on_error, error_callback=on_error,
timeout = self.DEFAULT_REQUEST_TIMEOUT) timeout=self.DEFAULT_REQUEST_TIMEOUT)
def doPrintJobAction(self, cluster_id: str, cluster_job_id: str, action: str, def doPrintJobAction(self, cluster_id: str, cluster_job_id: str, action: str,
data: Optional[Dict[str, Any]] = None) -> None: data: Optional[Dict[str, Any]] = None) -> None:
@ -174,14 +174,15 @@ class CloudApiClient:
:param cluster_id: The ID of the cluster. :param cluster_id: The ID of the cluster.
:param cluster_job_id: The ID of the print job within the cluster. :param cluster_job_id: The ID of the print job within the cluster.
:param action: The name of the action to execute. :param action: The name of the action to execute.
:param data: Optional data to send with the POST request
""" """
body = json.dumps({"data": data}).encode() if data else b"" body = json.dumps({"data": data}).encode() if data else b""
url = f"{self.CLUSTER_API_ROOT}/clusters/{cluster_id}/print_jobs/{cluster_job_id}/action/{action}" url = f"{self.CLUSTER_API_ROOT}/clusters/{cluster_id}/print_jobs/{cluster_job_id}/action/{action}"
self._http.post(url, self._http.post(url,
scope = self._scope, scope=self._scope,
data = body, data=body,
timeout = self.DEFAULT_REQUEST_TIMEOUT) timeout=self.DEFAULT_REQUEST_TIMEOUT)
def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest:
"""We override _createEmptyRequest in order to add the user credentials. """We override _createEmptyRequest in order to add the user credentials.
@ -216,8 +217,11 @@ class CloudApiClient:
Logger.logException("e", "Could not parse the stardust response: %s", error.toDict()) Logger.logException("e", "Could not parse the stardust response: %s", error.toDict())
return status_code, {"errors": [error.toDict()]} return status_code, {"errors": [error.toDict()]}
def _parseResponse(self, response: Dict[str, Any], on_finished: Union[Callable[[CloudApiClientModel], Any], def _parseResponse(self,
Callable[[List[CloudApiClientModel]], Any]], model_class: Type[CloudApiClientModel]) -> None: response: Dict[str, Any],
on_finished: Union[Callable[[CloudApiClientModel], Any],
Callable[[List[CloudApiClientModel]], Any]],
model_class: Type[CloudApiClientModel]) -> None:
"""Parses the given response and calls the correct callback depending on the result. """Parses the given response and calls the correct callback depending on the result.
:param response: The response from the server, after being converted to a dict. :param response: The response from the server, after being converted to a dict.
@ -276,3 +280,14 @@ class CloudApiClient:
self._anti_gc_callbacks.append(parse) self._anti_gc_callbacks.append(parse)
return parse return parse
@classmethod
def getMachineIDMap(cls) -> Dict[str, str]:
if cls._machine_id_to_name is None:
try:
with open(Path(__file__).parent / "machine_id_to_name.json", "rt") as f:
cls._machine_id_to_name = json.load(f)
except Exception as e:
Logger.logException("e", f"Could not load machine_id_to_name.json: '{e}'")
cls._machine_id_to_name = {}
return cls._machine_id_to_name

View file

@ -331,7 +331,7 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
return False return False
[printer, *_] = self._printers [printer, *_] = self._printers
return printer.name in ("ultimaker_methodx", "ultimaker_methodxl") return printer.type in ("MakerBot Method X", "MakerBot Method XL")
@pyqtProperty(bool, notify=_cloudClusterPrintersChanged) @pyqtProperty(bool, notify=_cloudClusterPrintersChanged)
def supportsPrintJobActions(self) -> bool: def supportsPrintJobActions(self) -> bool:

View file

@ -0,0 +1,5 @@
{
"ultimaker_method": "MakerBot Method",
"ultimaker_methodx": "MakerBot Method X",
"ultimaker_methodxl": "MakerBot Method XL"
}

View file

@ -2,6 +2,8 @@
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, List from typing import Optional, List
import uuid
from .CloudClusterResponse import CloudClusterResponse from .CloudClusterResponse import CloudClusterResponse
from .ClusterPrinterStatus import ClusterPrinterStatus from .ClusterPrinterStatus import ClusterPrinterStatus
@ -11,4 +13,20 @@ class CloudClusterWithConfigResponse(CloudClusterResponse):
def __init__(self, **kwargs) -> None: def __init__(self, **kwargs) -> None:
self.configuration = self.parseModel(ClusterPrinterStatus, kwargs.get("host_printer")) self.configuration = self.parseModel(ClusterPrinterStatus, kwargs.get("host_printer"))
# Some printers will return a null UUID in the host_printer.uuid field. For those we can fall back using
# the host_guid field of the cluster data
valid_uuid = False
try:
parsed_uuid = uuid.UUID(self.configuration.uuid)
valid_uuid = parsed_uuid.int != 0
except:
pass
if not valid_uuid:
try:
self.configuration.uuid = kwargs.get("host_guid")
except:
pass
super().__init__(**kwargs) super().__init__(**kwargs)

View file

@ -4,9 +4,6 @@
from UM.Job import Job from UM.Job import Job
from UM.Logger import Logger from UM.Logger import Logger
from .avr_isp import ispBase
from .avr_isp.stk500v2 import Stk500v2
from time import time, sleep from time import time, sleep
from serial import Serial, SerialException from serial import Serial, SerialException

View file

@ -5,9 +5,9 @@ import os
from UM.i18n import i18nCatalog from UM.i18n import i18nCatalog
from UM.Logger import Logger from UM.Logger import Logger
from UM.Mesh.MeshWriter import MeshWriter #To get the g-code output. from UM.Mesh.MeshWriter import MeshWriter # To get the g-code output.
from UM.Message import Message #Show an error when already printing. from UM.Message import Message # Show an error when already printing.
from UM.PluginRegistry import PluginRegistry #To get the g-code output. from UM.PluginRegistry import PluginRegistry # To get the g-code output.
from UM.Qt.Duration import DurationFormat from UM.Qt.Duration import DurationFormat
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
@ -19,7 +19,7 @@ from cura.PrinterOutput.GenericOutputController import GenericOutputController
from .AutoDetectBaudJob import AutoDetectBaudJob from .AutoDetectBaudJob import AutoDetectBaudJob
from .AvrFirmwareUpdater import AvrFirmwareUpdater from .AvrFirmwareUpdater import AvrFirmwareUpdater
from io import StringIO #To write the g-code output. from io import StringIO # To write the g-code output.
from queue import Queue from queue import Queue
from serial import Serial, SerialException, SerialTimeoutException from serial import Serial, SerialException, SerialTimeoutException
from threading import Thread, Event from threading import Thread, Event

View file

@ -1,16 +1,16 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import configparser #To read config files. import configparser # To read config files.
import io #To write config files to strings as if they were files. import io # To write config files to strings as if they were files.
import os.path #To get the path to write new user profiles to. import os.path # To get the path to write new user profiles to.
from typing import Dict, List, Optional, Set, Tuple from typing import Dict, List, Optional, Set, Tuple
import urllib #To serialise the user container file name properly. import urllib # To serialise the user container file name properly.
import urllib.parse import urllib.parse
import UM.VersionUpgrade #To indicate that a file is of incorrect format. import UM.VersionUpgrade # To indicate that a file is of incorrect format.
import UM.VersionUpgradeManager #To schedule more files to be upgraded. import UM.VersionUpgradeManager # To schedule more files to be upgraded.
from UM.Resources import Resources #To get the config storage path. from UM.Resources import Resources # To get the config storage path.
## Creates a new machine instance instance by parsing a serialised machine ## Creates a new machine instance instance by parsing a serialised machine
# instance in version 1 of the file format. # instance in version 1 of the file format.

View file

@ -1,11 +1,11 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import configparser #To read config files. import configparser # To read config files.
import io #To output config files to string. import io # To output config files to string.
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
import UM.VersionUpgrade #To indicate that a file is of the wrong format. import UM.VersionUpgrade # To indicate that a file is of the wrong format.
## Creates a new preferences instance by parsing a serialised preferences file ## Creates a new preferences instance by parsing a serialised preferences file
# in version 1 of the file format. # in version 1 of the file format.

View file

@ -1,8 +1,8 @@
# Copyright (c) 2018 Ultimaker B.V. # Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher. # Cura is released under the terms of the LGPLv3 or higher.
import configparser #To read config files. import configparser # To read config files.
import io #To write config files to strings as if they were files. import io # To write config files to strings as if they were files.
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
import UM.VersionUpgrade import UM.VersionUpgrade

View file

@ -0,0 +1,100 @@
# Copyright (c) 2024 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
import configparser
from typing import Tuple, List
import io
from UM.VersionUpgrade import VersionUpgrade
_REMOVED_SETTINGS = {
"support_interface_skip_height",
}
_NEW_SETTING_VERSION = "23"
class VersionUpgrade56to57(VersionUpgrade):
def upgradePreferences(self, serialized: str, filename: str) -> Tuple[List[str], List[str]]:
"""
Upgrades preferences to remove from the visibility list the settings that were removed in this version.
It also changes the preferences to have the new version number.
This removes any settings that were removed in the new Cura version.
:param serialized: The original contents of the preferences file.
:param filename: The file name of the preferences file.
:return: A list of new file names, and a list of the new contents for
those files.
"""
parser = configparser.ConfigParser(interpolation = None)
parser.read_string(serialized)
# Update version number.
parser["metadata"]["setting_version"] = _NEW_SETTING_VERSION
# Remove deleted settings from the visible settings list.
if "general" in parser and "visible_settings" in parser["general"]:
visible_settings = set(parser["general"]["visible_settings"].split(";"))
for removed in _REMOVED_SETTINGS:
if removed in visible_settings:
visible_settings.remove(removed)
parser["general"]["visible_settings"] = ";".join(visible_settings)
result = io.StringIO()
parser.write(result)
return [filename], [result.getvalue()]
def upgradeInstanceContainer(self, serialized: str, filename: str) -> Tuple[List[str], List[str]]:
"""
Upgrades instance containers to remove the settings that were removed in this version.
It also changes the instance containers to have the new version number.
This removes any settings that were removed in the new Cura version and updates settings that need to be updated
with a new value.
:param serialized: The original contents of the instance container.
:param filename: The original file name of the instance container.
:return: A list of new file names, and a list of the new contents for
those files.
"""
parser = configparser.ConfigParser(interpolation = None, comment_prefixes = ())
parser.read_string(serialized)
# Update version number.
parser["metadata"]["setting_version"] = _NEW_SETTING_VERSION
if "values" in parser:
# Remove deleted settings from the instance containers.
for removed in _REMOVED_SETTINGS:
if removed in parser["values"]:
del parser["values"][removed]
if "brim_outside_only" in parser["values"]:
parser["values"]["brim_location"] = "outside" if parser["values"][
"brim_outside_only"] == "True" else "everywhere"
del parser["values"]["brim_outside_only"]
result = io.StringIO()
parser.write(result)
return [filename], [result.getvalue()]
def upgradeStack(self, serialized: str, filename: str) -> Tuple[List[str], List[str]]:
"""
Upgrades stacks to have the new version number.
:param serialized: The original contents of the stack.
:param filename: The original file name of the stack.
:return: A list of new file names, and a list of the new contents for
those files.
"""
parser = configparser.ConfigParser(interpolation = None)
parser.read_string(serialized)
# Update version number.
if "metadata" not in parser:
parser["metadata"] = {}
parser["metadata"]["setting_version"] = _NEW_SETTING_VERSION
result = io.StringIO()
parser.write(result)
return [filename], [result.getvalue()]

Some files were not shown because too many files have changed in this diff Show more