Merge branch 'main' into CURA-12679_fix_definition_issue_zyyx_pro_and_zyyx_plus
Some checks failed
conan-package-resources / conan-package (push) Has been cancelled
conan-package / conan-package (push) Has been cancelled
printer-linter-format / Printer linter auto format (push) Has been cancelled
unit-test / Run unit tests (push) Has been cancelled
conan-package-resources / signal-curator (push) Has been cancelled

This commit is contained in:
HellAholic 2025-09-04 15:48:53 +02:00 committed by GitHub
commit fe9975ebe5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
220 changed files with 11013 additions and 1249 deletions

View file

@ -5,38 +5,25 @@ body:
- type: markdown
attributes:
value: |
### ✨Try our improved Cura 5.7
Before filling out the report below, we want you to try the latest Cura 5.7.
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/latest) ####
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.
### ✨Are you stuck? Have you tried these two things?
1- Are you on a Cura version lower than Cura 5.7? We really recommend updating because it resolves a lot of slicing crashes!
2- Have you tried fixing the model with software that repairs 3d files and makes them watertight?
Are you seeing spots and dots on your model? That is Cura indicating that your model is not watertight.
You can try doing a quick [Mesh Fix with the Meshtools Plugin](https://marketplace.ultimaker.com/app/cura/plugins/fieldofview/MeshTools) or other mesh editing software.
### Project File
**⚠️ Before you continue, we need your project file to troubleshoot a slicing crash.**
It contains the printer and settings we need for troubleshooting.
If you still encounter a crash you are welcome to report the issue so we can use your model as a test case.
You can find instructions on how to share your model in a Package for Technical Support below.
![Alt Text](https://user-images.githubusercontent.com/40423138/240616958-5a9751f2-bd34-4808-9752-6fde2e27516e.gif)
To save a project file go to File -> Save project.
Please make sure to .zip your project file.
For big files, you may need to use [WeTransfer](https://wetransfer.com/) or similar file-sharing sites.
🤔 Before you share, please think to yourself. Is this a model that can be shared?
Unfortunately we cannot help if this file is missing.
Do you have the project file? Than let's continue ⬇️
### Questions
🤔 Before you share, please think to yourself. Is this a model that can be shared on the internet?
**Unfortunately, we cannot help if this file is missing.**
### Questions
- type: input
attributes:
label: Cura Version
placeholder: 5.6.0
description: We work hard on improving our slicing crashes. If you are not on the latest version of Cura, [you can download it here](https://github.com/Ultimaker/Cura/releases/latest)
validations:
required: true
- type: markdown
attributes:
value: |
We work hard on improving our slicing crashes. Our most recent release is 5.7.1.
If you are not on the latest version of Cura, [you can download it here](https://github.com/Ultimaker/Cura/releases/latest)
- type: input
attributes:
label: Operating System
@ -50,27 +37,13 @@ body:
description: Which printer was selected in Cura?
validations:
required: true
- type: input
attributes:
label: Name abnormal settings
description: Are there any settings that you might have changed that caused the crash? Does your model slice when you select the default profiles?
placeholder:
validations:
- type: input
attributes:
label: Describe model location
description: Does your model slice if you rotate the model 90 degrees or if you move it away from the center of the buildplate?
placeholder:
validations:
- type: input
attributes:
label: Describe your model
description: Have you sliced your model succesfully before? Is it watertight? Have you tried doing a quick [Mesh Fix with the Meshtools Plugin](https://marketplace.ultimaker.com/app/cura/plugins/fieldofview/MeshTools)?
validations:
required: true
- type: textarea
attributes:
label: Add your .zip here ⬇️
description: You can add the zip file and additional information that is relevant to the issue in the comments below.
label: Describe your problem and add the package for technical support as a .zip here ⬇️
description: |
If you still have Cura open with your crash > Click on Help on top bar > Click on Export Package For Technical Support > Compress the file into a zip > Add the file here to your GitHub issue 🔗
If you closed Cura, please open Cura to recreate the crash> Select your printer > Load your model > Select your print settings > Click on Help on top bar > Click on Export Package For Technical Support > Compress the file into a zip > Add the file here to your GitHub issue 🔗
validations:
required: true

View file

@ -30,3 +30,13 @@ jobs:
platform_mac: false
install_system_dependencies: false
secrets: inherit
signal-curator:
needs: conan-package
runs-on: ubuntu-latest
steps:
- name: Trigger Curator Workflow
run: |
gh workflow run --repo ultimaker/curator -r main package.yml
env:
GITHUB_TOKEN: ${{ secrets.CURATOR_TRIGGER_PAT_C3PO }}

51
.github/workflows/find-packages.yml vendored Normal file
View file

@ -0,0 +1,51 @@
name: All installers (based on Jira ticket)
run-name: ${{ inputs.jira_ticket_number }} by @${{ github.actor }}
on:
workflow_dispatch:
inputs:
jira_ticket_number:
description: 'Jira ticket number (e.g. CURA-15432 or cura_12345)'
required: true
type: string
start_builds:
description: 'Start installers build based on found packages'
default: true
required: false
type: boolean
conan_args:
description: 'Conan args'
default: ''
type: string
enterprise:
description: 'Build Cura as an Enterprise edition'
default: false
type: boolean
staging:
description: 'Use staging API'
default: false
type: boolean
permissions:
contents: read
jobs:
find-packages:
name: Find packages for Jira ticket
uses: ultimaker/cura-workflows/.github/workflows/find-package-by-ticket.yml@main
with:
jira_ticket_number: ${{ inputs.jira_ticket_number }}
secrets: inherit
installers:
name: Create installers
needs: find-packages
if: ${{ inputs.start_builds == true && needs.find-packages.outputs.discovered_packages != '' }}
uses: ultimaker/cura-workflows/.github/workflows/cura-installers.yml@main
with:
cura_conan_version: ${{ needs.find-packages.outputs.cura_package }}
package_overrides: ${{ needs.find-packages.outputs.package_overrides }}
conan_args: ${{ inputs.conan_args }}
enterprise: ${{ inputs.enterprise }}
staging: ${{ inputs.staging }}
secrets: inherit

View file

@ -2,9 +2,10 @@ name: Nightly build - stable release
run-name: Nightly build - stable release
on:
schedule:
# Daily at 5:15 CET
- cron: '15 4 * * *'
workflow_dispatch:
# schedule:
# # Daily at 5:15 CET
# - cron: '15 4 * * *'
jobs:
build-nightly:

View file

@ -2,9 +2,10 @@ name: Nightly build - dev release
run-name: Nightly build - dev release
on:
schedule:
# Daily at 4:15 CET
- cron: '15 3 * * *'
workflow_dispatch:
# schedule:
# # Daily at 5:15 CET
# - cron: '15 4 * * *'
jobs:
build-nightly:

View file

@ -2,7 +2,7 @@ name: printer-linter-pr-diagnose
on:
pull_request:
path:
paths:
- "resources/**"
permissions:
@ -47,7 +47,7 @@ jobs:
path: printer-linter-result/
- name: Run clang-tidy-pr-comments action
uses: platisd/clang-tidy-pr-comments@bc0bb7da034a8317d54e7fe1e819159002f4cc40
uses: platisd/clang-tidy-pr-comments@v1.8.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
clang_tidy_fixes: result.yml

View file

@ -0,0 +1,65 @@
name: Slicing Error Check
on:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
processSlicingError:
runs-on: ubuntu-latest
steps:
- name: Check for project file and set output
id: check_issue_details
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const issueNumber = issue.number;
console.log(`Processing issue #${issueNumber}: "${issue.title}"`);
const hasSlicingErrorLabel = issue.labels.some(label => label.name.toLowerCase().includes('slicing error'));
const titleContainsSliceFailed = issue.title.toLowerCase().includes('slice failed');
const bodyText = issue.body || "";
const bodyContainsSliceFailed = bodyText.toLowerCase().includes('slice failed');
let setNeedsInfoOutput = false;
if (hasSlicingErrorLabel || titleContainsSliceFailed || bodyContainsSliceFailed) {
console.log(`Issue #${issueNumber} matches slicing error criteria.`);
const zipRegex = /(\[[^\]]*?\]\(.*?\.zip\)|https?:\/\/[^\s]*?\.zip)/i;
let hasZipAttachment = zipRegex.test(bodyText);
if (hasZipAttachment) {
console.log(`Issue #${issueNumber} appears to have a .zip file linked in the body.`);
} else {
console.log(`Issue #${issueNumber} does not appear to have a .zip file linked in the body. Flagging for further action.`);
setNeedsInfoOutput = true;
}
} else {
console.log(`Issue #${issueNumber} does not match slicing error criteria. No action needed.`);
}
core.setOutput('needs_info', setNeedsInfoOutput.toString());
- name: Add comment if project file is missing
if: ${{ steps.check_issue_details.outputs.needs_info == 'true' }}
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ github.event.issue.number }}
body: |
This issue is related to a slicing error, but it seems a project file (`.zip`) is missing.
Please attach a `.zip` file containing your project (including models and profiles) so we can reproduce the issue.
This will help us investigate and resolve the problem more effectively.
Have Cura open with your project that fails to slice, go to `Help` > `Export Package For Technical Support`, and save the package.
Then create a .zip file with the package, attach the `.zip` file to this issue.
If you have already attached a `.zip` file, please ensure it is correctly linked in the issue body.
- name: Add Status Needs Info Label
if: ${{ steps.check_issue_details.outputs.needs_info == 'true' }}
uses: actions-ecosystem/action-add-labels@v1
with:
labels: |
Status: Needs Info

View file

@ -11,5 +11,5 @@ on:
jobs:
update-translations:
uses: ultimaker/cura-workflows/.github/workflows/update-translations.yml@main
with:
branch: ${{ inputs.branch }}
with:
branch: ${{ inputs.branch }}

View file

@ -1,16 +1,17 @@
version: "5.10.2"
version: "5.11.0-alpha.0"
commit: "unknown"
requirements:
- "cura_resources/5.10.2"
- "uranium/5.10.2"
- "curaengine/5.10.2"
- "cura_binary_data/5.10.2"
- "fdm_materials/5.10.2"
- "cura_resources/5.11.0-alpha.0@ultimaker/testing"
- "uranium/5.11.0-alpha.0@ultimaker/testing"
- "curaengine/5.11.0-alpha.0@ultimaker/testing"
- "cura_binary_data/5.11.0-alpha.0@ultimaker/testing"
- "fdm_materials/5.11.0-alpha.0@ultimaker/testing"
- "dulcificum/5.10.0"
- "pysavitar/5.10.0"
- "pysavitar/5.11.0-alpha.0"
- "pynest2d/5.10.0"
requirements_internal:
- "fdm_materials/5.10.2"
- "cura_private_data/5.10.0-alpha.0@internal/testing"
- "fdm_materials/5.11.0-alpha.0@ultimaker/testing"
- "cura_private_data/5.11.0-alpha.0@internal/testing"
requirements_enterprise:
- "native_cad_plugin/2.0.0"
urls:
@ -99,6 +100,7 @@ pyinstaller:
- "pyArcus"
- "pyDulcificum"
- "pynest2d"
- "pyUvula"
- "PyQt6"
- "PyQt6.QtNetwork"
- "PyQt6.sip"

View file

@ -1,8 +1,10 @@
import json
import os
import requests
import yaml
import tempfile
import tarfile
from datetime import datetime
from io import StringIO
from pathlib import Path
from git import Repo
@ -14,7 +16,7 @@ from conan import ConanFile
from conan.tools.files import copy, rmdir, save, mkdir, rm, update_conandata
from conan.tools.microsoft import unix_path
from conan.tools.env import VirtualRunEnv, Environment, VirtualBuildEnv
from conan.tools.scm import Version
from conan.tools.scm import Version, Git
from conan.errors import ConanInvalidConfiguration, ConanException
required_conan_version = ">=2.7.0" # When changing the version, also change the one in conandata.yml/extra_dependencies
@ -327,10 +329,16 @@ class CuraConan(ConanFile):
# If you want a specific Cura version to show up on the splash screen add the user configuration `user.cura:version=VERSION`
# the global.conf, profile, package_info (of dependency) or via the cmd line `-c user.cura:version=VERSION`
cura_version = Version(self.conf.get("user.cura:version", default = self.version, check_type = str))
pre_tag = f"-{cura_version.pre}" if cura_version.pre else ""
build_tag = f"+{cura_version.build}" if cura_version.build else ""
internal_tag = f"+internal" if self.options.internal else ""
cura_version = f"{cura_version.major}.{cura_version.minor}.{cura_version.patch}{pre_tag}{build_tag}{internal_tag}"
extra_build_identifiers = []
if self.options.internal:
extra_build_identifiers.append("internal")
if str(cura_version.pre).startswith("alpha") and self.conan_data["commit"] != "unknown":
extra_build_identifiers.append(self.conan_data["commit"][:6])
if extra_build_identifiers:
separator = "+" if not cura_version.build else "."
cura_version = Version(f"{cura_version}{separator}{'.'.join(extra_build_identifiers)}")
self.output.info(f"Write CuraVersion.py to {self.recipe_folder}")
@ -338,7 +346,7 @@ class CuraConan(ConanFile):
f.write(cura_version_py.render(
cura_app_name = self.name,
cura_app_display_name = self._app_name,
cura_version = cura_version,
cura_version = str(cura_version),
cura_version_full = self.version,
cura_build_type = "Enterprise" if self.options.enterprise else "",
cura_debug_mode = self.options.cura_debug_mode,
@ -525,7 +533,7 @@ class CuraConan(ConanFile):
))
def export(self):
update_conandata(self, {"version": self.version})
update_conandata(self, {"version": self.version, "commit": Git(self).get_commit()})
def export_sources(self):
copy(self, "*", os.path.join(self.recipe_folder, "plugins"), os.path.join(self.export_sources_folder, "plugins"))
@ -562,6 +570,30 @@ class CuraConan(ConanFile):
self.cpp.package.bindirs = ["bin"]
self.cpp.package.resdirs = ["resources", "plugins", "packaging"]
def _make_internal_distinct(self):
test_colors_path = Path(self.source_folder, "resources", "themes", "daily_test_colors.json")
if not test_colors_path.exists():
print(f"Could not find '{str(test_colors_path)}'. Won't generate rotating colors for alpha builds.")
return
if "alpha" in self.version:
with test_colors_path.open("r") as test_colors_file:
test_colors = json.load(test_colors_file)
biweekly_day = (datetime.now() - datetime(2025, 3, 14)).days % len(test_colors)
for theme_dir in Path(self.source_folder, "resources", "themes").iterdir():
if not theme_dir.is_dir():
continue
theme_path = Path(theme_dir, "theme.json")
if not theme_path.exists():
print(f"('Colorize-by-day' alpha builds): Skipping {str(theme_path)}, could not find file.")
continue
with theme_path.open("r") as theme_file:
theme = json.load(theme_file)
if theme["colors"]:
theme["colors"]["main_window_header_background"] = test_colors[biweekly_day]
with theme_path.open("w") as theme_file:
json.dump(theme, theme_file)
test_colors_path.unlink()
def generate(self):
copy(self, "cura_app.py", self.source_folder, str(self._script_dir))
@ -581,6 +613,9 @@ class CuraConan(ConanFile):
copy(self, "bundled_*.json", native_cad_plugin.resdirs[1],
str(Path(self.source_folder, "resources", "bundled_packages")), keep_path = False)
# Make internal versions built on different days distinct, so people don't get confused while testing.
self._make_internal_distinct()
# Copy resources of cura_binary_data
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)

View file

@ -1,4 +1,4 @@
# Copyright (c) 2018 Ultimaker B.V.
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Tuple, Optional, TYPE_CHECKING, Dict, Any
@ -9,14 +9,10 @@ if TYPE_CHECKING:
class Backups:
"""The back-ups API provides a version-proof bridge between Cura's
BackupManager and plug-ins that hook into it.
"""The back-ups API provides a version-proof bridge between Cura's BackupManager and plug-ins that hook into it.
Usage:
.. code-block:: python
from cura.API import CuraAPI
api = CuraAPI()
api.backups.createBackup()
@ -26,19 +22,22 @@ class Backups:
def __init__(self, application: "CuraApplication") -> None:
self.manager = BackupsManager(application)
def createBackup(self) -> Tuple[Optional[bytes], Optional[Dict[str, Any]]]:
def createBackup(self, available_remote_plugins: frozenset[str] = frozenset()) -> Tuple[Optional[bytes], Optional[Dict[str, Any]]]:
"""Create a new back-up using the BackupsManager.
:return: Tuple containing a ZIP file with the back-up data and a dict with metadata about the back-up.
"""
return self.manager.createBackup()
return self.manager.createBackup(available_remote_plugins)
def restoreBackup(self, zip_file: bytes, meta_data: Dict[str, Any]) -> None:
def restoreBackup(self, zip_file: bytes, meta_data: Dict[str, Any], auto_close: bool = True) -> None:
"""Restore a back-up using the BackupsManager.
:param zip_file: A ZIP file containing the actual back-up data.
:param meta_data: Some metadata needed for restoring a back-up, like the Cura version number.
"""
return self.manager.restoreBackup(zip_file, meta_data)
return self.manager.restoreBackup(zip_file, meta_data, auto_close=auto_close)
def shouldReinstallDownloadablePlugins(self) -> bool:
return self.manager.shouldReinstallDownloadablePlugins()

View file

@ -3,7 +3,7 @@
from dataclasses import asdict
from typing import cast, Dict, TYPE_CHECKING
from typing import cast, Dict, TYPE_CHECKING, Any
from UM.Settings.InstanceContainer import InstanceContainer
from UM.Settings.SettingFunction import SettingFunction
@ -54,6 +54,15 @@ class Settings:
return self.application.getSidebarCustomMenuItems()
def getAllGlobalSettings(self) -> Dict[str, Any]:
global_stack = cast(GlobalStack, self.application.getGlobalContainerStack())
all_settings = {}
for setting in global_stack.getAllKeys():
all_settings[setting] = self._retrieveValue(global_stack, setting)
return all_settings
def getSliceMetadata(self) -> Dict[str, Dict[str, Dict[str, str]]]:
"""Get all changed settings and all settings. For each extruder and the global stack"""
print_information = self.application.getPrintInformation()
@ -71,24 +80,16 @@ class Settings:
"quality": asdict(machine_manager.activeQualityDisplayNameMap()),
}
def _retrieveValue(container: InstanceContainer, setting_: str):
value_ = container.getProperty(setting_, "value")
for _ in range(0, 1024): # Prevent possibly endless loop by not using a limit.
if not isinstance(value_, SettingFunction):
return value_ # Success!
value_ = value_(container)
return 0 # Fallback value after breaking possibly endless loop.
global_stack = cast(GlobalStack, self.application.getGlobalContainerStack())
# Add global user or quality changes
global_flattened_changes = InstanceContainer.createMergedInstanceContainer(global_stack.userChanges, global_stack.qualityChanges)
for setting in global_flattened_changes.getAllKeys():
settings["global"]["changes"][setting] = _retrieveValue(global_flattened_changes, setting)
settings["global"]["changes"][setting] = self._retrieveValue(global_flattened_changes, setting)
# Get global all settings values without user or quality changes
for setting in global_stack.getAllKeys():
settings["global"]["all_settings"][setting] = _retrieveValue(global_stack, setting)
settings["global"]["all_settings"][setting] = self._retrieveValue(global_stack, setting)
for i, extruder in enumerate(global_stack.extruderList):
# Add extruder fields to settings dictionary
@ -100,10 +101,19 @@ class Settings:
# Add extruder user or quality changes
extruder_flattened_changes = InstanceContainer.createMergedInstanceContainer(extruder.userChanges, extruder.qualityChanges)
for setting in extruder_flattened_changes.getAllKeys():
settings[f"extruder_{i}"]["changes"][setting] = _retrieveValue(extruder_flattened_changes, setting)
settings[f"extruder_{i}"]["changes"][setting] = self._retrieveValue(extruder_flattened_changes, setting)
# Get extruder all settings values without user or quality changes
for setting in extruder.getAllKeys():
settings[f"extruder_{i}"]["all_settings"][setting] = _retrieveValue(extruder, setting)
settings[f"extruder_{i}"]["all_settings"][setting] = self._retrieveValue(extruder, setting)
return settings
@staticmethod
def _retrieveValue(container: InstanceContainer, setting_: str):
value_ = container.getProperty(setting_, "value")
for _ in range(0, 1024): # Prevent possibly endless loop by not using a limit.
if not isinstance(value_, SettingFunction):
return value_ # Success!
value_ = value_(container)
return 0 # Fallback value after breaking possibly endless loop.

View file

@ -1,5 +1,8 @@
# Copyright (c) 2021 Ultimaker B.V.
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
import tempfile
import json
import io
import os
@ -7,12 +10,13 @@ import re
import shutil
from copy import deepcopy
from zipfile import ZipFile, ZIP_DEFLATED, BadZipfile
from typing import Dict, Optional, TYPE_CHECKING, List
from typing import Callable, Dict, Optional, TYPE_CHECKING, List
from UM import i18nCatalog
from UM.Logger import Logger
from UM.Message import Message
from UM.Platform import Platform
from UM.PluginRegistry import PluginRegistry
from UM.Resources import Resources
from UM.Version import Version
@ -30,6 +34,7 @@ class Backup:
"""These files should be ignored when making a backup."""
IGNORED_FOLDERS = [] # type: List[str]
"""These folders should be ignored when making a backup."""
SECRETS_SETTINGS = ["general/ultimaker_auth_data"]
"""Secret preferences that need to obfuscated when making a backup of Cura"""
@ -42,7 +47,7 @@ class Backup:
self.zip_file = zip_file # type: Optional[bytes]
self.meta_data = meta_data # type: Optional[Dict[str, str]]
def makeFromCurrent(self) -> None:
def makeFromCurrent(self, available_remote_plugins: frozenset[str] = frozenset()) -> None:
"""Create a back-up from the current user config folder."""
cura_release = self._application.getVersion()
@ -68,7 +73,7 @@ class Backup:
# Create an empty buffer and write the archive to it.
buffer = io.BytesIO()
archive = self._makeArchive(buffer, version_data_dir)
archive = self._makeArchive(buffer, version_data_dir, available_remote_plugins)
if archive is None:
return
files = archive.namelist()
@ -77,9 +82,7 @@ class Backup:
machine_count = max(len([s for s in files if "machine_instances/" in s]) - 1, 0) # If people delete their profiles but not their preferences, it can still make a backup, and report -1 profiles. Server crashes on this.
material_count = max(len([s for s in files if "materials/" in s]) - 1, 0)
profile_count = max(len([s for s in files if "quality_changes/" in s]) - 1, 0)
# We don't store plugins anymore, since if you can make backups, you have an account (and the plugins are
# on the marketplace anyway)
plugin_count = 0
plugin_count = len([s for s in files if "plugin.json" in s])
# Store the archive and metadata so the BackupManager can fetch them when needed.
self.zip_file = buffer.getvalue()
self.meta_data = {
@ -92,22 +95,72 @@ class Backup:
# Restore the obfuscated settings
self._illuminate(**secrets)
def _makeArchive(self, buffer: "io.BytesIO", root_path: str) -> Optional[ZipFile]:
def _fillToInstallsJson(self, file_path: str, reinstall_on_restore: frozenset[str], add_to_archive: Callable[[str, str], None]) -> Optional[str]:
""" Moves all plugin-data (in a config-file) for plugins that could be (re)installed from the Marketplace from
'installed' to 'to_installs' before adding that file to the archive.
Note that the 'filename'-entry in the package-data (of the plugins) might not be valid anymore on restore.
We'll replace it on restore instead, as that's the time when the new package is downloaded.
:param file_path: Absolute path to the packages-file.
:param reinstall_on_restore: A set of plugins that _can_ be reinstalled from the Marketplace.
:param add_to_archive: A function/lambda that takes a filename and adds it to the archive (as the 2nd name).
"""
with open(file_path, "r") as file:
data = json.load(file)
reinstall, keep_in = {}, {}
for install_id, install_info in data["installed"].items():
(reinstall if install_id in reinstall_on_restore else keep_in)[install_id] = install_info
data["installed"] = keep_in
data["to_install"].update(reinstall)
if data is not None:
tmpfile = tempfile.NamedTemporaryFile(delete_on_close=False)
with open(tmpfile.name, "w") as outfile:
json.dump(data, outfile)
add_to_archive(tmpfile.name, file_path)
return tmpfile.name
return None
def _findRedownloadablePlugins(self, available_remote_plugins: frozenset) -> (frozenset[str], frozenset[str]):
""" Find all plugins that should be able to be reinstalled from the Marketplace.
:param plugins_path: Path to all plugins in the user-space.
:return: Tuple of a set of plugin-ids and a set of plugin-paths.
"""
plugin_reg = PluginRegistry.getInstance()
id = "id"
plugins = [v for v in plugin_reg.getAllMetaData()
if v[id] in available_remote_plugins and not plugin_reg.isBundledPlugin(v[id])]
return frozenset([v[id] for v in plugins]), frozenset([v["location"] for v in plugins])
def _makeArchive(self, buffer: "io.BytesIO", root_path: str, available_remote_plugins: frozenset) -> Optional[ZipFile]:
"""Make a full archive from the given root path with the given name.
:param root_path: The root directory to archive recursively.
:return: The archive as bytes.
"""
ignore_string = re.compile("|".join(self.IGNORED_FILES + self.IGNORED_FOLDERS))
reinstall_instead_ids, reinstall_instead_paths = self._findRedownloadablePlugins(available_remote_plugins)
tmpfiles = []
try:
archive = ZipFile(buffer, "w", ZIP_DEFLATED)
for root, folders, files in os.walk(root_path):
add_path_to_archive = lambda path, alt_path: archive.write(path, alt_path[len(root_path) + len(os.sep):])
for root, folders, files in os.walk(root_path, topdown=True):
for item_name in folders + files:
absolute_path = os.path.join(root, item_name)
if ignore_string.search(absolute_path):
if ignore_string.search(absolute_path) or any([absolute_path.startswith(x) for x in reinstall_instead_paths]):
continue
archive.write(absolute_path, absolute_path[len(root_path) + len(os.sep):])
if item_name == "packages.json":
tmpfiles.append(
self._fillToInstallsJson(absolute_path, reinstall_instead_ids, add_path_to_archive))
else:
add_path_to_archive(absolute_path, absolute_path)
archive.close()
for tmpfile_path in tmpfiles:
try:
os.remove(tmpfile_path)
except IOError as ex:
Logger.warning(f"Couldn't remove temporary file '{tmpfile_path}' because '{ex}'.")
return archive
except (IOError, OSError, BadZipfile) as error:
Logger.log("e", "Could not create archive from user data directory: %s", error)

View file

@ -1,4 +1,4 @@
# Copyright (c) 2018 Ultimaker B.V.
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Dict, Optional, Tuple, TYPE_CHECKING
@ -22,7 +22,10 @@ class BackupsManager:
def __init__(self, application: "CuraApplication") -> None:
self._application = application
def createBackup(self) -> Tuple[Optional[bytes], Optional[Dict[str, str]]]:
def shouldReinstallDownloadablePlugins(self) -> bool:
return True
def createBackup(self, available_remote_plugins: frozenset[str] = frozenset()) -> Tuple[Optional[bytes], Optional[Dict[str, str]]]:
"""
Get a back-up of the current configuration.
@ -31,17 +34,18 @@ class BackupsManager:
self._disableAutoSave()
backup = Backup(self._application)
backup.makeFromCurrent()
backup.makeFromCurrent(available_remote_plugins if self.shouldReinstallDownloadablePlugins() else frozenset())
self._enableAutoSave()
# We don't return a Backup here because we want plugins only to interact with our API and not full objects.
return backup.zip_file, backup.meta_data
def restoreBackup(self, zip_file: bytes, meta_data: Dict[str, str]) -> None:
def restoreBackup(self, zip_file: bytes, meta_data: Dict[str, str], auto_close: bool = True) -> None:
"""
Restore a back-up from a given ZipFile.
:param zip_file: A bytes object containing the actual back-up.
:param meta_data: A dict containing some metadata that is needed to restore the back-up correctly.
:param auto_close: Normally, Cura will need to close immediately after restoring the back-up.
"""
if not meta_data.get("cura_release", None):
@ -54,7 +58,7 @@ class BackupsManager:
backup = Backup(self._application, zip_file = zip_file, meta_data = meta_data)
restored = backup.restore()
if restored:
if restored and auto_close:
# At this point, Cura will need to restart for the changes to take effect.
# We don't want to store the data at this point as that would override the just-restored backup.
self._application.windowClosed(save_data = False)

View file

@ -9,7 +9,6 @@ import time
import platform
from pathlib import Path
from typing import cast, TYPE_CHECKING, Optional, Callable, List, Any, Dict
import requests
import numpy
from PyQt6.QtCore import QObject, QTimer, QUrl, QUrlQuery, pyqtSignal, pyqtProperty, QEvent, pyqtEnum, QCoreApplication, \
@ -60,6 +59,7 @@ from cura import ApplicationMetadata
from cura.API import CuraAPI
from cura.API.Account import Account
from cura.Arranging.ArrangeObjectsJob import ArrangeObjectsJob
from cura.CuraRenderer import CuraRenderer
from cura.Machines.MachineErrorChecker import MachineErrorChecker
from cura.Machines.Models.BuildPlateModel import BuildPlateModel
from cura.Machines.Models.CustomQualityProfilesDropDownMenuModel import CustomQualityProfilesDropDownMenuModel
@ -188,6 +188,7 @@ class CuraApplication(QtApplication):
self._single_instance = None
self._open_project_mode: Optional[str] = None
self._read_operation_is_project_file: Optional[bool] = None
self._cura_formula_functions = None # type: Optional[CuraFormulaFunctions]
@ -361,6 +362,9 @@ class CuraApplication(QtApplication):
self._machine_action_manager = MachineActionManager(self)
self._machine_action_manager.initialize()
def makeRenderer(self) -> CuraRenderer:
return CuraRenderer(self)
def __sendCommandToSingleInstance(self):
self._single_instance = SingleInstance(self, self._files_to_open, self._urls_to_open)
@ -1034,7 +1038,6 @@ class CuraApplication(QtApplication):
# Initialize UI state
controller.setActiveStage("PrepareStage")
controller.setActiveView("SolidView")
controller.setCameraTool("CameraTool")
controller.setSelectionTool("SelectionTool")
@ -1644,14 +1647,10 @@ class CuraApplication(QtApplication):
Logger.log("w", "Unable to reload data because we don't have a filename.")
for file_name, nodes in objects_in_filename.items():
file_path = os.path.normpath(os.path.dirname(file_name))
job = ReadMeshJob(file_name,
add_to_recent_files=file_path != tempfile.gettempdir()) # Don't add temp files to the recent files list
job._nodes = nodes # type: ignore
job.finished.connect(self._reloadMeshFinished)
on_done = None
if has_merged_nodes:
job.finished.connect(self.updateOriginOfMergedMeshes)
job.start()
on_done = self.updateOriginOfMergedMeshes
self.getController().getScene().reloadNodes(nodes, file_name, on_done)
@pyqtSlot("QStringList")
def setExpandedCategories(self, categories: List[str]) -> None:
@ -1834,53 +1833,6 @@ class CuraApplication(QtApplication):
fileLoaded = pyqtSignal(str)
fileCompleted = pyqtSignal(str)
def _reloadMeshFinished(self, job) -> None:
"""
Function called when ReadMeshJob finishes reloading a file in the background, then update node objects in the
scene from its source file. The function gets all the nodes that exist in the file through the job result, and
then finds the scene nodes that need to be refreshed by their name. Each job refreshes all nodes of a file.
Nodes that are not present in the updated file are kept in the scene.
:param job: The :py:class:`Uranium.UM.ReadMeshJob.ReadMeshJob` running in the background that reads all the
meshes in a file
"""
job_result = job.getResult() # nodes that exist inside the file read by this job
if len(job_result) == 0:
Logger.log("e", "Reloading the mesh failed.")
return
renamed_nodes = {} # type: Dict[str, int]
# Find the node to be refreshed based on its id
for job_result_node in job_result:
mesh_data = job_result_node.getMeshData()
if not mesh_data:
Logger.log("w", "Could not find a mesh in reloaded node.")
continue
# Solves issues with object naming
result_node_name = job_result_node.getName()
if not result_node_name:
result_node_name = os.path.basename(mesh_data.getFileName())
if result_node_name in renamed_nodes: # objects may get renamed by ObjectsModel._renameNodes() when loaded
renamed_nodes[result_node_name] += 1
result_node_name = "{0}({1})".format(result_node_name, renamed_nodes[result_node_name])
else:
renamed_nodes[job_result_node.getName()] = 0
# Find the matching scene node to replace
scene_node = None
for replaced_node in job._nodes:
if replaced_node.getName() == result_node_name:
scene_node = replaced_node
break
if scene_node:
scene_node.setMeshData(mesh_data)
else:
# Current node is a new one in the file, or it's name has changed
# TODO: Load this mesh into the scene. Also alter the "_reloadJobFinished" action in UM.Scene
Logger.log("w", "Could not find matching node for object '{0}' in the scene.".format(result_node_name))
def _openFile(self, filename):
self.readLocalFile(QUrl.fromLocalFile(filename))
@ -1894,36 +1846,39 @@ class CuraApplication(QtApplication):
query = QUrlQuery(url.query())
model_url = QUrl(query.queryItemValue("file", options=QUrl.ComponentFormattingOption.FullyDecoded))
def on_finish(response):
content_disposition_header_key = QByteArray("content-disposition".encode())
filename = model_url.path().split("/")[-1] + ".stl"
if response.hasRawHeader(content_disposition_header_key):
# content_disposition is in the format
# ```
# content_disposition attachment; filename="[FILENAME]"
# ```
# Use a regex to extract the filename
content_disposition = str(response.rawHeader(content_disposition_header_key).data(),
encoding='utf-8')
content_disposition_match = re.match(r'attachment; filename=(?P<filename>.*)',
content_disposition)
if content_disposition_match is not None:
filename = content_disposition_match.group("filename").strip("\"")
tmp = tempfile.NamedTemporaryFile(suffix=filename, delete=False)
with open(tmp.name, "wb") as f:
f.write(response.readAll())
self.readLocalFile(QUrl.fromLocalFile(tmp.name), add_to_recent_files=False)
def on_error(*args, **kwargs):
Logger.log("w", "Could not download file from {0}".format(model_url.url()))
Message("Could not download file: " + str(model_url.url()),
Logger.warning(f"Could not download file from {model_url.url()}")
Message(f"Could not download file: {str(model_url.url())}",
title= "Loading Model failed",
message_type=Message.MessageType.ERROR).show()
return
def on_finish(response):
try:
content_disposition_header_key = QByteArray("content-disposition".encode())
filename = model_url.path().split("/")[-1] + ".stl"
if response.hasRawHeader(content_disposition_header_key):
# content_disposition is in the format
# ```
# content_disposition attachment; filename="[FILENAME]"
# ```
# Use a regex to extract the filename
content_disposition = str(response.rawHeader(content_disposition_header_key).data(),
encoding='utf-8')
content_disposition_match = re.match(r'attachment; filename=(?P<filename>.*)',
content_disposition)
if content_disposition_match is not None:
filename = content_disposition_match.group("filename").strip("\"")
tmp = tempfile.NamedTemporaryFile(suffix=filename, delete=False)
with open(tmp.name, "wb") as f:
f.write(response.readAll())
self.readLocalFile(QUrl.fromLocalFile(tmp.name), add_to_recent_files=False)
except Exception as ex:
Logger.warning(f"Exception {str(ex)}")
on_error()
self.getHttpRequestManager().get(
model_url.url(),
@ -2015,18 +1970,18 @@ class CuraApplication(QtApplication):
self.deleteAll()
break
is_project_file = self.checkIsValidProjectFile(file)
self._read_operation_is_project_file = self.checkIsValidProjectFile(file)
if self._open_project_mode is None:
self._open_project_mode = self.getPreferences().getValue("cura/choice_on_open_project")
if is_project_file and self._open_project_mode == "open_as_project":
if self._read_operation_is_project_file and self._open_project_mode == "open_as_project":
# open as project immediately without presenting a dialog
workspace_handler = self.getWorkspaceFileHandler()
workspace_handler.readLocalFile(file, add_to_recent_files_hint = add_to_recent_files)
return
if is_project_file and self._open_project_mode == "always_ask":
if self._read_operation_is_project_file and self._open_project_mode == "always_ask":
# present a dialog asking to open as project or import models
self.callLater(self.openProjectFile.emit, file, add_to_recent_files)
return
@ -2133,9 +2088,7 @@ class CuraApplication(QtApplication):
is_non_sliceable = "." + file_extension in self._non_sliceable_extensions
if is_non_sliceable:
# Need to switch first to the preview stage and then to layer view
self.callLater(lambda: (self.getController().setActiveStage("PreviewStage"),
self.getController().setActiveView("SimulationView")))
self.callLater(lambda: (self.getController().setActiveStage("PreviewStage")))
block_slicing_decorator = BlockSlicingDecorator()
node.addDecorator(block_slicing_decorator)
@ -2164,7 +2117,7 @@ class CuraApplication(QtApplication):
nodes_to_arrange.append(node)
# If the file is a project,and models are to be loaded from a that project,
# models inside file should be arranged in buildplate.
elif self._open_project_mode == "open_as_model":
elif self._read_operation_is_project_file and self._open_project_mode == "open_as_model":
nodes_to_arrange.append(node)
# This node is deep copied from some other node which already has a BuildPlateDecorator, but the deepcopy

46
cura/CuraRenderer.py Normal file
View file

@ -0,0 +1,46 @@
# Copyright (c) 2025 UltiMaker
# Uranium is released under the terms of the LGPLv3 or higher.
from typing import TYPE_CHECKING
from cura.PickingPass import PickingPass
from UM.Qt.QtRenderer import QtRenderer
from UM.View.RenderPass import RenderPass
from UM.View.SelectionPass import SelectionPass
if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication
class CuraRenderer(QtRenderer):
"""An overridden Renderer implementation that adds some behaviors specific to Cura."""
def __init__(self, application: "CuraApplication") -> None:
super().__init__()
self._controller = application.getController()
self._controller.activeToolChanged.connect(self._onActiveToolChanged)
self._extra_rendering_passes: list[RenderPass] = []
def _onActiveToolChanged(self) -> None:
tool_extra_rendering_passes = []
active_tool = self._controller.getActiveTool()
if active_tool is not None:
tool_extra_rendering_passes = active_tool.getRequiredExtraRenderingPasses()
for extra_rendering_pass in self._extra_rendering_passes:
extra_rendering_pass.setEnabled(extra_rendering_pass.getName() in tool_extra_rendering_passes)
def _makeRenderPasses(self) -> list[RenderPass]:
self._extra_rendering_passes = [
SelectionPass(self._viewport_width, self._viewport_height, SelectionPass.SelectionMode.FACES),
PickingPass(self._viewport_width, self._viewport_height, only_selected_objects=True),
PickingPass(self._viewport_width, self._viewport_height, only_selected_objects=False)
]
for extra_rendering_pass in self._extra_rendering_passes:
extra_rendering_pass.setEnabled(False)
return super()._makeRenderPasses() + self._extra_rendering_passes

View file

@ -80,9 +80,13 @@ class LayerDataBuilder(MeshBuilder):
material_colors = numpy.zeros((line_dimensions.shape[0], 4), dtype=numpy.float32)
for extruder_nr in range(material_color_map.shape[0]):
material_colors[extruders == extruder_nr] = material_color_map[extruder_nr]
# Set material_colors with indices where line_types (also numpy array) == MoveCombingType
material_colors[line_types == LayerPolygon.MoveCombingType] = colors[line_types == LayerPolygon.MoveCombingType]
material_colors[line_types == LayerPolygon.MoveRetractionType] = colors[line_types == LayerPolygon.MoveRetractionType]
# Set material_colors with indices where line_types (also numpy array) == MoveUnretractedType
material_colors[line_types == LayerPolygon.MoveUnretractedType] = colors[line_types == LayerPolygon.MoveUnretractedType]
material_colors[line_types == LayerPolygon.MoveRetractedType] = colors[line_types == LayerPolygon.MoveRetractedType]
material_colors[line_types == LayerPolygon.MoveWhileRetractingType] = colors[
line_types == LayerPolygon.MoveWhileRetractingType]
material_colors[line_types == LayerPolygon.MoveWhileUnretractingType] = colors[
line_types == LayerPolygon.MoveWhileUnretractingType]
attributes = {
"line_dimensions": {

View file

@ -19,15 +19,22 @@ class LayerPolygon:
SkirtType = 5
InfillType = 6
SupportInfillType = 7
MoveCombingType = 8
MoveRetractionType = 9
MoveUnretractedType = 8
MoveRetractedType = 9
SupportInterfaceType = 10
PrimeTowerType = 11
__number_of_types = 12
MoveWhileRetractingType = 12
MoveWhileUnretractingType = 13
StationaryRetractUnretract = 14
__number_of_types = 15
__jump_map = numpy.logical_or(numpy.logical_or(numpy.arange(__number_of_types) == NoneType,
numpy.arange(__number_of_types) == MoveCombingType),
numpy.arange(__number_of_types) == MoveRetractionType)
__jump_map = numpy.logical_or(numpy.logical_or(numpy.logical_or(
numpy.arange(__number_of_types) == NoneType,
numpy.arange(__number_of_types) == MoveUnretractedType),
numpy.logical_or(
numpy.arange(__number_of_types) == MoveRetractedType,
numpy.arange(__number_of_types) == MoveWhileRetractingType)),
numpy.arange(__number_of_types) == MoveWhileUnretractingType)
def __init__(self, extruder: int, line_types: numpy.ndarray, data: numpy.ndarray,
line_widths: numpy.ndarray, line_thicknesses: numpy.ndarray, line_feedrates: numpy.ndarray) -> None:
@ -269,10 +276,13 @@ class LayerPolygon:
theme.getColor("layerview_skirt").getRgbF(), # SkirtType
theme.getColor("layerview_infill").getRgbF(), # InfillType
theme.getColor("layerview_support_infill").getRgbF(), # SupportInfillType
theme.getColor("layerview_move_combing").getRgbF(), # MoveCombingType
theme.getColor("layerview_move_retraction").getRgbF(), # MoveRetractionType
theme.getColor("layerview_move_combing").getRgbF(), # MoveUnretractedType
theme.getColor("layerview_move_retraction").getRgbF(), # MoveRetractedType
theme.getColor("layerview_support_interface").getRgbF(), # SupportInterfaceType
theme.getColor("layerview_prime_tower").getRgbF() # PrimeTowerType
theme.getColor("layerview_prime_tower").getRgbF(), # PrimeTowerType
theme.getColor("layerview_move_while_retracting").getRgbF(), # MoveWhileRetracting
theme.getColor("layerview_move_while_unretracting").getRgbF(), # MoveWhileUnretracting
theme.getColor("layerview_move_retraction").getRgbF(), # StationaryRetractUnretract
])
return cls.__color_map

View file

@ -61,6 +61,7 @@ class MachineErrorChecker(QObject):
self._machine_manager.globalContainerChanged.connect(self.startErrorCheck)
self._onMachineChanged()
self.startErrorCheck()
def _setCheckTimer(self) -> None:
"""A QTimer to regulate error check frequency

View file

@ -7,6 +7,7 @@ from UM.Qt.QtApplication import QtApplication
from UM.Logger import Logger
from UM.Math.Vector import Vector
from UM.Resources import Resources
from UM.Scene.Selection import Selection
from UM.View.RenderPass import RenderPass
from UM.View.GL.OpenGL import OpenGL
@ -27,13 +28,14 @@ class PickingPass(RenderPass):
.. note:: that in order to increase precision, the 24 bit depth value is encoded into all three of the R,G & B channels
"""
def __init__(self, width: int, height: int) -> None:
super().__init__("picking", width, height)
def __init__(self, width: int, height: int, only_selected_objects: bool = False) -> None:
super().__init__("picking" if not only_selected_objects else "picking_selected", width, height)
self._renderer = QtApplication.getInstance().getRenderer()
self._shader = None #type: Optional[ShaderProgram]
self._scene = QtApplication.getInstance().getController().getScene()
self._only_selected_objects = only_selected_objects
def render(self) -> None:
if not self._shader:
@ -53,7 +55,7 @@ class PickingPass(RenderPass):
# Fill up the batch with objects that can be sliced. `
for node in DepthFirstIterator(self._scene.getRoot()): #type: ignore #Ignore type error because iter() should get called automatically by Python syntax.
if node.callDecoration("isSliceable") and node.getMeshData() and node.isVisible():
if node.callDecoration("isSliceable") and node.getMeshData() and node.isVisible() and (not self._only_selected_objects or Selection.isSelected(node)):
batch.addItem(node.getWorldTransformation(copy = False), node.getMeshData(), normal_transformation=node.getCachedNormalMatrix())
self.bind()

View file

@ -33,8 +33,8 @@ class AuthState(IntEnum):
class NetworkedPrinterOutputDevice(PrinterOutputDevice):
authenticationStateChanged = pyqtSignal()
def __init__(self, device_id, address: str, properties: Dict[bytes, bytes], connection_type: ConnectionType = ConnectionType.NetworkConnection, parent: QObject = None) -> None:
super().__init__(device_id = device_id, connection_type = connection_type, parent = parent)
def __init__(self, device_id, address: str, properties: Dict[bytes, bytes], connection_type: ConnectionType = ConnectionType.NetworkConnection, parent: QObject = None, active: bool = True) -> None:
super().__init__(device_id = device_id, connection_type = connection_type, parent = parent, active = active)
self._manager = None # type: Optional[QNetworkAccessManager]
self._timeout_time = 10 # After how many seconds of no response should a timeout occur?

View file

@ -72,7 +72,10 @@ class PrinterOutputDevice(QObject, OutputDevice):
# Signal to indicate that the configuration of one of the printers has changed.
uniqueConfigurationsChanged = pyqtSignal()
def __init__(self, device_id: str, connection_type: "ConnectionType" = ConnectionType.NotConnected, parent: QObject = None) -> None:
# Signal to indicate that the printer has become active or inactive
activeChanged = pyqtSignal()
def __init__(self, device_id: str, connection_type: "ConnectionType" = ConnectionType.NotConnected, parent: QObject = None, active: bool = True) -> None:
super().__init__(device_id = device_id, parent = parent) # type: ignore # MyPy complains with the multiple inheritance
self._printers = [] # type: List[PrinterOutputModel]
@ -88,6 +91,8 @@ class PrinterOutputDevice(QObject, OutputDevice):
self._accepts_commands = False # type: bool
self._active: bool = active
self._update_timer = QTimer() # type: QTimer
self._update_timer.setInterval(2000) # TODO; Add preference for update interval
self._update_timer.setSingleShot(False)
@ -295,3 +300,17 @@ class PrinterOutputDevice(QObject, OutputDevice):
return
self._firmware_updater.updateFirmware(firmware_file)
@pyqtProperty(bool, notify = activeChanged)
def active(self) -> bool:
"""
Indicates whether the printer is active, which is not the same as "being the active printer". In this case,
active means that the printer can be used. An example of an inactive printer is one that cannot be used because
the user doesn't have enough seats on Digital Factory.
"""
return self._active
def _setActive(self, active: bool) -> None:
if active != self._active:
self._active = active
self.activeChanged.emit()

View file

@ -1,12 +1,63 @@
import copy
import json
from typing import Optional, Dict
from PyQt6.QtCore import QBuffer
from PyQt6.QtGui import QImage, QImageWriter
import UM.View.GL.Texture
from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
from UM.View.GL.OpenGL import OpenGL
from UM.View.GL.Texture import Texture
class SliceableObjectDecorator(SceneNodeDecorator):
def __init__(self) -> None:
super().__init__()
self._paint_texture = None
self._texture_data_mapping: Dict[str, tuple[int, int]] = {}
def isSliceable(self) -> bool:
return True
def getPaintTexture(self) -> Optional[Texture]:
return self._paint_texture
def setPaintTexture(self, texture: Texture) -> None:
self._paint_texture = texture
def getTextureDataMapping(self) -> Dict[str, tuple[int, int]]:
return self._texture_data_mapping
def setTextureDataMapping(self, mapping: Dict[str, tuple[int, int]]) -> None:
self._texture_data_mapping = mapping
def prepareTexture(self, width: int, height: int) -> None:
if self._paint_texture is None:
self._paint_texture = OpenGL.getInstance().createTexture(width, height)
image = QImage(width, height, QImage.Format.Format_RGB32)
image.fill(0)
self._paint_texture.setImage(image)
def packTexture(self) -> Optional[bytearray]:
if self._paint_texture is None:
return None
texture_image = self._paint_texture.getImage()
if texture_image is None:
return None
texture_buffer = QBuffer()
texture_buffer.open(QBuffer.OpenModeFlag.ReadWrite)
image_writer = QImageWriter(texture_buffer, b"png")
image_writer.setText("Description", json.dumps(self._texture_data_mapping))
image_writer.write(texture_image)
return texture_buffer.data()
def __deepcopy__(self, memo) -> "SliceableObjectDecorator":
return type(self)()
copied_decorator = SliceableObjectDecorator()
copied_decorator.setPaintTexture(copy.deepcopy(self.getPaintTexture()))
copied_decorator.setTextureDataMapping(copy.deepcopy(self.getTextureDataMapping()))
return copied_decorator

View file

@ -402,6 +402,9 @@ class CuraContainerStack(ContainerStack):
return super().getProperty(key, property_name, context)
def getValue(self, key: str, context = None) -> Any:
return self.getProperty(key, "value", context)
class _ContainerIndexes:
"""Private helper class to keep track of container positions and their types."""

View file

@ -15,6 +15,7 @@ from UM.Scene.Selection import Selection
from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator
from UM.Settings.ContainerRegistry import ContainerRegistry # Finding containers by ID.
from cura.Machines.ContainerTree import ContainerTree
from cura.Settings.ExtruderStack import ExtruderStack
from typing import Any, cast, Dict, List, Optional, TYPE_CHECKING, Union
@ -304,6 +305,11 @@ class ExtruderManager(QObject):
Logger.log("e", "Unable to find one or more of the extruders in %s", used_extruder_stack_ids)
return []
def getFirstUsedExtruderStack(self)-> ExtruderStack:
used_extruders = self.getUsedExtruderStacks()
sorted_extruders = sorted(used_extruders, key=lambda extruder: extruder.getValue("extruder_nr"))
return sorted_extruders[0]
def getInitialExtruderNr(self) -> int:
"""Get the extruder that the print will start with.
@ -320,8 +326,7 @@ class ExtruderManager(QObject):
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
return self.getFirstUsedExtruderStack().getValue("extruder_nr")
else:
return skirt_brim_extruder_nr
if adhesion_type == "raft":
@ -332,7 +337,7 @@ class ExtruderManager(QObject):
return global_stack.getProperty("support_infill_extruder_nr", "value")
# REALLY no adhesion? Use the first used extruder.
return self.getUsedExtruderStacks()[0].getProperty("extruder_nr", "value")
return self.getFirstUsedExtruderStack().getValue("extruder_nr")
def removeMachineExtruders(self, machine_id: str) -> None:
"""Removes the container stack and user profile for the extruders for a specific machine.

View file

@ -183,10 +183,14 @@ class MachineManager(QObject):
self.setActiveMachine(active_machine_id)
def _onOutputDevicesChanged(self) -> None:
for printer_output_device in self._printer_output_devices:
printer_output_device.activeChanged.disconnect(self.printerConnectedStatusChanged)
self._printer_output_devices = []
for printer_output_device in self._application.getOutputDeviceManager().getOutputDevices():
if isinstance(printer_output_device, PrinterOutputDevice):
self._printer_output_devices.append(printer_output_device)
printer_output_device.activeChanged.connect(self.printerConnectedStatusChanged)
self.outputDevicesChanged.emit()
@ -569,6 +573,13 @@ class MachineManager(QObject):
def activeMachineIsUsingCloudConnection(self) -> bool:
return self.activeMachineHasCloudConnection and not self.activeMachineHasNetworkConnection
@pyqtProperty(bool, notify = printerConnectedStatusChanged)
def activeMachineIsActive(self) -> bool:
if not self._printer_output_devices:
return True
return self._printer_output_devices[0].active
def activeMachineNetworkKey(self) -> str:
if self._global_container_stack:
return self._global_container_stack.getMetaDataEntry("um_network_key", "")

View file

@ -1,6 +1,8 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional
from PyQt6.QtCore import pyqtProperty, QUrl
from UM.Stage import Stage
@ -13,8 +15,8 @@ from UM.Stage import Stage
# * The MainComponent is the component that will be drawn starting from the bottom of the stageBar and fills the rest
# of the screen.
class CuraStage(Stage):
def __init__(self, parent = None) -> None:
super().__init__(parent)
def __init__(self, parent = None, active_view: Optional[str] = "SolidView") -> None:
super().__init__(parent, active_view = active_view)
@pyqtProperty(str, constant = True)
def stageId(self) -> str:

View file

@ -16,7 +16,7 @@ from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
class XRayPass(RenderPass):
def __init__(self, width, height):
super().__init__("xray", width, height)
super().__init__("xray", width, height, -100)
self._shader = None
self._gl = OpenGL.getInstance().getBindingsObject()

View file

@ -1,9 +1,8 @@
# Copyright (c) 2022 UltiMaker B.V.
# Copyright (c) 2025 UltiMaker
# Cura's build system is released under the terms of the AGPLv3 or higher.
!define APP_NAME "{{ app_name }}"
!define COMP_NAME "{{ company }}"
!define WEB_SITE "{{ web_site }}"
!define VERSION "{{ version }}"
!define VIVERSION "{{ version_major }}.{{ version_minor }}.{{ version_patch }}.0"
!define COPYRIGHT "Copyright (c) {{ year }} {{ company }}"
@ -16,13 +15,11 @@
!define REG_APP_PATH "Software\Microsoft\Windows\CurrentVersion\App Paths\${APP_NAME}-${VERSION}"
!define UNINSTALL_PATH "Software\Microsoft\Windows\CurrentVersion\Uninstall\${APP_NAME}-${VERSION}"
!define REG_START_MENU "Start Menu Folder"
!define REG_START_MENU "Start Menu Shortcut"
;Require administrator access
RequestExecutionLevel admin
var SM_Folder
######################################################################
VIProductVersion "${VIVERSION}"
@ -64,11 +61,9 @@ InstallDir "$PROGRAMFILES64\${APP_NAME}"
!ifdef REG_START_MENU
!define MUI_STARTMENUPAGE_NODISABLE
!define MUI_STARTMENUPAGE_DEFAULTFOLDER "UltiMaker Cura"
!define MUI_STARTMENUPAGE_REGISTRY_ROOT "${REG_ROOT}"
!define MUI_STARTMENUPAGE_REGISTRY_KEY "${UNINSTALL_PATH}"
!define MUI_STARTMENUPAGE_REGISTRY_VALUENAME "${REG_START_MENU}"
!insertmacro MUI_PAGE_STARTMENU Application $SM_Folder
!endif
!insertmacro MUI_PAGE_INSTFILES
@ -107,27 +102,11 @@ SetOutPath "$INSTDIR"
WriteUninstaller "$INSTDIR\uninstall.exe"
!ifdef REG_START_MENU
!insertmacro MUI_STARTMENU_WRITE_BEGIN Application
CreateDirectory "$SMPROGRAMS\$SM_Folder"
CreateShortCut "$SMPROGRAMS\$SM_Folder\${APP_NAME}.lnk" "$INSTDIR\${MAIN_APP_EXE}"
CreateShortCut "$SMPROGRAMS\$SM_Folder\Uninstall ${APP_NAME}.lnk" "$INSTDIR\uninstall.exe"
!ifdef WEB_SITE
WriteIniStr "$INSTDIR\UltiMaker Cura website.url" "InternetShortcut" "URL" "${WEB_SITE}"
CreateShortCut "$SMPROGRAMS\$SM_Folder\UltiMaker Cura website.lnk" "$INSTDIR\UltiMaker Cura website.url"
!endif
!insertmacro MUI_STARTMENU_WRITE_END
CreateShortCut "$SMPROGRAMS\${APP_NAME}.lnk" "$INSTDIR\${MAIN_APP_EXE}"
!endif
!ifndef REG_START_MENU
CreateDirectory "$SMPROGRAMS\{{ app_name }}"
CreateShortCut "$SMPROGRAMS\{{ app_name }}\${APP_NAME}.lnk" "$INSTDIR\${MAIN_APP_EXE}"
CreateShortCut "$SMPROGRAMS\{{ app_name }}\Uninstall ${APP_NAME}.lnk" "$INSTDIR\uninstall.exe"
!ifdef WEB_SITE
WriteIniStr "$INSTDIR\UltiMaker Cura website.url" "InternetShortcut" "URL" "${WEB_SITE}"
CreateShortCut "$SMPROGRAMS\{{ app_name }}\UltiMaker Cura website.lnk" "$INSTDIR\UltiMaker Cura website.url"
!endif
CreateShortCut "$SMPROGRAMS\${APP_NAME}.lnk" "$INSTDIR\${MAIN_APP_EXE}"
!endif
WriteRegStr ${REG_ROOT} "${REG_APP_PATH}" "" "$INSTDIR\${MAIN_APP_EXE}"
@ -138,9 +117,6 @@ WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "DisplayIcon" "$INSTDIR\${MAIN_APP_
WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "DisplayVersion" "${VERSION}"
WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}"
!ifdef WEB_SITE
WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "URLInfoAbout" "${WEB_SITE}"
!endif
SectionEnd
######################################################################
@ -177,29 +153,17 @@ RmDir "$INSTDIR\share\uranium"
RmDir "$INSTDIR\share"
Delete "$INSTDIR\uninstall.exe"
!ifdef WEB_SITE
Delete "$INSTDIR\${APP_NAME} website.url"
!endif
RmDir /r /REBOOTOK "$INSTDIR"
!ifdef REG_START_MENU
!insertmacro MUI_STARTMENU_GETFOLDER "Application" $SM_Folder
Delete "$SMPROGRAMS\$SM_Folder\${APP_NAME}.lnk"
Delete "$SMPROGRAMS\$SM_Folder\Uninstall ${APP_NAME}.lnk"
!ifdef WEB_SITE
Delete "$SMPROGRAMS\$SM_Folder\UltiMaker Cura website.lnk"
!endif
RmDir "$SMPROGRAMS\$SM_Folder"
Delete "$SMPROGRAMS\${APP_NAME}.lnk"
Delete "$SMPROGRAMS\Uninstall ${APP_NAME}.lnk"
!endif
!ifndef REG_START_MENU
Delete "$SMPROGRAMS\{{ app_name }}\${APP_NAME}.lnk"
Delete "$SMPROGRAMS\{{ app_name }}\Uninstall ${APP_NAME}.lnk"
!ifdef WEB_SITE
Delete "$SMPROGRAMS\{{ app_name }}\UltiMaker Cura website.lnk"
!endif
RmDir "$SMPROGRAMS\{{ app_name }}"
Delete "$SMPROGRAMS\${APP_NAME}.lnk"
Delete "$SMPROGRAMS\Uninstall ${APP_NAME}.lnk"
!endif
!insertmacro APP_UNASSOCIATE "stl" "Cura.model"

View file

@ -1,4 +1,4 @@
# Copyright (c) 2022 UltiMaker
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
@ -51,7 +51,6 @@ def generate_nsi(source_path: str, dist_path: str, filename: str, version: str):
version_minor = str(parsed_version.minor),
version_patch = str(parsed_version.patch),
company = "UltiMaker",
web_site = "https://ultimaker.com",
year = datetime.now().year,
cura_license_file = str(source_loc.joinpath("packaging", "cura_license.txt")),
compression_method = "LZMA", # ZLIB, BZIP2 or LZMA

View file

@ -1,4 +1,4 @@
# Copyright (c) 2022 UltiMaker
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
@ -40,7 +40,6 @@ def generate_wxs(source_path: Path, dist_path: Path, filename: Path, app_name: s
version_minor=str(parsed_version.minor),
version_patch=str(parsed_version.patch),
company="UltiMaker",
web_site="https://ultimaker.com",
year=datetime.now().year,
upgrade_code=str(uuid.uuid5(uuid.NAMESPACE_DNS, app_name)),
cura_license_file=str(source_loc.joinpath("packaging", "msi", "cura_license.rtf")),

View file

@ -1,12 +1,14 @@
# Copyright (c) 2021-2022 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import json
import os.path
import zipfile
from typing import List, Optional, Union, TYPE_CHECKING, cast
import pySavitar as Savitar
import numpy
from PyQt6.QtCore import QBuffer
from PyQt6.QtGui import QImage, QImageReader
from UM.Logger import Logger
from UM.Math.Matrix import Matrix
@ -18,6 +20,8 @@ from UM.Scene.GroupDecorator import GroupDecorator
from UM.Scene.SceneNode import SceneNode # For typing.
from UM.Scene.SceneNodeSettings import SceneNodeSettings
from UM.Util import parseBool
from UM.View.GL.OpenGL import OpenGL
from UM.View.GL.Texture import Texture
from cura.CuraApplication import CuraApplication
from cura.Machines.ContainerTree import ContainerTree
from cura.Scene.BuildPlateDecorator import BuildPlateDecorator
@ -94,14 +98,14 @@ class ThreeMFReader(MeshReader):
return temp_mat
@staticmethod
def _convertSavitarNodeToUMNode(savitar_node: Savitar.SceneNode, file_name: str = "") -> Optional[SceneNode]:
def _convertSavitarNodeToUMNode(savitar_node: Savitar.SceneNode, file_name: str = "", archive: zipfile.ZipFile = None, scene: Savitar.Scene = None) -> Optional[SceneNode]:
"""Convenience function that converts a SceneNode object (as obtained from libSavitar) to a scene node.
:returns: Scene node.
"""
try:
node_name = savitar_node.getName()
node_id = savitar_node.getId()
node_id = str(savitar_node.getId())
except AttributeError:
Logger.log("e", "Outdated version of libSavitar detected! Please update to the newest version!")
node_name = ""
@ -115,6 +119,10 @@ class ThreeMFReader(MeshReader):
active_build_plate = CuraApplication.getInstance().getMultiBuildPlateModel().activeBuildPlate
component_path = savitar_node.getComponentPath()
if component_path != "" and archive is not None:
savitar_node.parseComponentData(archive.open(component_path.lstrip("/")).read())
um_node = CuraSceneNode() # This adds a SettingOverrideDecorator
um_node.addDecorator(BuildPlateDecorator(active_build_plate))
try:
@ -127,23 +135,31 @@ class ThreeMFReader(MeshReader):
um_node.setTransformation(transformation)
mesh_builder = MeshBuilder()
data = numpy.fromstring(savitar_node.getMeshData().getFlatVerticesAsBytes(), dtype=numpy.float32)
mesh_data = savitar_node.getMeshData()
vertices_data = numpy.fromstring(mesh_data.getFlatVerticesAsBytes(), dtype=numpy.float32)
vertices = numpy.resize(vertices_data, (int(vertices_data.size / 3), 3))
texture_path = mesh_data.getTexturePath(scene)
uv_data = numpy.fromstring(mesh_data.getUVCoordinatesPerVertexAsBytes(scene), dtype=numpy.float32)
uv_coordinates = numpy.resize(uv_data, (int(uv_data.size / 2), 2))
vertices = numpy.resize(data, (int(data.size / 3), 3))
mesh_builder.setVertices(vertices)
mesh_builder.calculateNormals(fast=True)
mesh_builder.setMeshId(node_id)
mesh_builder.setUVCoordinates(uv_coordinates)
if file_name:
# The filename is used to give the user the option to reload the file if it is changed on disk
# It is only set for the root node of the 3mf file
mesh_builder.setFileName(file_name)
mesh_data = mesh_builder.build()
if len(mesh_data.getVertices()):
um_node.setMeshData(mesh_data)
for child in savitar_node.getChildren():
child_node = ThreeMFReader._convertSavitarNodeToUMNode(child)
child_node = ThreeMFReader._convertSavitarNodeToUMNode(child, archive=archive, scene=scene)
if child_node:
um_node.addChild(child_node)
@ -215,6 +231,30 @@ class ThreeMFReader(MeshReader):
# affects (auto) slicing
sliceable_decorator = SliceableObjectDecorator()
um_node.addDecorator(sliceable_decorator)
if texture_path != "" and archive is not None:
texture_data = archive.open(texture_path).read()
texture_buffer = QBuffer()
texture_buffer.open(QBuffer.OpenModeFlag.ReadWrite)
texture_buffer.write(texture_data)
image_reader = QImageReader(texture_buffer, b"png")
texture_buffer.seek(0)
texture_image = image_reader.read()
texture = Texture(OpenGL.getInstance())
texture.setImage(texture_image)
sliceable_decorator.setPaintTexture(texture)
texture_buffer.seek(0)
data_mapping_desc = image_reader.text("Description")
if data_mapping_desc != "":
data_mapping = json.loads(data_mapping_desc)
for key, value in data_mapping.items():
# Tuples are stored as lists in json, restore them back to tuples
data_mapping[key] = tuple(value)
sliceable_decorator.setTextureDataMapping(data_mapping)
return um_node
def _read(self, file_name: str) -> Union[SceneNode, List[SceneNode]]:
@ -232,7 +272,7 @@ class ThreeMFReader(MeshReader):
CuraApplication.getInstance().getController().getScene().setMetaDataEntry(key, value)
for node in scene_3mf.getSceneNodes():
um_node = ThreeMFReader._convertSavitarNodeToUMNode(node, file_name)
um_node = ThreeMFReader._convertSavitarNodeToUMNode(node, file_name, archive, scene_3mf)
if um_node is None:
continue
@ -332,7 +372,7 @@ class ThreeMFReader(MeshReader):
# Convert the scene to scene nodes
nodes = []
for savitar_node in scene.getSceneNodes():
scene_node = ThreeMFReader._convertSavitarNodeToUMNode(savitar_node, "file_name")
scene_node = ThreeMFReader._convertSavitarNodeToUMNode(savitar_node, "file_name", scene=scene)
if scene_node is None:
continue
nodes.append(scene_node)

View file

@ -23,7 +23,7 @@ def getMetaData() -> Dict:
if "3MFReader.ThreeMFReader" in sys.modules:
metaData["mesh_reader"] = [
{
"extension": "3mf",
"extension": workspace_extension,
"description": catalog.i18nc("@item:inlistbox", "3MF File")
}
]

View file

@ -0,0 +1,176 @@
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
import hashlib
import json
from io import StringIO
import xml.etree.ElementTree as ET
import zipfile
from PyQt6.QtCore import Qt, QBuffer
from PyQt6.QtGui import QImage
from UM.Application import Application
from UM.Logger import Logger
from UM.Mesh.MeshWriter import MeshWriter
from UM.PluginRegistry import PluginRegistry
from typing import cast
from cura.CuraApplication import CuraApplication
from .ThreeMFVariant import ThreeMFVariant
from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura")
# Path constants
METADATA_PATH = "Metadata"
THUMBNAIL_PATH_MULTIPLATE = f"{METADATA_PATH}/plate_1.png"
THUMBNAIL_PATH_MULTIPLATE_SMALL = f"{METADATA_PATH}/plate_1_small.png"
GCODE_PATH = f"{METADATA_PATH}/plate_1.gcode"
GCODE_MD5_PATH = f"{GCODE_PATH}.md5"
MODEL_SETTINGS_PATH = f"{METADATA_PATH}/model_settings.config"
PLATE_DESC_PATH = f"{METADATA_PATH}/plate_1.json"
SLICE_INFO_PATH = f"{METADATA_PATH}/slice_info.config"
PROJECT_SETTINGS_PATH = f"{METADATA_PATH}/project_settings.config"
class BambuLabVariant(ThreeMFVariant):
"""BambuLab specific implementation of the 3MF format."""
@property
def mime_type(self) -> str:
return "application/vnd.bambulab-package.3dmanufacturing-3dmodel+xml"
def process_thumbnail(self, snapshot: QImage, thumbnail_buffer: QBuffer,
archive: zipfile.ZipFile, relations_element: ET.Element) -> None:
"""Process the thumbnail for BambuLab variant."""
# Write thumbnail
archive.writestr(zipfile.ZipInfo(THUMBNAIL_PATH_MULTIPLATE), thumbnail_buffer.data())
# Add relations elements for thumbnails
ET.SubElement(relations_element, "Relationship",
Target="/" + THUMBNAIL_PATH_MULTIPLATE, Id="rel-2",
pe="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail")
ET.SubElement(relations_element, "Relationship",
Target="/" + THUMBNAIL_PATH_MULTIPLATE, Id="rel-4",
Type="http://schemas.bambulab.com/package/2021/cover-thumbnail-middle")
# Create and save small thumbnail
small_snapshot = snapshot.scaled(128, 128, transformMode=Qt.TransformationMode.SmoothTransformation)
small_thumbnail_buffer = QBuffer()
small_thumbnail_buffer.open(QBuffer.OpenModeFlag.ReadWrite)
small_snapshot.save(small_thumbnail_buffer, "PNG")
# Write small thumbnail
archive.writestr(zipfile.ZipInfo(THUMBNAIL_PATH_MULTIPLATE_SMALL), small_thumbnail_buffer.data())
# Add relation for small thumbnail
ET.SubElement(relations_element, "Relationship",
Target="/" + THUMBNAIL_PATH_MULTIPLATE_SMALL, Id="rel-5",
Type="http://schemas.bambulab.com/package/2021/cover-thumbnail-small")
def add_extra_files(self, archive: zipfile.ZipFile, metadata_relations_element: ET.Element) -> None:
"""Add BambuLab specific files to the archive."""
self._storeGCode(archive, metadata_relations_element)
self._storeModelSettings(archive)
self._storePlateDesc(archive)
self._storeSliceInfo(archive)
self._storeProjectSettings(archive)
def _storeGCode(self, archive: zipfile.ZipFile, metadata_relations_element: ET.Element):
"""Store GCode data in the archive."""
gcode_textio = StringIO()
gcode_writer = cast(MeshWriter, PluginRegistry.getInstance().getPluginObject("GCodeWriter"))
success = gcode_writer.write(gcode_textio, None)
if not success:
error_msg = catalog.i18nc("@info:error", "Can't write GCode to 3MF file")
self._writer.setInformation(error_msg)
Logger.error(error_msg)
raise Exception(error_msg)
gcode_data = gcode_textio.getvalue().encode("UTF-8")
archive.writestr(zipfile.ZipInfo(GCODE_PATH), gcode_data)
gcode_relation_element = ET.SubElement(metadata_relations_element, "Relationship",
Target=f"/{GCODE_PATH}", Id="rel-1",
Type="http://schemas.bambulab.com/package/2021/gcode")
# Calculate and store the MD5 sum of the gcode data
md5_hash = hashlib.md5(gcode_data).hexdigest()
archive.writestr(zipfile.ZipInfo(GCODE_MD5_PATH), md5_hash.encode("UTF-8"))
def _storeModelSettings(self, archive: zipfile.ZipFile):
"""Store model settings in the archive."""
config = ET.Element("config")
plate = ET.SubElement(config, "plate")
ET.SubElement(plate, "metadata", key="plater_id", value="1")
ET.SubElement(plate, "metadata", key="plater_name", value="")
ET.SubElement(plate, "metadata", key="locked", value="false")
ET.SubElement(plate, "metadata", key="filament_map_mode", value="Auto For Flush")
extruders_count = len(CuraApplication.getInstance().getExtruderManager().extruderIds)
ET.SubElement(plate, "metadata", key="filament_maps", value=" ".join("1" for _ in range(extruders_count)))
ET.SubElement(plate, "metadata", key="gcode_file", value=GCODE_PATH)
ET.SubElement(plate, "metadata", key="thumbnail_file", value=THUMBNAIL_PATH_MULTIPLATE)
ET.SubElement(plate, "metadata", key="pattern_bbox_file", value=PLATE_DESC_PATH)
self._writer._storeElementTree(archive, MODEL_SETTINGS_PATH, config)
def _storePlateDesc(self, archive: zipfile.ZipFile):
"""Store plate description in the archive."""
plate_desc = {}
filament_ids = []
filament_colors = []
for extruder in CuraApplication.getInstance().getExtruderManager().getUsedExtruderStacks():
filament_ids.append(extruder.getValue("extruder_nr"))
filament_colors.append(self._writer._getMaterialColor(extruder))
plate_desc["filament_ids"] = filament_ids
plate_desc["filament_colors"] = filament_colors
plate_desc["first_extruder"] = CuraApplication.getInstance().getExtruderManager().getInitialExtruderNr()
plate_desc["is_seq_print"] = Application.getInstance().getGlobalContainerStack().getValue("print_sequence") == "one_at_a_time"
plate_desc["nozzle_diameter"] = CuraApplication.getInstance().getExtruderManager().getActiveExtruderStack().getValue("machine_nozzle_size")
plate_desc["version"] = 2
file = zipfile.ZipInfo(PLATE_DESC_PATH)
file.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(file, json.dumps(plate_desc).encode("UTF-8"))
def _storeSliceInfo(self, archive: zipfile.ZipFile):
"""Store slice information in the archive."""
config = ET.Element("config")
header = ET.SubElement(config, "header")
ET.SubElement(header, "header_item", key="X-BBL-Client-Type", value="slicer")
ET.SubElement(header, "header_item", key="X-BBL-Client-Version", value="02.00.01.50")
plate = ET.SubElement(config, "plate")
ET.SubElement(plate, "metadata", key="index", value="1")
ET.SubElement(plate,
"metadata",
key="nozzle_diameters",
value=str(CuraApplication.getInstance().getExtruderManager().getActiveExtruderStack().getValue("machine_nozzle_size")))
print_information = CuraApplication.getInstance().getPrintInformation()
for index, extruder in enumerate(Application.getInstance().getGlobalContainerStack().extruderList):
used_m = print_information.materialLengths[index]
used_g = print_information.materialWeights[index]
if used_m > 0.0 and used_g > 0.0:
ET.SubElement(plate,
"filament",
id=str(extruder.getValue("extruder_nr") + 1),
tray_info_idx="GFA00",
type=extruder.material.getMetaDataEntry("material", ""),
color=self._writer._getMaterialColor(extruder),
used_m=str(used_m),
used_g=str(used_g))
self._writer._storeElementTree(archive, SLICE_INFO_PATH, config)
def _storeProjectSettings(self, archive: zipfile.ZipFile):
api = CuraApplication.getInstance().getCuraAPI()
file = zipfile.ZipInfo(PROJECT_SETTINGS_PATH)
json_string = json.dumps(api.interface.settings.getAllGlobalSettings(), separators=(", ", ": "), indent=4)
archive.writestr(file, json_string.encode("UTF-8"))

View file

@ -0,0 +1,33 @@
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
import xml.etree.ElementTree as ET
import zipfile
from PyQt6.QtCore import QBuffer
from PyQt6.QtGui import QImage
from .ThreeMFVariant import ThreeMFVariant
# Standard 3MF paths
METADATA_PATH = "Metadata"
THUMBNAIL_PATH = f"{METADATA_PATH}/thumbnail.png"
class Cura3mfVariant(ThreeMFVariant):
"""Default implementation of the 3MF format."""
@property
def mime_type(self) -> str:
return "application/vnd.ms-package.3dmanufacturing-3dmodel+xml"
def process_thumbnail(self, snapshot: QImage, thumbnail_buffer: QBuffer,
archive: zipfile.ZipFile, relations_element: ET.Element) -> None:
"""Process the thumbnail for default 3MF variant."""
thumbnail_file = zipfile.ZipInfo(THUMBNAIL_PATH)
# Don't try to compress snapshot file, because the PNG is pretty much as compact as it will get
archive.writestr(thumbnail_file, thumbnail_buffer.data())
# Add thumbnail relation to _rels/.rels file
ET.SubElement(relations_element, "Relationship",
Target="/" + THUMBNAIL_PATH, Id="rel1",
Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail")

View file

@ -0,0 +1,74 @@
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
import xml.etree.ElementTree as ET
import zipfile
from PyQt6.QtGui import QImage
from PyQt6.QtCore import QBuffer
if TYPE_CHECKING:
from .ThreeMFWriter import ThreeMFWriter
class ThreeMFVariant(ABC):
"""Base class for 3MF format variants.
Different vendors may have their own extensions to the 3MF format,
such as BambuLab's 3MF variant. This class provides an interface
for implementing these variants.
"""
def __init__(self, writer: 'ThreeMFWriter'):
"""
:param writer: The ThreeMFWriter instance that will use this variant
"""
self._writer = writer
@property
@abstractmethod
def mime_type(self) -> str:
"""The MIME type for this 3MF variant."""
pass
def handles_mime_type(self, mime_type: str) -> bool:
"""Check if this variant handles the given MIME type.
:param mime_type: The MIME type to check
:return: True if this variant handles the MIME type, False otherwise
"""
return mime_type == self.mime_type
def prepare_content_types(self, content_types: ET.Element) -> None:
"""Prepare the content types XML element for this variant.
:param content_types: The content types XML element
"""
pass
def prepare_relations(self, relations_element: ET.Element) -> None:
"""Prepare the relations XML element for this variant.
:param relations_element: The relations XML element
"""
pass
def process_thumbnail(self, snapshot: QImage, thumbnail_buffer: QBuffer,
archive: zipfile.ZipFile, relations_element: ET.Element) -> None:
"""Process the thumbnail for this variant.
:param snapshot: The snapshot image
:param thumbnail_buffer: Buffer containing the thumbnail data
:param archive: The zip archive to write to
:param relations_element: The relations XML element
"""
pass
def add_extra_files(self, archive: zipfile.ZipFile, metadata_relations_element: ET.Element) -> None:
"""Add any extra files required by this variant to the archive.
:param archive: The zip archive to write to
:param metadata_relations_element: The metadata relations XML element
"""
pass

View file

@ -1,11 +1,13 @@
# Copyright (c) 2015-2022 Ultimaker B.V.
# Copyright (c) 2015-2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
import json
import re
import threading
from typing import Optional, cast, List, Dict, Pattern, Set
from typing import Optional, cast, List, Dict, Set
from UM.PluginRegistry import PluginRegistry
from UM.Mesh.MeshWriter import MeshWriter
from UM.Math.Vector import Vector
from UM.Logger import Logger
@ -19,7 +21,9 @@ from UM.Settings.ContainerRegistry import ContainerRegistry
from cura.CuraApplication import CuraApplication
from cura.CuraPackageManager import CuraPackageManager
from cura.Machines.Models.ExtrudersModel import ExtrudersModel
from cura.Settings import CuraContainerStack
from cura.Settings.ExtruderStack import ExtruderStack
from cura.Utils.Threading import call_on_qt_thread
from cura.Scene.CuraSceneNode import CuraSceneNode
from cura.Snapshot import Snapshot
@ -45,13 +49,17 @@ import UM.Application
from .SettingsExportModel import SettingsExportModel
from .SettingsExportGroup import SettingsExportGroup
from .ThreeMFVariant import ThreeMFVariant
from .Cura3mfVariant import Cura3mfVariant
from .BambuLabVariant import BambuLabVariant
from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura")
THUMBNAIL_PATH = "Metadata/thumbnail.png"
MODEL_PATH = "3D/3dmodel.model"
PACKAGE_METADATA_PATH = "Cura/packages.json"
TEXTURES_PATH = "3D/Textures"
MODEL_RELATIONS_PATH = "3D/_rels/3dmodel.model.rels"
class ThreeMFWriter(MeshWriter):
def __init__(self):
@ -68,6 +76,12 @@ class ThreeMFWriter(MeshWriter):
self._store_archive = False
self._lock = threading.Lock()
# Register available variants
self._variants = {
Cura3mfVariant(self).mime_type: Cura3mfVariant,
BambuLabVariant(self).mime_type: BambuLabVariant
}
@staticmethod
def _convertMatrixToString(matrix):
result = ""
@ -97,7 +111,11 @@ class ThreeMFWriter(MeshWriter):
def _convertUMNodeToSavitarNode(um_node,
transformation = Matrix(),
exported_settings: Optional[Dict[str, Set[str]]] = None,
center_mesh = False):
center_mesh = False,
scene: Savitar.Scene = None,
archive: zipfile.ZipFile = None,
model_relations_element: ET.Element = None,
content_types_element: ET.Element = None):
"""Convenience function that converts an Uranium SceneNode object to a SavitarSceneNode
:returns: Uranium Scene node.
@ -138,7 +156,28 @@ class ThreeMFWriter(MeshWriter):
if indices_array is not None:
savitar_node.getMeshData().setFacesFromBytes(indices_array)
else:
savitar_node.getMeshData().setFacesFromBytes(numpy.arange(mesh_data.getVertices().size / 3, dtype=numpy.int32).tostring())
savitar_node.getMeshData().setFacesFromBytes(numpy.arange(mesh_data.getVertices().size / 3, dtype=numpy.int32).tobytes())
packed_texture = um_node.callDecoration("packTexture")
uv_coordinates_array = mesh_data.getUVCoordinatesAsByteArray()
if packed_texture is not None and archive is not None and uv_coordinates_array is not None and len(uv_coordinates_array) > 0:
texture_path = f"{TEXTURES_PATH}/{id(um_node)}.png"
texture_file = zipfile.ZipInfo(texture_path)
# Don't try to compress texture file, because the PNG is pretty much as compact as it will get
archive.writestr(texture_file, packed_texture)
savitar_node.getMeshData().setUVCoordinatesPerVertexAsBytes(uv_coordinates_array, texture_path, scene)
# Add texture relation to model relations file
if model_relations_element is not None:
ET.SubElement(model_relations_element, "Relationship",
Target=texture_path, Id=f"rel{len(model_relations_element)+1}",
Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dtexture")
if content_types_element is not None:
ET.SubElement(content_types_element, "Override", PartName=texture_path,
ContentType="application/vnd.ms-package.3dmanufacturing-3dmodeltexture")
# Handle per object settings (if any)
stack = um_node.callDecoration("getStack")
@ -175,7 +214,11 @@ class ThreeMFWriter(MeshWriter):
if child_node.callDecoration("getBuildPlateNumber") != active_build_plate_nr:
continue
savitar_child_node = ThreeMFWriter._convertUMNodeToSavitarNode(child_node,
exported_settings = exported_settings)
exported_settings = exported_settings,
scene = scene,
archive = archive,
model_relations_element = model_relations_element,
content_types_element = content_types_element)
if savitar_child_node is not None:
savitar_node.addChild(savitar_child_node)
@ -201,26 +244,51 @@ class ThreeMFWriter(MeshWriter):
painter.end()
def write(self, stream, nodes, mode = MeshWriter.OutputMode.BinaryMode, export_settings_model = None) -> bool:
def _getVariant(self, mime_type: str) -> ThreeMFVariant:
"""Get the appropriate variant for the given MIME type.
:param mime_type: The MIME type to get the variant for
:return: An instance of the variant for the given MIME type
"""
variant_class = self._variants.get(mime_type, Cura3mfVariant)
return variant_class(self)
def write(self, stream, nodes, mode = MeshWriter.OutputMode.BinaryMode, export_settings_model = None, **kwargs) -> bool:
self._archive = None # Reset archive
archive = zipfile.ZipFile(stream, "w", compression = zipfile.ZIP_DEFLATED)
# Determine which variant to use based on mime type in kwargs
mime_type = kwargs.get("mime_type", Cura3mfVariant(self).mime_type)
variant = self._getVariant(mime_type)
try:
model_file = zipfile.ZipInfo(MODEL_PATH)
# Because zipfile is stupid and ignores archive-level compression settings when writing with ZipInfo.
model_file.compress_type = zipfile.ZIP_DEFLATED
# Create content types file
content_types_file = zipfile.ZipInfo("[Content_Types].xml")
content_types_file.compress_type = zipfile.ZIP_DEFLATED
content_types = ET.Element("Types", xmlns = self._namespaces["content-types"])
rels_type = ET.SubElement(content_types, "Default", Extension = "rels", ContentType = "application/vnd.openxmlformats-package.relationships+xml")
model_type = ET.SubElement(content_types, "Default", Extension = "model", ContentType = "application/vnd.ms-package.3dmanufacturing-3dmodel+xml")
# Create _rels/.rels file
relations_file = zipfile.ZipInfo("_rels/.rels")
relations_file.compress_type = zipfile.ZIP_DEFLATED
relations_element = ET.Element("Relationships", xmlns = self._namespaces["relationships"])
model_relation_element = ET.SubElement(relations_element, "Relationship", Target = "/" + MODEL_PATH, Id = "rel0", Type = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel")
relations_element = self._makeRelationsTree()
model_relation_element = ET.SubElement(relations_element, "Relationship", Target="/" + MODEL_PATH,
Id="rel0",
Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel")
# Create Metadata/_rels/model_settings.config.rels
metadata_relations_element = self._makeRelationsTree()
# Create model relations
model_relations_element = self._makeRelationsTree()
# Let the variant add its specific files
variant.add_extra_files(archive, metadata_relations_element)
# Let the variant prepare content types and relations
variant.prepare_content_types(content_types)
variant.prepare_relations(relations_element)
# Attempt to add a thumbnail
snapshot = self._createSnapshot()
@ -233,16 +301,11 @@ class ThreeMFWriter(MeshWriter):
thumbnail_buffer.open(QBuffer.OpenModeFlag.ReadWrite)
snapshot.save(thumbnail_buffer, "PNG")
thumbnail_file = zipfile.ZipInfo(THUMBNAIL_PATH)
# Don't try to compress snapshot file, because the PNG is pretty much as compact as it will get
archive.writestr(thumbnail_file, thumbnail_buffer.data())
# Add PNG to content types file
thumbnail_type = ET.SubElement(content_types, "Default", Extension="png", ContentType="image/png")
# Add thumbnail relation to _rels/.rels file
thumbnail_relation_element = ET.SubElement(relations_element, "Relationship",
Target="/" + THUMBNAIL_PATH, Id="rel1",
Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail")
# Let the variant process the thumbnail
variant.process_thumbnail(snapshot, thumbnail_buffer, archive, relations_element)
# Write material metadata
packages_metadata = self._getMaterialPackageMetadata() + self._getPluginPackageMetadata()
@ -291,13 +354,21 @@ class ThreeMFWriter(MeshWriter):
savitar_node = ThreeMFWriter._convertUMNodeToSavitarNode(root_child,
transformation_matrix,
exported_model_settings,
center_mesh = True)
center_mesh = True,
scene = savitar_scene,
archive = archive,
model_relations_element = model_relations_element,
content_types_element = content_types)
if savitar_node:
savitar_scene.addSceneNode(savitar_node)
else:
savitar_node = self._convertUMNodeToSavitarNode(node,
transformation_matrix,
exported_model_settings)
exported_model_settings,
scene = savitar_scene,
archive = archive,
model_relations_element = model_relations_element,
content_types_element = content_types)
if savitar_node:
savitar_scene.addSceneNode(savitar_node)
@ -305,8 +376,12 @@ class ThreeMFWriter(MeshWriter):
scene_string = parser.sceneToString(savitar_scene)
archive.writestr(model_file, scene_string)
archive.writestr(content_types_file, b'<?xml version="1.0" encoding="UTF-8"?> \n' + ET.tostring(content_types))
archive.writestr(relations_file, b'<?xml version="1.0" encoding="UTF-8"?> \n' + ET.tostring(relations_element))
self._storeElementTree(archive, "[Content_Types].xml", content_types)
self._storeElementTree(archive, "_rels/.rels", relations_element)
if len(metadata_relations_element) > 0:
self._storeElementTree(archive, "Metadata/_rels/model_settings.config.rels", metadata_relations_element)
if len(model_relations_element) > 0:
self._storeElementTree(archive, MODEL_RELATIONS_PATH, model_relations_element)
except Exception as error:
Logger.logException("e", "Error writing zip file")
self.setInformation(str(error))
@ -319,6 +394,25 @@ class ThreeMFWriter(MeshWriter):
return True
@staticmethod
def _storeElementTree(archive: zipfile.ZipFile, file_path: str, root_element: ET.Element):
file = zipfile.ZipInfo(file_path)
file.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(file, b'<?xml version="1.0" encoding="UTF-8"?> \n' + ET.tostring(root_element))
def _makeRelationsTree(self):
return ET.Element("Relationships", xmlns=self._namespaces["relationships"])
@staticmethod
def _getMaterialColor(extruder: "ExtruderStack") -> str:
position = int(extruder.getMetaDataEntry("position", default="0"))
try:
default_color = ExtrudersModel.defaultColors[position]
except IndexError:
default_color = "#e0e000"
color_code = extruder.material.getMetaDataEntry("color_code", default=default_color)
return color_code.upper()
@staticmethod
def _storeMetadataJson(metadata: Dict[str, List[Dict[str, str]]], archive: zipfile.ZipFile, path: str) -> None:
"""Stores metadata inside archive path as json file"""
@ -450,7 +544,7 @@ class ThreeMFWriter(MeshWriter):
def sceneNodesToString(scene_nodes: [SceneNode]) -> str:
savitar_scene = Savitar.Scene()
for scene_node in scene_nodes:
savitar_node = ThreeMFWriter._convertUMNodeToSavitarNode(scene_node, center_mesh = True)
savitar_node = ThreeMFWriter._convertUMNodeToSavitarNode(scene_node, center_mesh = True, scene = savitar_scene)
savitar_scene.addSceneNode(savitar_node)
parser = Savitar.ThreeMFParser()
scene_string = parser.sceneToString(savitar_scene)

View file

@ -28,11 +28,17 @@ def getMetaData():
metaData["mesh_writer"] = {
"output": [
{
"extension": "3mf",
"extension": workspace_extension,
"description": i18n_catalog.i18nc("@item:inlistbox", "3MF file"),
"mime_type": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
"mode": ThreeMFWriter.ThreeMFWriter.OutputMode.BinaryMode
},
{
"extension": f"gcode.{workspace_extension}",
"description": i18n_catalog.i18nc("@item:inlistbox", "BambuLab 3MF file"),
"mime_type": "application/vnd.bambulab-package.3dmanufacturing-3dmodel+xml",
"mode": ThreeMFWorkspaceWriter.ThreeMFWorkspaceWriter.OutputMode.BinaryMode
}
]
}
metaData["workspace_writer"] = {
@ -44,7 +50,7 @@ def getMetaData():
"mode": ThreeMFWorkspaceWriter.ThreeMFWorkspaceWriter.OutputMode.BinaryMode
},
{
"extension": "3mf",
"extension": workspace_extension,
"description": i18n_catalog.i18nc("@item:inlistbox", "Universal Cura Project"),
"mime_type": "application/x-ucp",
"mode": ThreeMFWorkspaceWriter.ThreeMFWorkspaceWriter.OutputMode.BinaryMode

View file

@ -1,4 +1,4 @@
# Copyright (c) 2020 Ultimaker B.V.
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
import json
import threading
@ -13,11 +13,14 @@ from UM.Message import Message
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope
from UM.i18n import i18nCatalog
from cura.ApplicationMetadata import CuraSDKVersion
from cura.CuraApplication import CuraApplication
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
import cura.UltimakerCloud.UltimakerCloudConstants as UltimakerCloudConstants
catalog = i18nCatalog("cura")
PACKAGES_URL = f"{UltimakerCloudConstants.CuraCloudAPIRoot}/cura-packages/v{UltimakerCloudConstants.CuraCloudAPIVersion}/cura/v{CuraSDKVersion}/packages"
class CreateBackupJob(Job):
"""Creates backup zip, requests upload url and uploads the backup file to cloud storage."""
@ -40,23 +43,54 @@ class CreateBackupJob(Job):
self._job_done = threading.Event()
"""Set when the job completes. Does not indicate success."""
self.backup_upload_error_message = ""
"""After the job completes, an empty string indicates success. Othrerwise, the value is a translated message."""
"""After the job completes, an empty string indicates success. Otherwise, the value is a translated message."""
def _setPluginFetchErrorMessage(self, error_msg: str) -> None:
Logger.error(f"Fetching plugins for backup resulted in error: {error_msg}")
self.backup_upload_error_message = "Couldn't update currently available plugins, backup stopped."
self._upload_message.hide()
self._job_done.set()
def run(self) -> None:
upload_message = Message(catalog.i18nc("@info:backup_status", "Creating your backup..."),
self._upload_message = Message(catalog.i18nc("@info:backup_status", "Fetch re-downloadable package-ids..."),
title = self.MESSAGE_TITLE,
progress = -1)
upload_message.show()
self._upload_message.show()
CuraApplication.getInstance().processEvents()
if CuraApplication.getInstance().getCuraAPI().backups.shouldReinstallDownloadablePlugins():
request_url = f"{PACKAGES_URL}?package_type=plugin"
scope = JsonDecoratorScope(UltimakerCloudScope(CuraApplication.getInstance()))
HttpRequestManager.getInstance().get(
request_url,
scope=scope,
callback=self._continueRun,
error_callback=lambda reply, error: self._setPluginFetchErrorMessage(str(error)),
)
else:
self._continueRun()
def _continueRun(self, reply: "QNetworkReply" = None) -> None:
if reply is not None:
response_data = HttpRequestManager.readJSON(reply)
if "data" not in response_data:
self._setPluginFetchErrorMessage(f"Missing 'data' from response. Keys in response: {response_data.keys()}")
return
available_remote_plugins = frozenset({v["package_id"] for v in response_data["data"]})
else:
available_remote_plugins = frozenset()
self._upload_message.setText(catalog.i18nc("@info:backup_status", "Creating your backup..."))
CuraApplication.getInstance().processEvents()
cura_api = CuraApplication.getInstance().getCuraAPI()
self._backup_zip, backup_meta_data = cura_api.backups.createBackup()
self._backup_zip, backup_meta_data = cura_api.backups.createBackup(available_remote_plugins)
if not self._backup_zip or not backup_meta_data:
self.backup_upload_error_message = catalog.i18nc("@info:backup_status", "There was an error while creating your backup.")
upload_message.hide()
self._upload_message.hide()
return
upload_message.setText(catalog.i18nc("@info:backup_status", "Uploading your backup..."))
self._upload_message.setText(catalog.i18nc("@info:backup_status", "Uploading your backup..."))
CuraApplication.getInstance().processEvents()
# Create an upload entry for the backup.
@ -64,13 +98,18 @@ class CreateBackupJob(Job):
backup_meta_data["description"] = "{}.backup.{}.cura.zip".format(timestamp, backup_meta_data["cura_release"])
self._requestUploadSlot(backup_meta_data, len(self._backup_zip))
self._job_done.wait()
# Note: One 'process events' call wasn't enough with the changed situation somehow.
for _ in range(5000):
CuraApplication.getInstance().processEvents()
if self._job_done.wait(0.02):
break
if self.backup_upload_error_message == "":
upload_message.setText(catalog.i18nc("@info:backup_status", "Your backup has finished uploading."))
upload_message.setProgress(None) # Hide progress bar
self._upload_message.setText(catalog.i18nc("@info:backup_status", "Your backup has finished uploading."))
self._upload_message.setProgress(None) # Hide progress bar
else:
# some error occurred. This error is presented to the user by DrivePluginExtension
upload_message.hide()
self._upload_message.hide()
def _requestUploadSlot(self, backup_metadata: Dict[str, Any], backup_size: int) -> None:
"""Request a backup upload slot from the API.
@ -83,7 +122,6 @@ class CreateBackupJob(Job):
"metadata": backup_metadata
}
}).encode()
HttpRequestManager.getInstance().put(
self._api_backup_url,
data = payload,

View file

@ -1,8 +1,9 @@
# Copyright (c) 2021 Ultimaker B.V.
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
import base64
import hashlib
import json
import os
import threading
from tempfile import NamedTemporaryFile
from typing import Optional, Any, Dict
@ -12,9 +13,16 @@ from PyQt6.QtNetwork import QNetworkReply, QNetworkRequest
from UM.Job import Job
from UM.Logger import Logger
from UM.PackageManager import catalog
from UM.Resources import Resources
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from cura.CuraApplication import CuraApplication
from UM.Version import Version
from cura.ApplicationMetadata import CuraSDKVersion
from cura.CuraApplication import CuraApplication
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
import cura.UltimakerCloud.UltimakerCloudConstants as UltimakerCloudConstants
PACKAGES_URL_TEMPLATE = f"{UltimakerCloudConstants.CuraCloudAPIRoot}/cura-packages/v{UltimakerCloudConstants.CuraCloudAPIVersion}/cura/v{{0}}/packages/{{1}}/download"
class RestoreBackupJob(Job):
"""Downloads a backup and overwrites local configuration with the backup.
@ -38,7 +46,6 @@ class RestoreBackupJob(Job):
self.restore_backup_error_message = ""
def run(self) -> None:
url = self._backup.get("download_url")
assert url is not None
@ -48,7 +55,11 @@ class RestoreBackupJob(Job):
error_callback = self._onRestoreRequestCompleted
)
self._job_done.wait() # A job is considered finished when the run function completes
# Note: Just to be sure, use the same structure here as in CreateBackupJob.
for _ in range(5000):
CuraApplication.getInstance().processEvents()
if self._job_done.wait(0.02):
break
def _onRestoreRequestCompleted(self, reply: QNetworkReply, error: Optional["QNetworkReply.NetworkError"] = None) -> None:
if not HttpRequestManager.replyIndicatesSuccess(reply, error):
@ -60,8 +71,8 @@ class RestoreBackupJob(Job):
# We store the file in a temporary path fist to ensure integrity.
try:
temporary_backup_file = NamedTemporaryFile(delete = False)
with open(temporary_backup_file.name, "wb") as write_backup:
self._temporary_backup_file = NamedTemporaryFile(delete_on_close = False)
with open(self._temporary_backup_file.name, "wb") as write_backup:
app = CuraApplication.getInstance()
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
while bytes_read:
@ -69,23 +80,98 @@ class RestoreBackupJob(Job):
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
app.processEvents()
except EnvironmentError as e:
Logger.log("e", f"Unable to save backed up files due to computer limitations: {str(e)}")
Logger.error(f"Unable to save backed up files due to computer limitations: {str(e)}")
self.restore_backup_error_message = self.DEFAULT_ERROR_MESSAGE
self._job_done.set()
return
if not self._verifyMd5Hash(temporary_backup_file.name, self._backup.get("md5_hash", "")):
if not self._verifyMd5Hash(self._temporary_backup_file.name, self._backup.get("md5_hash", "")):
# Don't restore the backup if the MD5 hashes do not match.
# This can happen if the download was interrupted.
Logger.log("w", "Remote and local MD5 hashes do not match, not restoring backup.")
Logger.error("Remote and local MD5 hashes do not match, not restoring backup.")
self.restore_backup_error_message = self.DEFAULT_ERROR_MESSAGE
self._job_done.set()
return
# Tell Cura to place the backup back in the user data folder.
with open(temporary_backup_file.name, "rb") as read_backup:
metadata = self._backup.get("metadata", {})
with open(self._temporary_backup_file.name, "rb") as read_backup:
cura_api = CuraApplication.getInstance().getCuraAPI()
cura_api.backups.restoreBackup(read_backup.read(), self._backup.get("metadata", {}))
cura_api.backups.restoreBackup(read_backup.read(), metadata, auto_close=False)
self._job_done.set()
# Read packages data-file, to get the 'to_install' plugin-ids.
version_to_restore = Version(metadata.get("cura_release", "dev"))
version_str = f"{version_to_restore.getMajor()}.{version_to_restore.getMinor()}"
packages_path = os.path.abspath(os.path.join(os.path.abspath(
Resources.getConfigStoragePath()), "..", version_str, "packages.json"))
if not os.path.exists(packages_path):
Logger.error(f"Can't find path '{packages_path}' to tell what packages should be redownloaded.")
self.restore_backup_error_message = self.DEFAULT_ERROR_MESSAGE
self._job_done.set()
return
to_install = {}
try:
with open(packages_path, "r") as packages_file:
packages_json = json.load(packages_file)
if "to_install" in packages_json:
for package_data in packages_json["to_install"].values():
if "package_info" not in package_data:
continue
package_info = package_data["package_info"]
if "package_id" in package_info and "sdk_version_semver" in package_info:
to_install[package_info["package_id"]] = package_info["sdk_version_semver"]
except IOError as ex:
Logger.error(f"Couldn't open '{packages_path}' because '{str(ex)}' to get packages to re-install.")
self.restore_backup_error_message = self.DEFAULT_ERROR_MESSAGE
self._job_done.set()
return
if len(to_install) < 1:
Logger.info("No packages to reinstall, early out.")
self._job_done.set()
return
# Download all re-installable plugins packages, so they can be put back on start-up.
redownload_errors = []
def packageDownloadCallback(package_id: str, msg: "QNetworkReply", err: "QNetworkReply.NetworkError" = None) -> None:
if err is not None or HttpRequestManager.safeHttpStatus(msg) != 200:
redownload_errors.append(err)
del to_install[package_id]
try:
with NamedTemporaryFile(mode="wb", suffix=".curapackage", delete=False) as temp_file:
bytes_read = msg.read(self.DISK_WRITE_BUFFER_SIZE)
while bytes_read:
temp_file.write(bytes_read)
bytes_read = msg.read(self.DISK_WRITE_BUFFER_SIZE)
CuraApplication.getInstance().processEvents()
temp_file.close()
if not CuraApplication.getInstance().getPackageManager().installPackage(temp_file.name):
redownload_errors.append(f"Couldn't install package '{package_id}'.")
except IOError as ex:
redownload_errors.append(f"Couldn't process package '{package_id}' because '{ex}'.")
if len(to_install) < 1:
if len(redownload_errors) == 0:
Logger.info("All packages redownloaded!")
self._job_done.set()
else:
msgs = "\n - ".join(redownload_errors)
Logger.error(f"Couldn't re-install at least one package(s) because: {msgs}")
self.restore_backup_error_message = self.DEFAULT_ERROR_MESSAGE
self._job_done.set()
self._package_download_scope = UltimakerCloudScope(CuraApplication.getInstance())
for package_id, package_api_version in to_install.items():
def handlePackageId(package_id: str = package_id):
HttpRequestManager.getInstance().get(
PACKAGES_URL_TEMPLATE.format(package_api_version, package_id),
scope=self._package_download_scope,
callback=lambda msg: packageDownloadCallback(package_id, msg),
error_callback=lambda msg, err: packageDownloadCallback(package_id, msg, err)
)
handlePackageId(package_id)
@staticmethod
def _verifyMd5Hash(file_path: str, known_hash: str) -> bool:

View file

@ -53,6 +53,8 @@ message Object
bytes indices = 4; //An array of ints.
repeated Setting settings = 5; // Setting override per object, overruling the global settings.
string name = 6; //Mesh name
bytes uv_coordinates = 7; //An array of 2 floats.
bytes texture = 8; //PNG-encoded texture data
}
message Progress
@ -78,10 +80,14 @@ message Polygon {
SkirtType = 5;
InfillType = 6;
SupportInfillType = 7;
MoveCombingType = 8;
MoveRetractionType = 9;
MoveUnretracted = 8;
MoveRetracted = 9;
SupportInterfaceType = 10;
PrimeTowerType = 11;
MoveWhileRetracting = 12;
MoveWhileUnretracting = 13;
StationaryRetractUnretract = 14;
NumPrintFeatureTypes = 15;
}
Type type = 1; // Type of move
bytes points = 2; // The points of the polygon, or two points if only a line segment (Currently only line segments are used)

View file

@ -2,6 +2,7 @@
# Cura is released under the terms of the LGPLv3 or higher.
import argparse #To run the engine in debug mode if the front-end is in debug mode.
from cmath import isnan
from collections import defaultdict
import os
from PyQt6.QtCore import QObject, QTimer, QUrl, pyqtSlot
@ -158,6 +159,7 @@ class CuraEngineBackend(QObject, Backend):
self._backend_log_max_lines: int = 20000 # Maximum number of lines to buffer
self._error_message: Optional[Message] = None # Pop-up message that shows errors.
self._unused_extruders: list[int] = [] # Extruder numbers of found unused extruders
# Count number of objects to see if there is something changed
self._last_num_objects: Dict[int, int] = defaultdict(int)
@ -960,12 +962,44 @@ class CuraEngineBackend(QObject, Backend):
"""
material_amounts = []
self._unused_extruders = []
for index in range(message.repeatedMessageCount("materialEstimates")):
material_amounts.append(message.getRepeatedMessage("materialEstimates", index).material_amount)
material_use_for_tool = message.getRepeatedMessage("materialEstimates", index).material_amount
if isnan(material_use_for_tool):
material_amounts.append(0.0)
if self._global_container_stack.extruderList[int(index)].isEnabled:
self._unused_extruders.append(index)
else:
material_amounts.append(material_use_for_tool)
if self._unused_extruders:
extruder_names = [self._global_container_stack.extruderList[int(idx)].definition.getName() for idx in self._unused_extruders]
unused_extruders = [f"<li>{extruder_name}</li>" for extruder_name in extruder_names]
warning_message = Message(
text=catalog.i18nc("@message", "<html>At least one extruder remains unused in this print:"
f"<ul><b>{"".join(unused_extruders)}</b></ul><br/>This can sometimes become a problem, "
"for example when the bed temperature is adjusted for the material present in the unused extruder. "
"It might be desirable to disable these unused extruders.</html>"),
title=catalog.i18nc("@message:title", "Unused Extruder(s)"),
message_type=Message.MessageType.WARNING
)
warning_message.addAction("disable_extruders",
name=catalog.i18nc("@button", "Disable unused extruder(s)"),
icon="",
description=catalog.i18nc("@label", "Automatically disable the unused extruder(s)")
)
warning_message.actionTriggered.connect(self._onMessageActionTriggered)
warning_message.show()
times = self._parseMessagePrintTimes(message)
self.printDurationMessage.emit(self._start_slice_job_build_plate, times, material_amounts)
def _onMessageActionTriggered(self, message: Message, message_action: str) -> None:
if message_action == "disable_extruders":
message.hide()
for unused_extruder in self._unused_extruders:
CuraApplication.getInstance().getMachineManager().setExtruderEnabled(unused_extruder, False)
def _parseMessagePrintTimes(self, message: Arcus.PythonMessage) -> Dict[str, float]:
"""Called for parsing message to retrieve estimated time per feature

View file

@ -509,6 +509,14 @@ class StartSliceJob(Job):
obj.vertices = flat_verts
uv_coordinates = mesh_data.getUVCoordinates()
if uv_coordinates is not None:
obj.uv_coordinates = uv_coordinates.flatten()
packed_texture = object.callDecoration("packTexture")
if packed_texture is not None:
obj.texture = packed_texture
self._handlePerObjectSettings(cast(CuraSceneNode, object), obj)
Job.yieldThread()

View file

@ -11,10 +11,10 @@ Cura.RoundedRectangle
width: parent.width
height: projectImage.height + 2 * UM.Theme.getSize("default_margin").width
cornerSide: Cura.RoundedRectangle.Direction.All
border.color: UM.Theme.getColor("lining")
border.color: enabled ? UM.Theme.getColor("lining") : UM.Theme.getColor("action_button_disabled_border")
border.width: UM.Theme.getSize("default_lining").width
radius: UM.Theme.getSize("default_radius").width
color: UM.Theme.getColor("main_background")
color: getBackgroundColor()
signal clicked()
property alias imageSource: projectImage.source
property alias projectNameText: displayNameLabel.text
@ -22,17 +22,18 @@ Cura.RoundedRectangle
property alias projectLastUpdatedText: lastUpdatedLabel.text
property alias cardMouseAreaEnabled: cardMouseArea.enabled
onVisibleChanged: color = UM.Theme.getColor("main_background")
onVisibleChanged: color = getBackgroundColor()
MouseArea
{
id: cardMouseArea
anchors.fill: parent
hoverEnabled: true
onEntered: base.color = UM.Theme.getColor("action_button_hovered")
onExited: base.color = UM.Theme.getColor("main_background")
hoverEnabled: base.enabled
onEntered: color = getBackgroundColor()
onExited: color = getBackgroundColor()
onClicked: base.clicked()
}
Row
{
id: projectInformationRow
@ -73,7 +74,7 @@ Cura.RoundedRectangle
width: parent.width
height: Math.round(parent.height / 3)
elide: Text.ElideRight
color: UM.Theme.getColor("small_button_text")
color: base.enabled ? UM.Theme.getColor("small_button_text") : UM.Theme.getColor("text_disabled")
}
UM.Label
@ -82,8 +83,27 @@ Cura.RoundedRectangle
width: parent.width
height: Math.round(parent.height / 3)
elide: Text.ElideRight
color: UM.Theme.getColor("small_button_text")
color: base.enabled ? UM.Theme.getColor("small_button_text") : UM.Theme.getColor("text_disabled")
}
}
}
}
function getBackgroundColor()
{
if(enabled)
{
if(cardMouseArea.containsMouse)
{
return UM.Theme.getColor("action_button_hovered")
}
else
{
return UM.Theme.getColor("main_background")
}
}
else
{
return UM.Theme.getColor("action_button_disabled")
}
}
}

View file

@ -159,17 +159,30 @@ Item
Repeater
{
model: manager.digitalFactoryProjectModel
delegate: ProjectSummaryCard
delegate: Item
{
id: projectSummaryCard
imageSource: model.thumbnailUrl || "../images/placeholder.svg"
projectNameText: model.displayName
projectUsernameText: model.username
projectLastUpdatedText: "Last updated: " + model.lastUpdated
width: parent.width
height: projectSummaryCard.height
onClicked:
UM.TooltipArea
{
manager.selectedProjectIndex = index
anchors.fill: parent
text: "This project is inactive and cannot be used."
enabled: !model.active
}
ProjectSummaryCard
{
id: projectSummaryCard
imageSource: model.thumbnailUrl || "../images/placeholder.svg"
projectNameText: model.displayName
projectUsernameText: model.username
projectLastUpdatedText: "Last updated: " + model.lastUpdated
enabled: model.active
onClicked: {
manager.selectedProjectIndex = index
}
}
}
}

View file

@ -17,6 +17,7 @@ class DigitalFactoryProjectModel(ListModel):
ThumbnailUrlRole = Qt.ItemDataRole.UserRole + 5
UsernameRole = Qt.ItemDataRole.UserRole + 6
LastUpdatedRole = Qt.ItemDataRole.UserRole + 7
ActiveRole = Qt.ItemDataRole.UserRole + 8
dfProjectModelChanged = pyqtSignal()
@ -28,6 +29,7 @@ class DigitalFactoryProjectModel(ListModel):
self.addRoleName(self.ThumbnailUrlRole, "thumbnailUrl")
self.addRoleName(self.UsernameRole, "username")
self.addRoleName(self.LastUpdatedRole, "lastUpdated")
self.addRoleName(self.ActiveRole, "active")
self._projects = [] # type: List[DigitalFactoryProjectResponse]
def setProjects(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
@ -59,5 +61,6 @@ class DigitalFactoryProjectModel(ListModel):
"thumbnailUrl": project.thumbnail_url,
"username": project.username,
"lastUpdated": project.last_updated.strftime(PROJECT_UPDATED_AT_DATETIME_FORMAT) if project.last_updated else "",
"active": project.active,
})
self.dfProjectModelChanged.emit()

View file

@ -28,6 +28,7 @@ class DigitalFactoryProjectResponse(BaseModel):
team_ids: Optional[List[str]] = None,
status: Optional[str] = None,
technical_requirements: Optional[Dict[str, Any]] = None,
is_inactive: bool = False,
**kwargs) -> None:
"""
Creates a new digital factory project response object
@ -56,6 +57,7 @@ class DigitalFactoryProjectResponse(BaseModel):
self.last_updated = datetime.strptime(last_updated, DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT) if last_updated else None
self.status = status
self.technical_requirements = technical_requirements
self.active = not is_inactive
super().__init__(**kwargs)
def __str__(self) -> str:

View file

@ -24,7 +24,7 @@ class GCodeGzWriter(MeshWriter):
def __init__(self) -> None:
super().__init__(add_to_recent_files = False)
def write(self, stream: BufferedIOBase, nodes: List[SceneNode], mode = MeshWriter.OutputMode.BinaryMode) -> bool:
def write(self, stream: BufferedIOBase, nodes: List[SceneNode], mode = MeshWriter.OutputMode.BinaryMode, **kwargs) -> bool:
"""Writes the gzipped g-code to a stream.
Note that even though the function accepts a collection of nodes, the

View file

@ -133,7 +133,10 @@ class FlavorParser:
if i > 0:
line_feedrates[i - 1] = point[3]
line_types[i - 1] = point[5]
if point[5] in [LayerPolygon.MoveCombingType, LayerPolygon.MoveRetractionType]:
if point[5] in [LayerPolygon.MoveUnretractedType,
LayerPolygon.MoveRetractedType,
LayerPolygon.MoveWhileRetractingType,
LayerPolygon.MoveWhileUnretractingType]:
line_widths[i - 1] = 0.1
line_thicknesses[i - 1] = 0.0 # Travels are set as zero thickness lines
else:
@ -196,7 +199,7 @@ class FlavorParser:
path.append([x, y, z, f, new_extrusion_value + self._extrusion_length_offset[self._extruder_number], self._layer_type]) # extrusion
self._previous_extrusion_value = new_extrusion_value
else:
path.append([x, y, z, f, new_extrusion_value + self._extrusion_length_offset[self._extruder_number], LayerPolygon.MoveRetractionType]) # retraction
path.append([x, y, z, f, new_extrusion_value + self._extrusion_length_offset[self._extruder_number], LayerPolygon.MoveRetractedType]) # retraction
e[self._extruder_number] = new_extrusion_value
# Only when extruding we can determine the latest known "layer height" which is the difference in height between extrusions
@ -205,9 +208,9 @@ class FlavorParser:
self._current_layer_thickness = z - self._previous_z # allow a tiny overlap
self._previous_z = z
elif self._previous_extrusion_value > e[self._extruder_number]:
path.append([x, y, z, f, e[self._extruder_number] + self._extrusion_length_offset[self._extruder_number], LayerPolygon.MoveRetractionType])
path.append([x, y, z, f, e[self._extruder_number] + self._extrusion_length_offset[self._extruder_number], LayerPolygon.MoveRetractedType])
else:
path.append([x, y, z, f, e[self._extruder_number] + self._extrusion_length_offset[self._extruder_number], LayerPolygon.MoveCombingType])
path.append([x, y, z, f, e[self._extruder_number] + self._extrusion_length_offset[self._extruder_number], LayerPolygon.MoveUnretractedType])
return self._position(x, y, z, f, e)
@ -419,7 +422,7 @@ class FlavorParser:
self._createPolygon(self._current_layer_thickness, current_path, self._extruder_offsets.get(self._extruder_number, [0, 0]))
current_path.clear()
# Start the new layer at the end position of the last layer
current_path.append([current_position.x, current_position.y, current_position.z, current_position.f, current_position.e[self._extruder_number], LayerPolygon.MoveCombingType])
current_path.append([current_position.x, current_position.y, current_position.z, current_position.f, current_position.e[self._extruder_number], LayerPolygon.MoveUnretractedType])
# When using a raft, the raft layers are stored as layers < 0, it mimics the same behavior
# as in ProcessSlicedLayersJob
@ -461,9 +464,9 @@ class FlavorParser:
# When changing tool, store the end point of the previous path, then process the code and finally
# add another point with the new position of the head.
current_path.append([current_position.x, current_position.y, current_position.z, current_position.f, current_position.e[self._extruder_number], LayerPolygon.MoveCombingType])
current_path.append([current_position.x, current_position.y, current_position.z, current_position.f, current_position.e[self._extruder_number], LayerPolygon.MoveUnretractedType])
current_position = self.processTCode(global_stack, T, line, current_position, current_path)
current_path.append([current_position.x, current_position.y, current_position.z, current_position.f, current_position.e[self._extruder_number], LayerPolygon.MoveCombingType])
current_path.append([current_position.x, current_position.y, current_position.z, current_position.f, current_position.e[self._extruder_number], LayerPolygon.MoveUnretractedType])
if line.startswith("M"):
M = self._getInt(line, "M")

View file

@ -56,7 +56,7 @@ class GCodeWriter(MeshWriter):
self._application = Application.getInstance()
def write(self, stream, nodes, mode = MeshWriter.OutputMode.TextMode):
def write(self, stream, nodes, mode = MeshWriter.OutputMode.TextMode, **kwargs):
"""Writes the g-code for the entire scene to a stream.
Note that even though the function accepts a collection of nodes, the

View file

@ -91,7 +91,7 @@ class MakerbotWriter(MeshWriter):
return None
def write(self, stream: BufferedIOBase, nodes: List[SceneNode], mode=MeshWriter.OutputMode.BinaryMode) -> bool:
def write(self, stream: BufferedIOBase, nodes: List[SceneNode], mode=MeshWriter.OutputMode.BinaryMode, **kwargs) -> bool:
metadata, file_format = self._getMeta(nodes)
if mode != MeshWriter.OutputMode.BinaryMode:
Logger.log("e", "MakerbotWriter does not support text mode.")

View file

@ -21,7 +21,6 @@ class Marketplace(Extension, QObject):
def __init__(self, parent: Optional[QObject] = None) -> None:
QObject.__init__(self, parent)
Extension.__init__(self)
self._window: Optional["QObject"] = None # If the window has been loaded yet, it'll be cached in here.
self._package_manager = CuraApplication.getInstance().getPackageManager()
self._material_package_list: Optional[RemotePackageList] = None
@ -79,20 +78,17 @@ class Marketplace(Extension, QObject):
If the window hadn't been loaded yet into Qt, it will be created lazily.
"""
if self._window is None:
plugin_registry = PluginRegistry.getInstance()
plugin_registry.pluginsEnabledOrDisabledChanged.connect(self.checkIfRestartNeeded)
plugin_path = plugin_registry.getPluginPath(self.getPluginId())
if plugin_path is None:
plugin_path = os.path.dirname(__file__)
path = os.path.join(plugin_path, "resources", "qml", "Marketplace.qml")
self._window = CuraApplication.getInstance().createQmlComponent(path, {"manager": self})
if self._window is None: # Still None? Failed to load the QML then.
return
if not self._window.isVisible():
self.setTabShown(0)
self._window.show()
self._window.requestActivate() # Bring window into focus, if it was already open in the background.
plugin_registry = PluginRegistry.getInstance()
plugin_registry.pluginsEnabledOrDisabledChanged.connect(self.checkIfRestartNeeded)
plugin_path = plugin_registry.getPluginPath(self.getPluginId())
if plugin_path is None:
plugin_path = os.path.dirname(__file__)
path = os.path.join(plugin_path, "resources", "qml", "Marketplace.qml")
window = CuraApplication.getInstance().createQmlSubWindow(path, {"manager": self})
if window is not None: # Still None? Failed to load the QML then.
window.show()
@pyqtSlot()
def setVisibleTabToMaterials(self) -> None:
@ -103,9 +99,6 @@ class Marketplace(Extension, QObject):
self.setTabShown(1)
def checkIfRestartNeeded(self) -> None:
if self._window is None:
return
if self._package_manager.hasPackagesToRemoveOrInstall or \
PluginRegistry.getInstance().getCurrentSessionActivationChangedPlugins():
self._restart_needed = True

View file

@ -9,7 +9,7 @@ import QtQuick.Window 2.2
import UM 1.5 as UM
import Cura 1.6 as Cura
Window
UM.Dialog
{
id: marketplaceDialog
property variant catalog: UM.I18nCatalog { name: "cura" }
@ -25,293 +25,289 @@ Window
width: minimumWidth
height: minimumHeight
onVisibleChanged:
{
while(contextStack.depth > 1)
{
contextStack.pop(); //Do NOT use the StackView.Immediate transition here, since it causes the window to stay empty. Seemingly a Qt bug: https://bugreports.qt.io/browse/QTBUG-60670?
}
}
Connections
{
target: Cura.API.account
function onLoginStateChanged()
{
close();
}
}
title: "Marketplace" //Seen by Ultimaker as a brand name, so this doesn't get translated.
modality: Qt.NonModal
// Background color
Rectangle
{
anchors.fill: parent
color: UM.Theme.getColor("main_background")
}
//The Marketplace can have a page in front of everything with package details. The stack view controls its visibility.
StackView
{
id: contextStack
anchors.fill: parent
initialItem: packageBrowse
ColumnLayout
//The Marketplace can have a page in front of everything with package details. The stack view controls its visibility.
StackView
{
id: packageBrowse
id: contextStack
anchors.fill: parent
spacing: UM.Theme.getSize("narrow_margin").height
initialItem: packageBrowse
// Page title.
Item
ColumnLayout
{
Layout.preferredWidth: parent.width
Layout.preferredHeight: childrenRect.height + UM.Theme.getSize("default_margin").height
id: packageBrowse
UM.Label
spacing: UM.Theme.getSize("narrow_margin").height
// Page title.
Item
{
id: pageTitle
anchors
{
left: parent.left
leftMargin: UM.Theme.getSize("default_margin").width
right: parent.right
rightMargin: UM.Theme.getSize("default_margin").width
bottom: parent.bottom
}
Layout.preferredWidth: parent.width
Layout.preferredHeight: childrenRect.height + UM.Theme.getSize("default_margin").height
font: UM.Theme.getFont("large")
text: content.item ? content.item.pageTitle: catalog.i18nc("@title", "Loading...")
UM.Label
{
id: pageTitle
anchors
{
left: parent.left
leftMargin: UM.Theme.getSize("default_margin").width
right: parent.right
rightMargin: UM.Theme.getSize("default_margin").width
bottom: parent.bottom
}
font: UM.Theme.getFont("large")
text: content.item ? content.item.pageTitle : catalog.i18nc("@title", "Loading...")
}
}
}
OnboardBanner
{
id: onBoardBanner
visible: content.item && content.item.bannerVisible
text: content.item && content.item.bannerText
icon: content.item && content.item.bannerIcon
onRemove: content.item && content.item.onRemoveBanner
readMoreUrl: content.item && content.item.bannerReadMoreUrl
Layout.fillWidth: true
Layout.leftMargin: UM.Theme.getSize("default_margin").width
Layout.rightMargin: UM.Theme.getSize("default_margin").width
}
// Search & Top-Level Tabs
Item
{
id: searchHeader
implicitHeight: childrenRect.height
implicitWidth: parent.width - 2 * UM.Theme.getSize("default_margin").width
Layout.alignment: Qt.AlignHCenter
RowLayout
OnboardBanner
{
width: parent.width
height: UM.Theme.getSize("button_icon").height + UM.Theme.getSize("default_margin").height
spacing: UM.Theme.getSize("thin_margin").width
id: onBoardBanner
visible: content.item && content.item.bannerVisible
text: content.item && content.item.bannerText
icon: content.item && content.item.bannerIcon
onRemove: content.item && content.item.onRemoveBanner
readMoreUrl: content.item && content.item.bannerReadMoreUrl
Cura.SearchBar
Layout.fillWidth: true
Layout.leftMargin: UM.Theme.getSize("default_margin").width
Layout.rightMargin: UM.Theme.getSize("default_margin").width
}
// Search & Top-Level Tabs
Item
{
id: searchHeader
implicitHeight: childrenRect.height
implicitWidth: parent.width - 2 * UM.Theme.getSize("default_margin").width
Layout.alignment: Qt.AlignHCenter
RowLayout
{
id: searchBar
implicitHeight: UM.Theme.getSize("button_icon").height
Layout.fillWidth: true
onTextEdited: searchStringChanged(text)
}
width: parent.width
height: UM.Theme.getSize("button_icon").height + UM.Theme.getSize("default_margin").height
spacing: UM.Theme.getSize("thin_margin").width
// Page selection.
TabBar
{
id: pageSelectionTabBar
Layout.alignment: Qt.AlignRight
height: UM.Theme.getSize("button_icon").height
spacing: 0
background: Rectangle { color: "transparent" }
currentIndex: manager.tabShown
onCurrentIndexChanged:
Cura.SearchBar
{
manager.tabShown = currentIndex
searchBar.text = "";
searchBar.visible = currentItem.hasSearch;
content.source = currentItem.sourcePage;
id: searchBar
implicitHeight: UM.Theme.getSize("button_icon").height
Layout.fillWidth: true
onTextEdited: searchStringChanged(text)
}
PackageTypeTab
// Page selection.
TabBar
{
id: pluginTabText
width: implicitWidth
text: catalog.i18nc("@button", "Plugins")
property string sourcePage: "Plugins.qml"
property bool hasSearch: true
}
PackageTypeTab
{
id: materialsTabText
width: implicitWidth
text: catalog.i18nc("@button", "Materials")
property string sourcePage: "Materials.qml"
property bool hasSearch: true
}
ManagePackagesButton
{
property string sourcePage: "ManagedPackages.qml"
property bool hasSearch: false
id: pageSelectionTabBar
Layout.alignment: Qt.AlignRight
height: UM.Theme.getSize("button_icon").height
spacing: 0
background: Rectangle {
color: "transparent"
}
currentIndex: manager.tabShown
Cura.NotificationIcon
onCurrentIndexChanged:
{
anchors
{
horizontalCenter: parent.right
verticalCenter: parent.top
}
visible: CuraApplication.getPackageManager().packagesWithUpdate.length > 0
manager.tabShown = currentIndex
searchBar.text = "";
searchBar.visible = currentItem.hasSearch;
content.source = currentItem.sourcePage;
}
labelText:
PackageTypeTab
{
id: pluginTabText
width: implicitWidth
text: catalog.i18nc("@button", "Plugins")
property string sourcePage: "Plugins.qml"
property bool hasSearch: true
}
PackageTypeTab
{
id: materialsTabText
width: implicitWidth
text: catalog.i18nc("@button", "Materials")
property string sourcePage: "Materials.qml"
property bool hasSearch: true
}
ManagePackagesButton
{
property string sourcePage: "ManagedPackages.qml"
property bool hasSearch: false
Cura.NotificationIcon
{
const itemCount = CuraApplication.getPackageManager().packagesWithUpdate.length
return itemCount > 9 ? "9+" : itemCount
anchors
{
horizontalCenter: parent.right
verticalCenter: parent.top
}
visible: CuraApplication.getPackageManager().packagesWithUpdate.length > 0
labelText:
{
const itemCount = CuraApplication.getPackageManager().packagesWithUpdate.length
return itemCount > 9 ? "9+" : itemCount
}
}
}
}
}
}
}
FontMetrics
{
id: fontMetrics
font: UM.Theme.getFont("default")
}
FontMetrics
{
id: fontMetrics
font: UM.Theme.getFont("default")
}
Cura.TertiaryButton
{
text: catalog.i18nc("@info", "Search in the browser")
iconSource: UM.Theme.getIcon("LinkExternal")
visible: pageSelectionTabBar.currentItem.hasSearch && searchHeader.visible
isIconOnRightSide: true
height: fontMetrics.height
textFont: fontMetrics.font
textColor: UM.Theme.getColor("text")
Cura.TertiaryButton
{
text: catalog.i18nc("@info", "Search in the browser")
iconSource: UM.Theme.getIcon("LinkExternal")
visible: pageSelectionTabBar.currentItem.hasSearch && searchHeader.visible
isIconOnRightSide: true
height: fontMetrics.height
textFont: fontMetrics.font
textColor: UM.Theme.getColor("text")
onClicked: content.item && Qt.openUrlExternally(content.item.searchInBrowserUrl)
}
// Page contents.
Rectangle
{
Layout.preferredWidth: parent.width
Layout.fillHeight: true
color: UM.Theme.getColor("detail_background")
onClicked: content.item && Qt.openUrlExternally(content.item.searchInBrowserUrl)
}
// Page contents.
Loader
Rectangle
{
id: content
anchors.fill: parent
anchors.margins: UM.Theme.getSize("default_margin").width
source: "Plugins.qml"
Layout.preferredWidth: parent.width
Layout.fillHeight: true
color: UM.Theme.getColor("detail_background")
Connections
// Page contents.
Loader
{
target: content
function onLoaded()
id: content
anchors.fill: parent
anchors.margins: UM.Theme.getSize("default_margin").width
source: "Plugins.qml"
Connections
{
pageTitle.text = content.item.pageTitle
searchStringChanged.connect(handleSearchStringChanged)
}
function handleSearchStringChanged(new_search)
{
content.item.model.searchString = new_search
target: content
function onLoaded()
{
pageTitle.text = content.item.pageTitle
searchStringChanged.connect(handleSearchStringChanged)
}
function handleSearchStringChanged(new_search)
{
content.item.model.searchString = new_search
}
}
}
}
}
}
}
Rectangle
{
height: quitButton.height + 2 * UM.Theme.getSize("default_margin").width
color: UM.Theme.getColor("primary")
visible: manager.showRestartNotification
anchors
{
left: parent.left
right: parent.right
bottom: parent.bottom
}
RowLayout
Rectangle
{
height: quitButton.height + 2 * UM.Theme.getSize("default_margin").width
color: UM.Theme.getColor("primary")
visible: manager.showRestartNotification
anchors
{
left: parent.left
right: parent.right
verticalCenter: parent.verticalCenter
margins: UM.Theme.getSize("default_margin").width
bottom: parent.bottom
}
spacing: UM.Theme.getSize("default_margin").width
UM.ColorImage
{
id: bannerIcon
source: UM.Theme.getIcon("Plugin")
color: UM.Theme.getColor("primary_button_text")
implicitWidth: UM.Theme.getSize("banner_icon_size").width
implicitHeight: UM.Theme.getSize("banner_icon_size").height
}
Text
RowLayout
{
color: UM.Theme.getColor("primary_button_text")
text: catalog.i18nc("@button", "In order to use the package you will need to restart Cura")
font: UM.Theme.getFont("default")
renderType: Text.NativeRendering
Layout.fillWidth: true
}
Cura.SecondaryButton
{
id: quitButton
text: catalog.i18nc("@info:button, %1 is the application name", "Quit %1").arg(CuraApplication.applicationDisplayName)
onClicked:
anchors
{
marketplaceDialog.hide();
CuraApplication.checkAndExitApplication();
left: parent.left
right: parent.right
verticalCenter: parent.verticalCenter
margins: UM.Theme.getSize("default_margin").width
}
spacing: UM.Theme.getSize("default_margin").width
UM.ColorImage
{
id: bannerIcon
source: UM.Theme.getIcon("Plugin")
color: UM.Theme.getColor("primary_button_text")
implicitWidth: UM.Theme.getSize("banner_icon_size").width
implicitHeight: UM.Theme.getSize("banner_icon_size").height
}
Text
{
color: UM.Theme.getColor("primary_button_text")
text: catalog.i18nc("@button", "In order to use the package you will need to restart Cura")
font: UM.Theme.getFont("default")
renderType: Text.NativeRendering
Layout.fillWidth: true
}
Cura.SecondaryButton
{
id: quitButton
text: catalog.i18nc("@info:button, %1 is the application name", "Quit %1").arg(CuraApplication.applicationDisplayName)
onClicked:
{
marketplaceDialog.hide();
CuraApplication.checkAndExitApplication();
}
}
}
}
}
Rectangle
{
color: UM.Theme.getColor("main_background")
anchors.fill: parent
visible: !Cura.API.account.isLoggedIn && CuraApplication.isEnterprise
UM.Label
Rectangle
{
id: signInLabel
anchors.centerIn: parent
width: Math.round(UM.Theme.getSize("modal_window_minimum").width / 2.5)
text: catalog.i18nc("@description","Please sign in to get verified plugins and materials for UltiMaker Cura Enterprise")
horizontalAlignment: Text.AlignHCenter
color: UM.Theme.getColor("main_background")
anchors.fill: parent
visible: !Cura.API.account.isLoggedIn && CuraApplication.isEnterprise
UM.Label
{
id: signInLabel
anchors.centerIn: parent
width: Math.round(UM.Theme.getSize("modal_window_minimum").width / 2.5)
text: catalog.i18nc("@description", "Please sign in to get verified plugins and materials for UltiMaker Cura Enterprise")
horizontalAlignment: Text.AlignHCenter
}
Cura.PrimaryButton
{
id: loginButton
width: UM.Theme.getSize("account_button").width
height: UM.Theme.getSize("account_button").height
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: signInLabel.bottom
anchors.topMargin: UM.Theme.getSize("default_margin").height * 2
text: catalog.i18nc("@button", "Sign in")
fixedWidthMode: true
onClicked: Cura.API.account.login()
}
}
Cura.PrimaryButton
Connections
{
id: loginButton
width: UM.Theme.getSize("account_button").width
height: UM.Theme.getSize("account_button").height
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: signInLabel.bottom
anchors.topMargin: UM.Theme.getSize("default_margin").height * 2
text: catalog.i18nc("@button", "Sign in")
fixedWidthMode: true
onClicked: Cura.API.account.login()
target: Cura.API.account
function onLoginStateChanged()
{
reject();
}
}
}
}

View file

@ -0,0 +1,18 @@
// Copyright (c) 2025 UltiMaker
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick
import UM 1.7 as UM
import Cura 1.0 as Cura
UM.ToolbarButton
{
id: buttonBrushColor
property string color
checked: UM.Controller.properties.getValue("BrushColor") === buttonBrushColor.color
onClicked: UM.Controller.setProperty("BrushColor", buttonBrushColor.color)
}

View file

@ -0,0 +1,18 @@
// Copyright (c) 2025 UltiMaker
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick
import UM 1.7 as UM
import Cura 1.0 as Cura
UM.ToolbarButton
{
id: buttonBrushShape
property int shape
checked: UM.Controller.properties.getValue("BrushShape") === buttonBrushShape.shape
onClicked: UM.Controller.setProperty("BrushShape", buttonBrushShape.shape)
}

View file

@ -0,0 +1,18 @@
// Copyright (c) 2025 UltiMaker
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick
import UM 1.7 as UM
import Cura 1.0 as Cura
Cura.ModeSelectorButton
{
id: modeSelectorButton
property string mode
selected: UM.Controller.properties.getValue("PaintType") === modeSelectorButton.mode
onClicked: UM.Controller.setProperty("PaintType", modeSelectorButton.mode)
}

View file

@ -0,0 +1,424 @@
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
from enum import IntEnum
import numpy
from PyQt6.QtCore import Qt, QObject, pyqtEnum
from PyQt6.QtGui import QImage, QPainter, QColor, QPen
from PyQt6 import QtWidgets
from typing import cast, Dict, List, Optional, Tuple
from numpy import ndarray
from UM.Application import Application
from UM.Event import Event, MouseEvent, KeyEvent
from UM.Job import Job
from UM.Logger import Logger
from UM.Scene.SceneNode import SceneNode
from UM.Scene.Selection import Selection
from UM.Tool import Tool
from UM.View.GL.OpenGL import OpenGL
from cura.CuraApplication import CuraApplication
from cura.PickingPass import PickingPass
from UM.View.SelectionPass import SelectionPass
from .PaintView import PaintView
from .PrepareTextureJob import PrepareTextureJob
class PaintTool(Tool):
"""Provides the tool to paint meshes."""
class Brush(QObject):
@pyqtEnum
class Shape(IntEnum):
SQUARE = 0
CIRCLE = 1
class Paint(QObject):
@pyqtEnum
class State(IntEnum):
MULTIPLE_SELECTION = 0 # Multiple objects are selected, wait until there is only one
PREPARING_MODEL = 1 # Model is being prepared (UV-unwrapping, texture generation)
READY = 2 # Ready to paint !
def __init__(self, view: PaintView) -> None:
super().__init__()
self._view: PaintView = view
self._view.canUndoChanged.connect(self._onCanUndoChanged)
self._view.canRedoChanged.connect(self._onCanRedoChanged)
self._picking_pass: Optional[PickingPass] = None
self._faces_selection_pass: Optional[SelectionPass] = None
self._shortcut_key: Qt.Key = Qt.Key.Key_P
self._node_cache: Optional[SceneNode] = None
self._mesh_transformed_cache = None
self._cache_dirty: bool = True
self._brush_size: int = 200
self._brush_color: str = "preferred"
self._brush_shape: PaintTool.Brush.Shape = PaintTool.Brush.Shape.CIRCLE
self._brush_pen: QPen = self._createBrushPen()
self._mouse_held: bool = False
self._last_text_coords: Optional[numpy.ndarray] = None
self._last_mouse_coords: Optional[Tuple[int, int]] = None
self._last_face_id: Optional[int] = None
self._state: PaintTool.Paint.State = PaintTool.Paint.State.MULTIPLE_SELECTION
self._prepare_texture_job: Optional[PrepareTextureJob] = None
self.setExposedProperties("PaintType", "BrushSize", "BrushColor", "BrushShape", "State", "CanUndo", "CanRedo")
self._controller.activeViewChanged.connect(self._updateIgnoreUnselectedObjects)
self._controller.activeToolChanged.connect(self._updateState)
def _createBrushPen(self) -> QPen:
pen = QPen()
pen.setWidth(self._brush_size)
pen.setColor(Qt.GlobalColor.white)
match self._brush_shape:
case PaintTool.Brush.Shape.SQUARE:
pen.setCapStyle(Qt.PenCapStyle.SquareCap)
case PaintTool.Brush.Shape.CIRCLE:
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
return pen
def _createStrokeImage(self, x0: float, y0: float, x1: float, y1: float) -> Tuple[QImage, Tuple[int, int]]:
xdiff = int(x1 - x0)
ydiff = int(y1 - y0)
half_brush_size = self._brush_size // 2
start_x = int(min(x0, x1) - half_brush_size)
start_y = int(min(y0, y1) - half_brush_size)
stroke_image = QImage(abs(xdiff) + self._brush_size, abs(ydiff) + self._brush_size, QImage.Format.Format_RGB32)
stroke_image.fill(0)
painter = QPainter(stroke_image)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
painter.setPen(self._brush_pen)
if xdiff == 0 and ydiff == 0:
painter.drawPoint(int(x0 - start_x), int(y0 - start_y))
else:
painter.drawLine(int(x0 - start_x), int(y0 - start_y), int(x1 - start_x), int(y1 - start_y))
painter.end()
return stroke_image, (start_x, start_y)
def getPaintType(self) -> str:
return self._view.getPaintType()
def setPaintType(self, paint_type: str) -> None:
if paint_type != self.getPaintType():
self._view.setPaintType(paint_type)
self._brush_pen = self._createBrushPen()
self._updateScene()
self.propertyChanged.emit()
def getBrushSize(self) -> int:
return self._brush_size
def setBrushSize(self, brush_size: float) -> None:
brush_size_int = int(brush_size)
if brush_size_int != self._brush_size:
self._brush_size = brush_size_int
self._brush_pen = self._createBrushPen()
self.propertyChanged.emit()
def getBrushColor(self) -> str:
return self._brush_color
def setBrushColor(self, brush_color: str) -> None:
if brush_color != self._brush_color:
self._brush_color = brush_color
self.propertyChanged.emit()
def getBrushShape(self) -> int:
return self._brush_shape
def setBrushShape(self, brush_shape: int) -> None:
if brush_shape != self._brush_shape:
self._brush_shape = brush_shape
self._brush_pen = self._createBrushPen()
self.propertyChanged.emit()
def getCanUndo(self) -> bool:
return self._view.canUndo()
def getState(self) -> int:
return self._state
def _onCanUndoChanged(self):
self.propertyChanged.emit()
def getCanRedo(self) -> bool:
return self._view.canRedo()
def _onCanRedoChanged(self):
self.propertyChanged.emit()
def undoStackAction(self) -> None:
self._view.undoStroke()
self._updateScene()
def redoStackAction(self) -> None:
self._view.redoStroke()
self._updateScene()
def clear(self) -> None:
width, height = self._view.getUvTexDimensions()
clear_image = QImage(width, height, QImage.Format.Format_RGB32)
clear_image.fill(Qt.GlobalColor.white)
self._view.addStroke(clear_image, 0, 0, "none", False)
self._updateScene()
@staticmethod
def _get_intersect_ratio_via_pt(a: numpy.ndarray, pt: numpy.ndarray, b: numpy.ndarray, c: numpy.ndarray) -> float:
# compute the intersection of (param) A - pt with (param) B - (param) C
if all(a == pt) or all(b == c) or all(a == c) or all(a == b):
return 1.0
# compute unit vectors of directions of lines A and B
udir_a = a - pt
udir_a /= numpy.linalg.norm(udir_a)
udir_b = b - c
udir_b /= numpy.linalg.norm(udir_b)
# find unit direction vector for line C, which is perpendicular to lines A and B
udir_res = numpy.cross(udir_b, udir_a)
udir_res_len = numpy.linalg.norm(udir_res)
if udir_res_len == 0:
return 1.0
udir_res /= udir_res_len
# solve system of equations
rhs = b - a
lhs = numpy.array([udir_a, -udir_b, udir_res]).T
try:
solved = numpy.linalg.solve(lhs, rhs)
except numpy.linalg.LinAlgError:
return 1.0
# get the ratio
intersect = ((a + solved[0] * udir_a) + (b + solved[1] * udir_b)) * 0.5
a_intersect_dist = numpy.linalg.norm(a - intersect)
if a_intersect_dist == 0:
return 1.0
return numpy.linalg.norm(pt - intersect) / a_intersect_dist
def _nodeTransformChanged(self, *args) -> None:
self._cache_dirty = True
def _getTexCoordsFromClick(self, node: SceneNode, x: float, y: float) -> Tuple[int, Optional[numpy.ndarray]]:
face_id = self._faces_selection_pass.getFaceIdAtPosition(x, y)
if face_id < 0 or face_id >= node.getMeshData().getFaceCount():
return face_id, None
pt = self._picking_pass.getPickedPosition(x, y).getData()
va, vb, vc = self._mesh_transformed_cache.getFaceNodes(face_id)
face_uv_coordinates = node.getMeshData().getFaceUvCoords(face_id)
if face_uv_coordinates is None:
return face_id, None
ta, tb, tc = face_uv_coordinates
# 'Weight' of each vertex that would produce point pt, so we can generate the texture coordinates from the uv ones of the vertices.
# See (also) https://mathworld.wolfram.com/BarycentricCoordinates.html
wa = PaintTool._get_intersect_ratio_via_pt(va, pt, vb, vc)
wb = PaintTool._get_intersect_ratio_via_pt(vb, pt, vc, va)
wc = PaintTool._get_intersect_ratio_via_pt(vc, pt, va, vb)
wt = wa + wb + wc
if wt == 0:
return face_id, None
wa /= wt
wb /= wt
wc /= wt
texcoords = wa * ta + wb * tb + wc * tc
return face_id, texcoords
def _iteratateSplitSubstroke(self, node, substrokes,
info_a: Tuple[Tuple[float, float], Tuple[int, Optional[numpy.ndarray]]],
info_b: Tuple[Tuple[float, float], Tuple[int, Optional[numpy.ndarray]]]) -> None:
click_a, (face_a, texcoords_a) = info_a
click_b, (face_b, texcoords_b) = info_b
if (abs(click_a[0] - click_b[0]) < 0.0001 and abs(click_a[1] - click_b[1]) < 0.0001) or (face_a < 0 and face_b < 0):
return
if face_b < 0 or face_a == face_b:
substrokes.append((self._last_text_coords, texcoords_a))
return
if face_a < 0:
substrokes.append((self._last_text_coords, texcoords_b))
return
mouse_mid = (click_a[0] + click_b[0]) / 2.0, (click_a[1] + click_b[1]) / 2.0
face_mid, texcoords_mid = self._getTexCoordsFromClick(node, mouse_mid[0], mouse_mid[1])
mid_struct = (mouse_mid, (face_mid, texcoords_mid))
if face_mid == face_a:
substrokes.append((texcoords_a, texcoords_mid))
self._iteratateSplitSubstroke(node, substrokes, mid_struct, info_b)
elif face_mid == face_b:
substrokes.append((texcoords_mid, texcoords_b))
self._iteratateSplitSubstroke(node, substrokes, info_a, mid_struct)
else:
self._iteratateSplitSubstroke(node, substrokes, mid_struct, info_b)
self._iteratateSplitSubstroke(node, substrokes, info_a, mid_struct)
def event(self, event: Event) -> bool:
"""Handle mouse and keyboard events.
:param event: The event to handle.
:return: Whether this event has been caught by this tool (True) or should
be passed on (False).
"""
super().event(event)
controller = Application.getInstance().getController()
node = Selection.getSelectedObject(0)
if node is None:
return False
# Make sure the displayed values are updated if the bounding box of the selected mesh(es) changes
if event.type == Event.ToolActivateEvent:
return True
if event.type == Event.ToolDeactivateEvent:
return True
if self._state != PaintTool.Paint.State.READY:
return False
if event.type == Event.MouseReleaseEvent and self._controller.getToolsEnabled():
if MouseEvent.LeftButton not in cast(MouseEvent, event).buttons:
return False
self._mouse_held = False
self._last_text_coords = None
self._last_mouse_coords = None
self._last_face_id = None
return True
is_moved = event.type == Event.MouseMoveEvent
is_pressed = event.type == Event.MousePressEvent
if (is_moved or is_pressed) and self._controller.getToolsEnabled():
if is_moved and not self._mouse_held:
return False
mouse_evt = cast(MouseEvent, event)
if is_pressed:
if MouseEvent.LeftButton not in mouse_evt.buttons:
return False
else:
self._mouse_held = True
if not self._faces_selection_pass:
self._faces_selection_pass = CuraApplication.getInstance().getRenderer().getRenderPass("selection_faces")
if not self._faces_selection_pass:
return False
if not self._picking_pass:
self._picking_pass = CuraApplication.getInstance().getRenderer().getRenderPass("picking_selected")
if not self._picking_pass:
return False
camera = self._controller.getScene().getActiveCamera()
if not camera:
return False
if node != self._node_cache:
if self._node_cache is not None:
self._node_cache.transformationChanged.disconnect(self._nodeTransformChanged)
self._node_cache = node
self._node_cache.transformationChanged.connect(self._nodeTransformChanged)
self._cache_dirty = True
if self._cache_dirty:
self._cache_dirty = False
self._mesh_transformed_cache = self._node_cache.getMeshDataTransformed()
if not self._mesh_transformed_cache:
return False
face_id, texcoords = self._getTexCoordsFromClick(node, mouse_evt.x, mouse_evt.y)
if texcoords is None:
return False
if self._last_text_coords is None:
self._last_text_coords = texcoords
self._last_mouse_coords = (mouse_evt.x, mouse_evt.y)
self._last_face_id = face_id
substrokes = []
if face_id == self._last_face_id:
substrokes.append((self._last_text_coords, texcoords))
else:
self._iteratateSplitSubstroke(node, substrokes,
(self._last_mouse_coords, (self._last_face_id, self._last_text_coords)),
((mouse_evt.x, mouse_evt.y), (face_id, texcoords)))
w, h = self._view.getUvTexDimensions()
for start_coords, end_coords in substrokes:
sub_image, (start_x, start_y) = self._createStrokeImage(
start_coords[0] * w,
start_coords[1] * h,
end_coords[0] * w,
end_coords[1] * h
)
self._view.addStroke(sub_image, start_x, start_y, self._brush_color, is_moved)
self._last_text_coords = texcoords
self._last_mouse_coords = (mouse_evt.x, mouse_evt.y)
self._last_face_id = face_id
self._updateScene(node)
return True
return False
def getRequiredExtraRenderingPasses(self) -> list[str]:
return ["selection_faces", "picking_selected"]
@staticmethod
def _updateScene(node: SceneNode = None):
if node is None:
node = Selection.getSelectedObject(0)
if node is not None:
Application.getInstance().getController().getScene().sceneChanged.emit(node)
def _onSelectionChanged(self):
super()._onSelectionChanged()
self.setActiveView("PaintTool" if len(Selection.getAllSelectedObjects()) == 1 else None)
self._updateState()
def _updateState(self):
if len(Selection.getAllSelectedObjects()) == 1 and self._controller.getActiveTool() == self:
selected_object = Selection.getSelectedObject(0)
if selected_object.callDecoration("getPaintTexture") is not None:
new_state = PaintTool.Paint.State.READY
else:
new_state = PaintTool.Paint.State.PREPARING_MODEL
self._prepare_texture_job = PrepareTextureJob(selected_object)
self._prepare_texture_job.finished.connect(self._onPrepareTextureFinished)
self._prepare_texture_job.start()
else:
new_state = PaintTool.Paint.State.MULTIPLE_SELECTION
if new_state != self._state:
self._state = new_state
self.propertyChanged.emit()
def _onPrepareTextureFinished(self, job: Job):
if job == self._prepare_texture_job:
self._prepare_texture_job = None
self._state = PaintTool.Paint.State.READY
self.propertyChanged.emit()
def _updateIgnoreUnselectedObjects(self):
ignore_unselected_objects = self._controller.getActiveView().name == "PaintTool"
CuraApplication.getInstance().getRenderer().getRenderPass("selection").setIgnoreUnselectedObjects(ignore_unselected_objects)
CuraApplication.getInstance().getRenderer().getRenderPass("selection_faces").setIgnoreUnselectedObjects(ignore_unselected_objects)

View file

@ -0,0 +1,301 @@
// Copyright (c) 2025 UltiMaker
// Cura is released under the terms of the LGPLv3 or higher.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import UM 1.7 as UM
import Cura 1.0 as Cura
Item
{
id: base
width: childrenRect.width
height: childrenRect.height
UM.I18nCatalog { id: catalog; name: "cura"}
Action
{
id: undoAction
shortcut: "Ctrl+L"
enabled: UM.Controller.properties.getValue("CanUndo")
onTriggered: UM.Controller.triggerAction("undoStackAction")
}
Action
{
id: redoAction
shortcut: "Ctrl+Shift+L"
enabled: UM.Controller.properties.getValue("CanRedo")
onTriggered: UM.Controller.triggerAction("redoStackAction")
}
Column
{
id: mainColumn
spacing: UM.Theme.getSize("default_margin").height
RowLayout
{
id: rowPaintMode
width: parent.width
PaintModeButton
{
text: catalog.i18nc("@action:button", "Seam")
icon: "Seam"
tooltipText: catalog.i18nc("@tooltip", "Refine seam placement by defining preferred/avoidance areas")
mode: "seam"
}
PaintModeButton
{
text: catalog.i18nc("@action:button", "Support")
icon: "Support"
tooltipText: catalog.i18nc("@tooltip", "Refine support placement by defining preferred/avoidance areas")
mode: "support"
visible: false
}
}
//Line between the sections.
Rectangle
{
width: parent.width
height: UM.Theme.getSize("default_lining").height
color: UM.Theme.getColor("lining")
}
RowLayout
{
id: rowBrushColor
UM.Label
{
text: catalog.i18nc("@label", "Mark as")
}
BrushColorButton
{
id: buttonPreferredArea
color: "preferred"
text: catalog.i18nc("@action:button", "Preferred")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("CheckBadge", "low")
color: UM.Theme.getColor("paint_preferred_area")
}
}
BrushColorButton
{
id: buttonAvoidArea
color: "avoid"
text: catalog.i18nc("@action:button", "Avoid")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("CancelBadge", "low")
color: UM.Theme.getColor("paint_avoid_area")
}
}
BrushColorButton
{
id: buttonEraseArea
color: "none"
text: catalog.i18nc("@action:button", "Erase")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("Eraser")
color: UM.Theme.getColor("icon")
}
}
}
RowLayout
{
id: rowBrushShape
UM.Label
{
text: catalog.i18nc("@label", "Brush Shape")
}
BrushShapeButton
{
id: buttonBrushCircle
shape: Cura.PaintToolBrush.CIRCLE
text: catalog.i18nc("@action:button", "Circle")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("Circle")
color: UM.Theme.getColor("icon")
}
}
BrushShapeButton
{
id: buttonBrushSquare
shape: Cura.PaintToolBrush.SQUARE
text: catalog.i18nc("@action:button", "Square")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("MeshTypeNormal")
color: UM.Theme.getColor("icon")
}
}
}
UM.Label
{
text: catalog.i18nc("@label", "Brush Size")
}
UM.Slider
{
id: shapeSizeSlider
width: parent.width
indicatorVisible: false
from: 10
to: 1000
value: UM.Controller.properties.getValue("BrushSize")
onPressedChanged: function(pressed)
{
if(! pressed)
{
UM.Controller.setProperty("BrushSize", shapeSizeSlider.value);
}
}
}
//Line between the sections.
Rectangle
{
width: parent.width
height: UM.Theme.getSize("default_lining").height
color: UM.Theme.getColor("lining")
}
RowLayout
{
UM.ToolbarButton
{
id: undoButton
enabled: undoAction.enabled
text: catalog.i18nc("@action:button", "Undo Stroke")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("ArrowReset")
color: UM.Theme.getColor("icon")
}
onClicked: undoAction.trigger()
}
UM.ToolbarButton
{
id: redoButton
enabled: redoAction.enabled
text: catalog.i18nc("@action:button", "Redo Stroke")
toolItem: UM.ColorImage
{
source: UM.Theme.getIcon("ArrowReset")
color: UM.Theme.getColor("icon")
transform: [
Scale { xScale: -1; origin.x: width/2 }
]
}
onClicked: redoAction.trigger()
}
Cura.SecondaryButton
{
id: clearButton
text: catalog.i18nc("@button", "Clear all")
onClicked: UM.Controller.triggerAction("clear")
}
}
}
Rectangle
{
id: waitPrepareItem
anchors.fill: parent
color: UM.Theme.getColor("main_background")
visible: UM.Controller.properties.getValue("State") === Cura.PaintToolState.PREPARING_MODEL
ColumnLayout
{
anchors.fill: parent
UM.Label
{
Layout.fillWidth: true
Layout.fillHeight: true
Layout.verticalStretchFactor: 2
text: catalog.i18nc("@label", "Preparing model for painting...")
verticalAlignment: Text.AlignBottom
horizontalAlignment: Text.AlignHCenter
}
Item
{
Layout.preferredWidth: loadingIndicator.width
Layout.alignment: Qt.AlignHCenter
Layout.fillHeight: true
Layout.verticalStretchFactor: 1
UM.ColorImage
{
id: loadingIndicator
anchors.top: parent.top
anchors.left: parent.left
width: UM.Theme.getSize("card_icon").width
height: UM.Theme.getSize("card_icon").height
source: UM.Theme.getIcon("ArrowDoubleCircleRight")
color: UM.Theme.getColor("text_default")
RotationAnimator
{
target: loadingIndicator
from: 0
to: 360
duration: 2000
loops: Animation.Infinite
running: true
alwaysRunToEnd: true
}
}
}
}
}
Rectangle
{
id: selectSingleMessageItem
anchors.fill: parent
color: UM.Theme.getColor("main_background")
visible: UM.Controller.properties.getValue("State") === Cura.PaintToolState.MULTIPLE_SELECTION
UM.Label
{
anchors.fill: parent
text: catalog.i18nc("@label", "Select a single model to start painting")
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignHCenter
}
}
}

View file

@ -0,0 +1,104 @@
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
from typing import cast, Optional
from PyQt6.QtCore import QRect, QPoint
from PyQt6.QtGui import QUndoCommand, QImage, QPainter
from UM.View.GL.Texture import Texture
class PaintUndoCommand(QUndoCommand):
"""Provides the command that does the actual painting on objects with undo/redo mechanisms"""
def __init__(self,
texture: Texture,
stroke_mask: QImage,
x: int,
y: int,
set_value: int,
bit_range: tuple[int, int],
mergeable: bool) -> None:
super().__init__()
self._original_texture_image: Optional[QImage] = texture.getImage().copy() if not mergeable else None
self._texture: Texture = texture
self._stroke_mask: QImage = stroke_mask
self._x: int = x
self._y: int = y
self._set_value: int = set_value
self._bit_range: tuple[int, int] = bit_range
self._mergeable: bool = mergeable
def id(self) -> int:
# Since the undo stack will contain only commands of this type, we can use a fixed ID
return 0
def redo(self) -> None:
actual_image = self._texture.getImage()
bit_range_start, bit_range_end = self._bit_range
full_int32 = 0xffffffff
clear_texture_bit_mask = full_int32 ^ (((full_int32 << (32 - 1 - (bit_range_end - bit_range_start))) & full_int32) >> (
32 - 1 - bit_range_end))
image_rect = QRect(0, 0, self._stroke_mask.width(), self._stroke_mask.height())
clear_bits_image = self._stroke_mask.copy()
clear_bits_image.invertPixels()
painter = QPainter(clear_bits_image)
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Lighten)
painter.fillRect(image_rect, clear_texture_bit_mask)
painter.end()
set_value_image = self._stroke_mask.copy()
painter = QPainter(set_value_image)
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Multiply)
painter.fillRect(image_rect, self._set_value)
painter.end()
stroked_image = actual_image.copy(self._x, self._y, self._stroke_mask.width(), self._stroke_mask.height())
painter = QPainter(stroked_image)
painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceAndDestination)
painter.drawImage(0, 0, clear_bits_image)
painter.setCompositionMode(QPainter.CompositionMode.RasterOp_SourceOrDestination)
painter.drawImage(0, 0, set_value_image)
painter.end()
self._texture.setSubImage(stroked_image, self._x, self._y)
def undo(self) -> None:
if self._original_texture_image is not None:
self._texture.setSubImage(self._original_texture_image.copy(self._x,
self._y,
self._stroke_mask.width(),
self._stroke_mask.height()),
self._x,
self._y)
def mergeWith(self, command: QUndoCommand) -> bool:
if not isinstance(command, PaintUndoCommand):
return False
paint_undo_command = cast(PaintUndoCommand, command)
if not paint_undo_command._mergeable:
return False
self_rect = QRect(QPoint(self._x, self._y), self._stroke_mask.size())
command_rect = QRect(QPoint(paint_undo_command._x, paint_undo_command._y), paint_undo_command._stroke_mask.size())
bounding_rect = self_rect.united(command_rect)
merged_mask = QImage(bounding_rect.width(), bounding_rect.height(), self._stroke_mask.format())
merged_mask.fill(0)
painter = QPainter(merged_mask)
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Lighten)
painter.drawImage(self._x - bounding_rect.x(), self._y - bounding_rect.y(), self._stroke_mask)
painter.drawImage(paint_undo_command._x - bounding_rect.x(), paint_undo_command._y - bounding_rect.y(), paint_undo_command._stroke_mask)
painter.end()
self._x = bounding_rect.x()
self._y = bounding_rect.y()
self._stroke_mask = merged_mask
return True

View file

@ -0,0 +1,173 @@
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
import os
from PyQt6.QtCore import QRect, pyqtSignal
from typing import Optional, Dict
from PyQt6.QtGui import QImage, QUndoStack
from cura.CuraApplication import CuraApplication
from cura.BuildVolume import BuildVolume
from cura.CuraView import CuraView
from UM.PluginRegistry import PluginRegistry
from UM.View.GL.ShaderProgram import ShaderProgram
from UM.View.GL.Texture import Texture
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Scene.Selection import Selection
from UM.View.GL.OpenGL import OpenGL
from UM.i18n import i18nCatalog
from UM.Math.Color import Color
from .PaintUndoCommand import PaintUndoCommand
catalog = i18nCatalog("cura")
class PaintView(CuraView):
"""View for model-painting."""
class PaintType:
def __init__(self, display_color: Color, value: int):
self.display_color: Color = display_color
self.value: int = value
def __init__(self) -> None:
super().__init__(use_empty_menu_placeholder = True)
self._paint_shader: Optional[ShaderProgram] = None
self._current_paint_texture: Optional[Texture] = None
self._current_bits_ranges: tuple[int, int] = (0, 0)
self._current_paint_type = ""
self._paint_modes: Dict[str, Dict[str, "PaintView.PaintType"]] = {}
self._paint_undo_stack: QUndoStack = QUndoStack()
self._paint_undo_stack.setUndoLimit(32) # Set a quite low amount since every command copies the full texture
self._paint_undo_stack.canUndoChanged.connect(self.canUndoChanged)
self._paint_undo_stack.canRedoChanged.connect(self.canRedoChanged)
application = CuraApplication.getInstance()
application.engineCreatedSignal.connect(self._makePaintModes)
self._scene = application.getController().getScene()
canUndoChanged = pyqtSignal(bool)
canRedoChanged = pyqtSignal(bool)
def canUndo(self):
return self._paint_undo_stack.canUndo()
def canRedo(self):
return self._paint_undo_stack.canRedo()
def _makePaintModes(self):
theme = CuraApplication.getInstance().getTheme()
usual_types = {"none": self.PaintType(Color(*theme.getColor("paint_normal_area").getRgb()), 0),
"preferred": self.PaintType(Color(*theme.getColor("paint_preferred_area").getRgb()), 1),
"avoid": self.PaintType(Color(*theme.getColor("paint_avoid_area").getRgb()), 2)}
self._paint_modes = {
"seam": usual_types,
"support": usual_types,
}
self._current_paint_type = "seam"
def _checkSetup(self):
if not self._paint_shader:
shader_filename = os.path.join(PluginRegistry.getInstance().getPluginPath("PaintTool"), "paint.shader")
self._paint_shader = OpenGL.getInstance().createShaderProgram(shader_filename)
def addStroke(self, stroke_mask: QImage, start_x: int, start_y: int, brush_color: str, merge_with_previous: bool) -> None:
if self._current_paint_texture is None or self._current_paint_texture.getImage() is None:
return
self._prepareDataMapping()
current_image = self._current_paint_texture.getImage()
texture_rect = QRect(0, 0, current_image.width(), current_image.height())
stroke_rect = QRect(start_x, start_y, stroke_mask.width(), stroke_mask.height())
intersect_rect = texture_rect.intersected(stroke_rect)
if intersect_rect != stroke_rect:
# Stroke doesn't fully fit into the image, we have to crop it
stroke_mask = stroke_mask.copy(intersect_rect.x() - start_x,
intersect_rect.y() - start_y,
intersect_rect.width(),
intersect_rect.height())
start_x = intersect_rect.x()
start_y = intersect_rect.y()
bit_range_start, bit_range_end = self._current_bits_ranges
set_value = self._paint_modes[self._current_paint_type][brush_color].value << bit_range_start
self._paint_undo_stack.push(PaintUndoCommand(self._current_paint_texture,
stroke_mask,
start_x,
start_y,
set_value,
(bit_range_start, bit_range_end),
merge_with_previous))
def undoStroke(self) -> None:
self._paint_undo_stack.undo()
def redoStroke(self) -> None:
self._paint_undo_stack.redo()
def getUvTexDimensions(self):
if self._current_paint_texture is not None:
return self._current_paint_texture.getWidth(), self._current_paint_texture.getHeight()
return 0, 0
def getPaintType(self) -> str:
return self._current_paint_type
def setPaintType(self, paint_type: str) -> None:
self._current_paint_type = paint_type
def _prepareDataMapping(self):
node = Selection.getAllSelectedObjects()[0]
if node is None:
return
paint_data_mapping = node.callDecoration("getTextureDataMapping")
if self._current_paint_type not in paint_data_mapping:
new_mapping = self._add_mapping(paint_data_mapping, len(self._paint_modes[self._current_paint_type]))
paint_data_mapping[self._current_paint_type] = new_mapping
node.callDecoration("setTextureDataMapping", paint_data_mapping)
self._current_bits_ranges = paint_data_mapping[self._current_paint_type]
@staticmethod
def _add_mapping(actual_mapping: Dict[str, tuple[int, int]], nb_storable_values: int) -> tuple[int, int]:
start_index = 0
if actual_mapping:
start_index = max(end_index for _, end_index in actual_mapping.values()) + 1
end_index = start_index + int.bit_length(nb_storable_values - 1) - 1
return start_index, end_index
def beginRendering(self) -> None:
if self._current_paint_type not in self._paint_modes:
return
self._checkSetup()
renderer = self.getRenderer()
for node in DepthFirstIterator(self._scene.getRoot()):
if isinstance(node, BuildVolume):
node.render(renderer)
paint_batch = renderer.createRenderBatch(shader=self._paint_shader)
renderer.addRenderBatch(paint_batch)
for node in Selection.getAllSelectedObjects():
paint_batch.addItem(node.getWorldTransformation(copy=False), node.getMeshData(), normal_transformation=node.getCachedNormalMatrix())
self._current_paint_texture = node.callDecoration("getPaintTexture")
self._paint_shader.setTexture(0, self._current_paint_texture)
self._paint_shader.setUniformValue("u_bitsRangesStart", self._current_bits_ranges[0])
self._paint_shader.setUniformValue("u_bitsRangesEnd", self._current_bits_ranges[1])
colors = [paint_type_obj.display_color for paint_type_obj in self._paint_modes[self._current_paint_type].values()]
colors_values = [[int(color_part * 255) for color_part in [color.r, color.g, color.b]] for color in colors]
self._paint_shader.setUniformValueArray("u_renderColors", colors_values)

View file

@ -0,0 +1,33 @@
# Copyright (c) 2025 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM.Job import Job
from UM.Scene.SceneNode import SceneNode
from UM.View.GL.OpenGL import OpenGL
class PrepareTextureJob(Job):
"""
Background job to prepare a model for painting, i.e. do the UV-unwrapping and create the appropriate texture image,
which can last a few seconds
"""
def __init__(self, node: SceneNode):
super().__init__()
self._node: SceneNode = node
def run(self) -> None:
# If the model has already-provided UV coordinates, we can only assume that the associated texture
# should be a square
texture_width = texture_height = 4096
mesh = self._node.getMeshData()
if not mesh.hasUVCoordinates():
texture_width, texture_height = mesh.calculateUnwrappedUVCoordinates()
self._node.callDecoration("prepareTexture", texture_width, texture_height)
if hasattr(mesh, OpenGL.VertexBufferProperty):
# Force clear OpenGL buffer so that new UV coordinates will be sent
delattr(mesh, OpenGL.VertexBufferProperty)

View file

@ -0,0 +1,35 @@
# Copyright (c) 2025 UltiMaker
# Cura is released under the terms of the LGPLv3 or higher.
from . import PaintTool
from . import PaintView
from PyQt6.QtQml import qmlRegisterUncreatableType
from UM.i18n import i18nCatalog
i18n_catalog = i18nCatalog("cura")
def getMetaData():
return {
"tool": {
"name": i18n_catalog.i18nc("@action:button", "Paint"),
"description": i18n_catalog.i18nc("@info:tooltip", "Paint Model"),
"icon": "Visual",
"tool_panel": "PaintTool.qml",
"weight": 0
},
"view": {
"name": i18n_catalog.i18nc("@item:inmenu", "Paint view"),
"weight": 0,
"visible": False
}
}
def register(app):
qmlRegisterUncreatableType(PaintTool.PaintTool.Brush, "Cura", 1, 0, "This is an enumeration class", "PaintToolBrush")
qmlRegisterUncreatableType(PaintTool.PaintTool.Paint, "Cura", 1, 0, "This is an enumeration class", "PaintToolState")
view = PaintView.PaintView()
return {
"tool": PaintTool.PaintTool(view),
"view": view
}

View file

@ -0,0 +1,146 @@
[shaders]
vertex =
uniform highp mat4 u_modelMatrix;
uniform highp mat4 u_viewMatrix;
uniform highp mat4 u_projectionMatrix;
uniform highp mat4 u_normalMatrix;
attribute highp vec4 a_vertex;
attribute highp vec4 a_normal;
attribute highp vec2 a_uvs;
varying highp vec3 v_vertex;
varying highp vec3 v_normal;
varying highp vec2 v_uvs;
void main()
{
vec4 world_space_vert = u_modelMatrix * a_vertex;
gl_Position = u_projectionMatrix * u_viewMatrix * world_space_vert;
v_vertex = world_space_vert.xyz;
v_normal = (u_normalMatrix * normalize(a_normal)).xyz;
v_uvs = a_uvs;
}
fragment =
uniform mediump vec4 u_ambientColor;
uniform highp vec3 u_lightPosition;
uniform highp vec3 u_viewPosition;
uniform sampler2D u_texture;
uniform mediump int u_bitsRangesStart;
uniform mediump int u_bitsRangesEnd;
uniform mediump vec3 u_renderColors[16];
varying highp vec3 v_vertex;
varying highp vec3 v_normal;
varying highp vec2 v_uvs;
void main()
{
mediump vec4 final_color = vec4(0.0);
/* Ambient Component */
final_color += u_ambientColor;
highp vec3 normal = normalize(v_normal);
highp vec3 light_dir = normalize(u_lightPosition - v_vertex);
/* Diffuse Component */
ivec4 texture = ivec4(texture(u_texture, v_uvs) * 255.0);
uint color_index = (texture.r << 16) | (texture.g << 8) | texture.b;
color_index = (color_index << (32 - 1 - u_bitsRangesEnd)) >> 32 - 1 - (u_bitsRangesEnd - u_bitsRangesStart);
vec4 diffuse_color = vec4(u_renderColors[color_index] / 255.0, 1.0);
highp float n_dot_l = mix(0.3, 0.7, dot(normal, light_dir));
final_color += (n_dot_l * diffuse_color);
final_color.a = 1.0;
frag_color = final_color;
}
vertex41core =
#version 410
uniform highp mat4 u_modelMatrix;
uniform highp mat4 u_viewMatrix;
uniform highp mat4 u_projectionMatrix;
uniform highp mat4 u_normalMatrix;
in highp vec4 a_vertex;
in highp vec4 a_normal;
in highp vec2 a_uvs;
out highp vec3 v_vertex;
out highp vec3 v_normal;
out highp vec2 v_uvs;
void main()
{
vec4 world_space_vert = u_modelMatrix * a_vertex;
gl_Position = u_projectionMatrix * u_viewMatrix * world_space_vert;
v_vertex = world_space_vert.xyz;
v_normal = (u_normalMatrix * normalize(a_normal)).xyz;
v_uvs = a_uvs;
}
fragment41core =
#version 410
uniform mediump vec4 u_ambientColor;
uniform highp vec3 u_lightPosition;
uniform highp vec3 u_viewPosition;
uniform sampler2D u_texture;
uniform mediump int u_bitsRangesStart;
uniform mediump int u_bitsRangesEnd;
uniform mediump vec3 u_renderColors[16];
in highp vec3 v_vertex;
in highp vec3 v_normal;
in highp vec2 v_uvs;
out vec4 frag_color;
void main()
{
mediump vec4 final_color = vec4(0.0);
/* Ambient Component */
final_color += u_ambientColor;
highp vec3 normal = normalize(v_normal);
highp vec3 light_dir = normalize(u_lightPosition - v_vertex);
/* Diffuse Component */
ivec4 texture = ivec4(texture(u_texture, v_uvs) * 255.0);
uint color_index = (texture.r << 16) | (texture.g << 8) | texture.b;
color_index = (color_index << (32 - 1 - u_bitsRangesEnd)) >> 32 - 1 - (u_bitsRangesEnd - u_bitsRangesStart);
vec4 diffuse_color = vec4(u_renderColors[color_index] / 255.0, 1.0);
highp float n_dot_l = mix(0.3, 0.7, dot(normal, light_dir));
final_color += (n_dot_l * diffuse_color);
final_color.a = 1.0;
frag_color = final_color;
}
[defaults]
u_ambientColor = [0.3, 0.3, 0.3, 1.0]
u_texture = 0
[bindings]
u_modelMatrix = model_matrix
u_viewMatrix = view_matrix
u_projectionMatrix = projection_matrix
u_normalMatrix = normal_matrix
u_lightPosition = light_0_position
u_viewPosition = camera_position
[attributes]
a_vertex = vertex
a_normal = normal
a_uvs = uv0

View file

@ -0,0 +1,8 @@
{
"name": "Paint Tools",
"author": "UltiMaker",
"version": "1.0.0",
"description": "Provides the paint tools.",
"api": 8,
"i18n-catalog": "cura"
}

View file

@ -1,16 +1,21 @@
# 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.
"""
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.
Changes:
09/14/23 (GV) Added support for One-at-a-Time print sequence.
12/15/23 (GV) Split off 'Single Fan By Layer', 'Multi-fan By Layer', 'Single Fan By Feature', and 'Multi-fan By Feature' from the main 'execute' script.
01/05/24 (GV) Revised the regex replacements.
12/11/24 (GV) Added 'off_fan_speed' for the idle nozzle layer cooling fan. It does not have to go to 0%.
03/22/25 (GV) Added 'Chamber Cooling Fan / Auxiliary Fan' control.
"""
from ..Script import Script
from UM.Application import Application
@ -43,7 +48,8 @@ class AddCoolingProfile(Script):
"type": "bool",
"enabled": true,
"value": true,
"default_value": true
"default_value": true,
"read_only": true
},
"feature_fan_start_layer":
{
@ -273,67 +279,180 @@ class AddCoolingProfile(Script):
"maximum_value": 100,
"unit": "% ",
"enabled": "fan_enable_raft"
},
"enable_off_fan_speed":
{
"label": "Enable 'Off speed' of the idle fan",
"description": "For machines with independent layer cooling fans. Leaving a fan running while the other nozzle is printing can help with oozing. You can pick the speed % for the idle nozzle layer cooling fan to hold at.",
"type": "bool",
"default_value": false,
"enabled": "enable_off_fan_speed_enable and self.extruder_count > 1"
},
"off_fan_speed":
{
"label": " 'Off' speed of idle nozzle fan",
"description": "This is the speed that the 'idle nozzle' layer cooling fan will maintain rather than being turned off completely.",
"type": "int",
"default_value": 35,
"minimum_value": 0,
"maximum_value": 100,
"unit": "% ",
"enabled": "enable_off_fan_speed_enable and enable_off_fan_speed and self.extruder_count > 1"
},
"enable_off_fan_speed_enable":
{
"label": "Hidden setting",
"description": "For dual extruder printers, this enables 'enable_off_fan_speed'.",
"type": "bool",
"default_value": false,
"enabled": false
},
"bv_fan_speed_control_enable":
{
"label": "Enable 'Chamber/Aux Fan' control",
"description": "Controls the 'Build Volume Fan' or an 'Auxiliary Fan' on printers with that hardware. Provides: 'On' layer, 'Off' layer, and PWM speed control of a secondary fan.",
"type": "bool",
"default_value": false,
"enabled": "enable_bv_fan"
},
"bv_fan_nr":
{
"label": " Chamber/Aux Fan Number",
"description": "The mainboard circuit number of the Chamber or Auxiliary Fan.",
"type": "int",
"unit": "# ",
"default_value": 0,
"minimum_value": 0,
"enabled": "enable_bv_fan and bv_fan_speed_control_enable"
},
"bv_fan_speed":
{
"label": " Chamber/Aux Fan Speed %",
"description": "The speed of the Chamber or Auxiliary Fan. This will be converted to PWM Duty Cycle (0-255) or (RepRap 0-1 if that is enabled in Cura). If your specified fan does not operate on variable speeds then set this to '100'.",
"type": "int",
"unit": "% ",
"default_value": 50,
"maximum_value": 100,
"minimum_value": 0,
"enabled": "enable_bv_fan and bv_fan_speed_control_enable"
},
"bv_fan_start_layer":
{
"label": " Chamber/Aux Fan Start Layer",
"description": "The layer to start the Chamber or Auxiliary Fan. Use the Cura preview layer number and the fan will start at the beginning of the layer.",
"type": "int",
"unit": "Layer# ",
"default_value": 1,
"minimum_value": 1,
"enabled": "enable_bv_fan and bv_fan_speed_control_enable"
},
"bv_fan_end_layer":
{
"label": " Chamber/Aux Fan End Layer",
"description": "The layer number for Chamber or Auxiliary Fan to turn off. Use the Cura preview layer number or '-1' to indicate the end of the print. The fan will run until the end of the layer",
"type": "int",
"unit": "Layer# ",
"default_value": -1,
"minimum_value": -1,
"enabled": "enable_bv_fan and bv_fan_speed_control_enable"
},
"enable_bv_fan":
{
"label": "Hidden setting",
"description": "This is enabled when machine_heated_bed is true, and in turn this enables 'bv_fan_speed_control_enable'.",
"type": "bool",
"default_value": false,
"enabled": false
}
}
}"""
def initialize(self) -> None:
super().initialize()
scripts = Application.getInstance().getGlobalContainerStack().getMetaDataEntry("post_processing_scripts")
self.global_stack = Application.getInstance().getGlobalContainerStack()
self.extruder_list = self.global_stack.extruderList
self.extruder_count = self.global_stack.getProperty("machine_extruder_count", "value")
scripts = self.global_stack.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.
# Set 'Remove M106 lines' to "false" if there is already an instance of this script running.
self._instance.setProperty("delete_existing_m106", "value", False)
self._instance.setProperty("enable_off_fan_speed_enable", "value", False)
if self.extruder_count > 1:
if self.extruder_list[0].getProperty("machine_extruder_cooling_fan_number", "value") != self.extruder_list[1].getProperty("machine_extruder_cooling_fan_number", "value"):
self._instance.setProperty("enable_off_fan_speed_enable", "value", True)
if bool(self.global_stack.getProperty("machine_heated_bed", "value")):
self._instance.setProperty("enable_bv_fan", "value", True)
def execute(self, data):
#Initialize variables that are buried in if statements.
mycura = Application.getInstance().getGlobalContainerStack()
"""
Collect the settings from Cura and from this script
params:
t0_fan thru t3_fan: The fan numbers for up to 4 layer cooling circuits
fan_mode: Whether the fan scale will be 0-255 PWM (when true) or 0-1 RepRap (when false)
bed_adhesion: Is only important if a raft is enabled
print_seuence: Options are slightly different if in One-at-a-Time mode
is_multi-fan: Used to distinguish between a multi-extruder with a single fan for each nozzle, or one fan for both nozzles.
is_multi_extr_print: For the slight difference in handling a multi-extruder printer and a print that only uses one of the extruders.
fan_list: A list of fan speeds (even numbered items) and layer numbers (odd numbered items)
feature_speed_list: A list of the speeds for each ';TYPE:' in the gcode
feature_name_list: The list of each 'TYPE' in the gcode
off_fan_speed: The speed that will be maintained by the fan for the inactive extruder (for an anti-oozing effect)
init_fan: The fan number of the first extruder used in a print
delete_existing_m106: The first instance of the script in the post processing list should remove the CUra M106 lines. Following instances should not delete the changes made by the first instance.
feature_fan_combing: Whether or not to shut the cooling fan off during travel moves.
the_start_layer: When in By Feature this is the user selected start of the fan changes.
the_end_layer: When in By Feature this is the user selected end of the fan changes
the_end_is_enabled: When in By Feature, if the fan control ends before the print ends, then this will enable the Final Fan Speed to carry through to the print end.
"""
# Exit if the gcode has been previously post-processed.
if ";POSTPROCESSED" in data[0]:
return data
# Initialize variables that are buried in if statements.
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)
# 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
# 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:
fan_mode = not bool(self.extruder_list[0].getProperty("machine_scale_fan_speed_zero_to_one", "value"))
except AttributeError:
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:
bed_adhesion = (self.extruder_list[0].getProperty("adhesion_type", "value"))
print_sequence = str(self.global_stack.getProperty("print_sequence", "value"))
# Assign the fan numbers to the tools
if self.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")))
if int((self.extruder_list[0].getProperty("machine_extruder_cooling_fan_number", "value"))) > 0:
t0_fan = " P" + str((self.extruder_list[0].getProperty("machine_extruder_cooling_fan_number", "value")))
else:
#No P parameter if there is a single fan circuit------------------
# 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:
# Get the cooling fan numbers for each extruder if the printer has multiple extruders
elif self.extruder_count > 1:
is_multi_fan = True
t0_fan = " P" + str((extruder[0].getProperty("machine_extruder_cooling_fan_number", "value")))
t0_fan = " P" + str((self.extruder_list[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")))
if self.extruder_count > 1: t1_fan = " P" + str((self.extruder_list[1].getProperty("machine_extruder_cooling_fan_number", "value")))
if self.extruder_count > 2: t2_fan = " P" + str((self.extruder_list[2].getProperty("machine_extruder_cooling_fan_number", "value")))
if self.extruder_count > 3: t3_fan = " P" + str((self.extruder_list[3].getProperty("machine_extruder_cooling_fan_number", "value")))
#Initialize the fan_list with defaults----------------------------
# 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"-------------------------
# 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
# 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")
@ -343,25 +462,25 @@ class AddCoolingProfile(Script):
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
# 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"
# 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.
# 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.
except ValueError:
the_end_layer = -1 # If there is an input error then default to the entire gcode file.
## Get the speed for each feature
# 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")
@ -376,20 +495,29 @@ class AddCoolingProfile(Script):
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
# 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
# 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
# For multi-extruder printers with separate cooling fans the 'idle' nozzle fan can be left on for ooze control
off_fan_speed = 0
if self.extruder_count > 1:
if self.getSettingValueByKey("enable_off_fan_speed"):
if fan_mode:
off_fan_speed = round(int(self.getSettingValueByKey("off_fan_speed")) * 2.55)
else:
off_fan_speed = round(int(self.getSettingValueByKey("off_fan_speed")) * .01, 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):
# Catch the number of raft layers.
for l_num in range(1,len(data) - 1):
layer = data[l_num]
if ";LAYER:-" in layer:
number_of_raft_layers += 1
@ -399,14 +527,14 @@ class AddCoolingProfile(Script):
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.
# 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):
# Bypass the file header and ending gcode.
for num in range(1,len(data)-1):
lines = data[num]
if "T0" in lines:
T0_used = True
@ -418,7 +546,7 @@ class AddCoolingProfile(Script):
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.
# 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]
@ -431,7 +559,7 @@ class AddCoolingProfile(Script):
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.
# 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:
@ -445,23 +573,23 @@ class AddCoolingProfile(Script):
init_fan = t3_fan
else:
init_fan = ""
## Assign the variable values if "Raft Enabled"
# 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.
# 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 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.
# 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):
@ -471,12 +599,12 @@ class AddCoolingProfile(Script):
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
# 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
# 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):
@ -486,9 +614,9 @@ class AddCoolingProfile(Script):
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
# Turn the raft fan on
lines.insert(1, fan_sp_raft + str(t0_fan))
## Shut the raft fan off at layer 0
# 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)
@ -496,13 +624,13 @@ class AddCoolingProfile(Script):
layer = data[raft_start_index]
lines = layer.split("\n")
if ";LAYER:-" in layer:
## Turn the raft fan on
# 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
# Shut the raft fan off
lines.insert(1, "M106 S0" + str(init_fan))
data[layer_0_index] = "\n".join(lines)
else:
@ -513,41 +641,44 @@ class AddCoolingProfile(Script):
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.
# 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 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))
if self.extruder_count > 1 and t1_fan != t0_fan: temp_startup.insert(len(temp_startup)-2,"M106 S0" + str(t1_fan))
if self.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 self.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' 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 bool(self.getSettingValueByKey("bv_fan_speed_control_enable")):
data = self._control_bv_fan(data)
# 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"---------------------------------------------
# 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)
return self._multi_fan_by_layer(data, layer_0_index, fan_list, t0_fan, t1_fan, t2_fan, t3_fan, fan_mode, off_fan_speed)
#Single Fan "By Feature"------------------------------------------
# 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"-------------------------------------------
# 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)
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, fan_mode, off_fan_speed)
# The Single Fan "By Layer"----------------------------------------
# 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
@ -557,15 +688,15 @@ class AddCoolingProfile(Script):
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
# 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 "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, fan_mode: bool, off_fan_speed: str)->str:
multi_fan_data = data
layer_number = "0"
current_fan_speed = "0"
@ -573,15 +704,16 @@ class AddCoolingProfile(Script):
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
# 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:
except ValueError:
pass
## Move the start point if delete_existing_m106 is false
# Move the start point if delete_existing_m106 is false
start_index = int(start_index) + int(layer_0_index)
## Track the tool number
# Track the tool number
for num in range(1,int(start_index),1):
layer = multi_fan_data[num]
lines = layer.split("\n")
@ -598,18 +730,19 @@ class AddCoolingProfile(Script):
elif line == "T3":
prev_fan = this_fan
this_fan = t3_fan
# With Active Tool tracked - now the body of changes can start
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.
# Prepare to turn off 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 += f"M106 S{off_fan_speed}" + prev_fan + "\n"
modified_data += fan_line + "\n"
modified_data += "M106 S" + str(current_fan_speed) + this_fan + "\n"
prev_fan = this_fan
@ -620,19 +753,22 @@ class AddCoolingProfile(Script):
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
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
# Insure the fans get shut off if 'off_fan_speed' was enabled
if self.extruder_count > 1 and self.getSettingValueByKey("enable_off_fan_speed"):
multi_fan_data[-1] += "M106 S0 P1\nM106 S0 P0\n"
return multi_fan_data
# Single fan by feature-----------------------------------------------
# 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
# 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]
@ -652,15 +788,16 @@ class AddCoolingProfile(Script):
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 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 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, fan_mode: bool, off_fan_speed: str)->str:
multi_fan_data = data
layer_number = "0"
start_index = 1
@ -673,7 +810,7 @@ class AddCoolingProfile(Script):
if ";LAYER:" + str(the_start_layer) + "\n" in layer:
start_index = int(my_index) - 1
break
## Track the previous tool changes
# Track the previous tool changes
for num in range(1,start_index,1):
layer = multi_fan_data[num]
lines = layer.split("\n")
@ -690,7 +827,7 @@ class AddCoolingProfile(Script):
elif line == "T3":
prev_fan = this_fan
this_fan = t3_fan
## Get the current tool.
# 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")
@ -702,7 +839,7 @@ class AddCoolingProfile(Script):
if line == "T3": this_fan = t3_fan
prev_fan = this_fan
## Start to make insertions-------------------------------------
# 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")
@ -712,10 +849,10 @@ class AddCoolingProfile(Script):
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"
# Turn off the prev fan
modified_data += f"M106 S{off_fan_speed}" + prev_fan + "\n"
modified_data += line + "\n"
## Turn on the current fan
# Turn on the current fan
modified_data += "M106 S" + str(current_fan_speed) + this_fan + "\n"
prev_fan = this_fan
if ";LAYER:" in line:
@ -725,34 +862,39 @@ class AddCoolingProfile(Script):
temp = line.split(" ")[0]
try:
name_index = feature_name_list.index(temp)
except:
except IndexError:
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"
modified_data += f"M106 S{off_fan_speed}" + 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.
# 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
# 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 = ""
# Insure the fans get shut off if 'off_fan_speed' was enabled
if self.extruder_count > 1 and self.getSettingValueByKey("enable_off_fan_speed"):
multi_fan_data[-1] += "M106 S0 P1\nM106 S0 P0\n"
return multi_fan_data
#Try to catch layer input errors, set the minimum speed to 12%, and put the strings together
# 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:
@ -768,7 +910,7 @@ class AddCoolingProfile(Script):
if int(fan_string_p) > 100: fan_string_p = "100"
except ValueError:
fan_string_p = "0"
## Set the minimum fan speed to 12%
# 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)
@ -784,7 +926,7 @@ class AddCoolingProfile(Script):
#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%
# 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:
@ -798,7 +940,7 @@ class AddCoolingProfile(Script):
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
# Copy the data to new_data and make the insertions there
new_data = lines
g0_count = 0
g0_index = -1
@ -818,12 +960,12 @@ class AddCoolingProfile(Script):
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--------
# 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
# 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
@ -834,4 +976,35 @@ class AddCoolingProfile(Script):
g0_index = -1
is_travel = False
comment_data[lay_num] = "\n".join(new_data)
return comment_data
return comment_data
def _control_bv_fan(self, bv_data: str) -> str:
# Control any secondary fan. Can be used for an Auxilliary/Chamber fan
bv_start_layer = self.getSettingValueByKey("bv_fan_start_layer") - 1
bv_end_layer = self.getSettingValueByKey("bv_fan_end_layer")
bv_fan_nr = self.getSettingValueByKey("bv_fan_nr")
if bv_end_layer != -1:
bv_end_layer -= 1
# Get the PWM speed or if RepRap then the 0-1 speed
if self.extruder_list[0].getProperty("machine_scale_fan_speed_zero_to_one", "value"):
bv_fan_speed = round(self.getSettingValueByKey("bv_fan_speed") * .01, 1)
else:
bv_fan_speed = int(self.getSettingValueByKey("bv_fan_speed") * 2.55)
# Turn the chamber fan on
for index, layer in enumerate(bv_data):
if f";LAYER:{bv_start_layer}\n" in layer:
bv_data[index] = re.sub(f";LAYER:{bv_start_layer}", f";LAYER:{bv_start_layer}\nM106 S{bv_fan_speed} P{bv_fan_nr}",layer)
break
# Turn the chamber fan off
if bv_end_layer == -1:
bv_data[len(bv_data)-2] += f"M106 S0 P{bv_fan_nr}\n"
else:
for index, layer in enumerate(bv_data):
if f";LAYER:{bv_end_layer}\n" in layer:
lines = layer.split("\n")
for fdex, line in enumerate(lines):
if ";TIME_ELAPSED:" in line:
lines[fdex] = f"M106 S0 P{bv_fan_nr}\n" + line
bv_data[index] = "\n".join(lines)
break
return bv_data

View file

@ -0,0 +1,571 @@
"""
Copyright (c) 2025 GregValiant (Greg Foresi)
When Annealing:
The user may elect to hold the build plate at a temperature for a period of time. When the hold expires, the 'Timed Cooldown' will begin.
If there is no 'Hold Time' then the 'Annealing' cooldown will begin when the print ends. In 'Annealing' the bed temperature drops in 3° increments across the time span.
G4 commands are used for the cooldown steps.
If there is a 'Heated Chamber' then the chamber will start to cool when the bed temperature reaches the chamber temperature.
When drying filament:
The bed must be empty because the printer will auto-home before raising the Z to 'machine_height minus 20mm' and then park the head in the XY.
The bed will heat up to the set point.
G4 commands are used to keep the machine from turning the bed off until the Drying Time has expired.
If you happen to have an enclosure with a fan, the fan can be set up to run during the drying or annealing.
NOTE: This script uses the G4 Dwell command as a timer. It cannot be canceled from the LCD. If you wish to 'escape' from G4 you might have to cancel the print from the LCD or cycle the printer on and off to reset.
"""
from UM.Application import Application
from ..Script import Script
from UM.Message import Message
class AnnealingOrDrying(Script):
def initialize(self) -> None:
super().initialize()
# Get the Bed Temperature from Cura
self.global_stack = Application.getInstance().getGlobalContainerStack()
bed_temp_during_print = str(self.global_stack.getProperty("material_bed_temperature", "value"))
self._instance.setProperty("startout_temp", "value", bed_temp_during_print)
# Get the Build Volume temperature if there is one
heated_build_volume = bool(self.global_stack.getProperty("machine_heated_build_volume", "value"))
chamber_fan_nr = self.global_stack.getProperty("build_volume_fan_nr", "value")
extruder_count = self.global_stack.getProperty("machine_extruder_count", "value")
if heated_build_volume:
chamber_temp = self.global_stack.getProperty("build_volume_temperature", "value")
self._instance.setProperty("has_build_volume_heater", "value", heated_build_volume)
self._instance.setProperty("build_volume_temp", "value", chamber_temp)
try:
if chamber_fan_nr > 0:
self._instance.setProperty("enable_chamber_fan_setting", "value", True)
except:
pass
def getSettingDataString(self):
return """{
"name": "Annealing CoolDown or Filament Drying",
"key": "AnnealingOrDrying",
"metadata": {},
"version": 2,
"settings":
{
"enable_script":
{
"label": "Enable the Script",
"description": "If it isn't enabled it doesn't run.",
"type": "bool",
"default_value": true,
"enabled": true
},
"cycle_type":
{
"label": "Anneal Print or Dry Filament",
"description": "Whether to Anneal the Print (by keeping the bed hot for a period of time), or to use the bed as a Filament Dryer. If drying; you will still need to slice a model, but it will not print. The gcode will consist only of a short script to heat the bed, wait for a while, then turn the bed off. The 'Z' will move to the max height and XY park position so the filament can be covered. The 'Hold Time', 'Bed Start Temp' and (if applicable) the 'Chamber Temp' come from these settings rather than from the Cura settings. When annealing; the Timed Cooldown will commence when the print ends.",
"type": "enum",
"options":
{
"anneal_cycle": "Anneal Print",
"dry_cycle": "Dry Filament"
},
"default_value": "anneal_cycle",
"enabled": true,
"enabled": "enable_script"
},
"heating_zone_selection":
{
"label": "Hold the Temp for the:",
"description": "Select the 'Bed' for just the bed, or 'Bed and Chamber' if you want to include your 'Heated Build Volume'.",
"type": "enum",
"options":
{
"bed_only": "Bed",
"bed_chamber": "Bed and Chamber"
},
"default_value": "bed_only",
"enabled": "enable_script"
},
"wait_time":
{
"label": "Hold Time at Temp(s)",
"description": "Hold the bed temp at the 'Bed Start Out Temperature' for this amount of time (in decimal hours). When this time expires then the Annealing cool down will start. This is also the 'Drying Time' used when 'Drying Filament'.",
"type": "float",
"default_value": 0.0,
"unit": "Decimal Hrs ",
"enabled": "enable_script and cycle_type == 'anneal_cycle'"
},
"dry_time":
{
"label": "Drying Time",
"description": "Hold the bed temp at the 'Bed Start Out Temperature' for this amount of time (in decimal hours). When this time expires the bed will shut off.",
"type": "float",
"default_value": 4.0,
"unit": "Decimal Hrs ",
"enabled": "enable_script and cycle_type == 'dry_cycle'"
},
"pause_cmd":
{
"label": "Pause Cmd for Auto-Home",
"description": "Not required when you are paying attention and the bed is empty; ELSE; Enter the pause command to use prior to the Auto-Home command. The pause insures that the user IS paying attention and clears the build plate for Auto-Home. If you leave the box empty then there won't be a pause.",
"type": "str",
"default_value": "",
"enabled": "enable_script and cycle_type == 'dry_cycle'"
},
"startout_temp":
{
"label": "Bed Start Out Temperature:",
"description": "Enter the temperature to start at. This is typically the bed temperature during the print but can be changed here. This is also the temperature used when drying filament.",
"type": "int",
"value": 30,
"unit": "Degrees ",
"minimum_value": 30,
"maximum_value": 110,
"maximum_value_warning": 100,
"enabled": "enable_script"
},
"lowest_temp":
{
"label": "Shut-Off Temp:",
"description": "Enter the lowest temperature to control the cool down. This is the shut-off temperature for the build plate and (when applicable) the Heated Chamber. The minimum value is 30",
"type": "int",
"default_value": 30,
"unit": "Degrees ",
"minimum_value": 30,
"enabled": "enable_script and cycle_type == 'anneal_cycle'"
},
"build_volume_temp":
{
"label": "Build Volume Temperature:",
"description": "Enter the temperature for the Build Volume (Heated Chamber). This is typically the temperature during the print but can be changed here.",
"type": "int",
"value": 24,
"unit": "Degrees ",
"minimum_value": 0,
"maximum_value": 90,
"maximum_value_warning": 75,
"enabled": "enable_script and has_build_volume_heater and heating_zone_selection == 'bed_chamber'"
},
"enable_chamber_fan_setting":
{
"label": "Hidden Setting",
"description": "Enables chamber fan and speed.",
"type": "bool",
"default_value": false,
"enabled": false
},
"chamber_fan_speed":
{
"label": "Chamber Fan Speed",
"description": "Set to % fan speed. Set to 0 to turn it off.",
"type": "int",
"default_value": 0,
"minimum_value": 0,
"maximum_value": 100,
"unit": "% ",
"enabled": "enable_script and enable_chamber_fan_setting"
},
"time_span":
{
"label": "Cool Down Time Span:",
"description": "The total amount of time (in decimal hours) to control the cool down. The build plate temperature will be dropped in 3° increments across this time span. 'Cool Down Time' starts at the end of the 'Hold Time' if you entered one.",
"type": "float",
"default_value": 1.0,
"unit": "Decimal Hrs ",
"minimum_value_warning": 0.25,
"enabled": "enable_script and cycle_type == 'anneal_cycle'"
},
"park_head":
{
"label": "Park at MaxX and MaxY",
"description": "When unchecked, the park position is X0 Y0. Enable this setting to move the nozzle to the Max X and Max Y to allow access to the print.",
"type": "bool",
"default_value": false,
"enabled": "enable_script and cycle_type == 'anneal_cycle'"
},
"park_max_z":
{
"label": "Move to MaxZ",
"description": "Enable this setting to move the nozzle to 'Machine_Height - 20' to allow the print to be covered.",
"type": "bool",
"default_value": false,
"enabled": "enable_script and cycle_type == 'anneal_cycle'"
},
"beep_when_done":
{
"label": "Beep when done",
"description": "Add an annoying noise when the Cool Down completes.",
"type": "bool",
"default_value": true,
"enabled": "enable_script"
},
"beep_duration":
{
"label": "Beep Duration",
"description": "The length of the buzzer sound. Units are in milliseconds so 1000ms = 1 second.",
"type": "int",
"unit": "milliseconds ",
"default_value": 1000,
"enabled": "beep_when_done and enable_script"
},
"add_messages":
{
"label": "Include M117 and M118 messages",
"description": "Add messages to the LCD and any print server.",
"type": "bool",
"default_value": false,
"enabled": "enable_script"
},
"has_build_volume_heater":
{
"label": "Hidden setting",
"description": "Hidden. This setting enables the build volume settings.",
"type": "bool",
"default_value": false,
"enabled": false
}
}
}"""
def execute(self, data):
# Exit if there is no heated bed.
if not bool(self.global_stack.getProperty("machine_heated_bed", "value")):
Message(title = "[Anneal or Dry Filament]", text = "The script did not run because Heated Bed is disabled in Machine Settings.").show()
return data
# Enter a message in the gcode if the script is not enabled.
if not bool(self.getSettingValueByKey("enable_script")):
data[0] += "; [Anneal or Dry Filament] was not enabled\n"
return data
lowest_temp = int(self.getSettingValueByKey("lowest_temp"))
# If the shutoff temp is under 30° then exit as a safety precaution so the bed doesn't stay on.
if lowest_temp < 30:
data[0] += "; Anneal or Dry Filament did not run. Shutoff Temp < 30\n"
Message(title = "[Anneal or Dry Filament]", text = "The script did not run because the Shutoff Temp is less than 30°.").show()
return data
extruders = self.global_stack.extruderList
bed_temperature = int(self.getSettingValueByKey("startout_temp"))
heated_chamber = bool(self.global_stack.getProperty("machine_heated_build_volume", "value"))
heating_zone = self.getSettingValueByKey("heating_zone_selection")
# Get the heated chamber temperature or set to 0 if no chamber
if heated_chamber:
chamber_temp = str(self.getSettingValueByKey("build_volume_temp"))
else:
heating_zone = "bed_only"
chamber_temp = "0"
# Beep line
if bool(self.getSettingValueByKey("beep_when_done")):
beep_duration = self.getSettingValueByKey("beep_duration")
self.beep_string = f"M300 S440 P{beep_duration} ; Beep\n"
else:
self.beep_string = ""
# For compatibility with earlier Cura versions
if self.global_stack.getProperty("build_volume_fan_nr", "value") is not None:
has_bv_fan = bool(self.global_stack.getProperty("build_volume_fan_nr", "value"))
bv_fan_nr = int(self.global_stack.getProperty("build_volume_fan_nr", "value"))
if bv_fan_nr > 0:
speed_bv_fan = int(self.getSettingValueByKey("chamber_fan_speed"))
else:
speed_bv_fan = 0
if bool(extruders[0].getProperty("machine_scale_fan_speed_zero_to_one", "value")) and has_bv_fan:
speed_bv_fan = round(speed_bv_fan * 0.01)
else:
speed_bv_fan = round(speed_bv_fan * 2.55)
if has_bv_fan and speed_bv_fan > 0:
self.bv_fan_on_str = f"M106 S{speed_bv_fan} P{bv_fan_nr} ; Build Chamber Fan On\n"
self.bv_fan_off_str = f"M106 S0 P{bv_fan_nr} ; Build Chamber Fan Off\n"
else:
self.bv_fan_on_str = ""
self.bv_fan_off_str = ""
else:
has_bv_fan = False
bv_fan_nr = 0
speed_bv_fan = 0
self.bv_fan_on_str = ""
self.bv_fan_off_str = ""
# Park Head
max_y = str(self.global_stack.getProperty("machine_depth", "value"))
max_x = str(self.global_stack.getProperty("machine_width", "value"))
# Max_z is limited to 'machine_height - 20' just so the print head doesn't smack into anything.
max_z = str(int(self.global_stack.getProperty("machine_height", "value")) - 20)
speed_travel = str(round(extruders[0].getProperty("speed_travel", "value")*60))
park_xy = bool(self.getSettingValueByKey("park_head"))
park_z = bool(self.getSettingValueByKey("park_max_z"))
cycle_type = self.getSettingValueByKey("cycle_type")
add_messages = bool(self.getSettingValueByKey("add_messages"))
if cycle_type == "anneal_cycle":
data = self._anneal_print(add_messages, data, bed_temperature, chamber_temp, heated_chamber, heating_zone, lowest_temp, max_x, max_y, max_z, park_xy, park_z, speed_travel)
elif cycle_type == "dry_cycle":
data = self._dry_filament_only(data, bed_temperature, chamber_temp, heated_chamber, heating_zone, max_y, max_z, speed_travel)
return data
def _anneal_print(
self,
add_messages: bool,
anneal_data: str,
bed_temperature: int,
chamber_temp: str,
heated_chamber: bool,
heating_zone: str,
lowest_temp: int,
max_x: str,
max_y: str,
max_z: str,
park_xy: bool,
park_z: bool,
speed_travel: str) -> str:
"""
The procedure disables the M140 (and M141) lines at the end of the print, and adds additional bed (and chamber) temperature commands to the end of the G-Code file.
The bed is allowed to cool down over a period of time.
:param add_messages: Whether to include M117 and M118 messages for LCD and print server
:param anneal_data: The G-code data to be modified with annealing commands
:param bed_temperature: Starting bed temperature in degrees Celsius
:param chamber_temp: Chamber/build volume temperature in degrees Celsius as string
:param heated_chamber: Whether the printer has a heated build volume/chamber
:param heating_zone: Zone selection - "bed_only" or "bed_chamber"
:param lowest_temp: Final shutdown temperature in degrees Celsius
:param max_x: Maximum X axis position for parking as string
:param max_y: Maximum Y axis position for parking as string
:param max_z: Maximum Z axis position (machine height - 20mm) as string
:param park_xy: Whether to park the print head at max X and Y positions
:param park_z: Whether to raise Z to maximum safe height
:param speed_travel: Travel speed for positioning moves in mm/min as string
:return: Modified G-code data with annealing cooldown sequence
"""
# Put the head parking string together
bed_temp_during_print = int(self.global_stack.getProperty("material_bed_temperature", "value"))
time_minutes = 1
time_span = int(float(self.getSettingValueByKey("time_span")) * 3600)
park_string = ""
if park_xy:
park_string += f"G0 F{speed_travel} X{max_x} Y{max_y} ; Park XY\n"
if park_z:
park_string += f"G0 Z{max_z} ; Raise Z to 'ZMax - 20'\n"
if not park_xy and not park_z:
park_string += f"G91 ; Relative movement\nG0 F{speed_travel} Z5 ; Raise Z\nG90 ; Absolute movement\nG0 X0 Y0 ; Park\n"
park_string += "M84 X Y E ; Disable steppers except Z\n"
# Calculate the temperature differential
hysteresis = bed_temperature - lowest_temp
# Exit if the bed temp is below the shutoff temp
if hysteresis <= 0:
anneal_data[0] += "; Anneal or Dry Filament did not run. Bed Temp < Shutoff Temp\n"
Message(title = "Anneal or Dry Filament", text = "Did not run because the Bed Temp < Shutoff Temp.").show()
return anneal_data
# Drop the bed temperature in 3° increments.
num_steps = int(hysteresis / 3)
step_index = 2
deg_per_step = int(hysteresis / num_steps)
time_per_step = int(time_span / num_steps)
step_down = bed_temperature
wait_time = int(float(self.getSettingValueByKey("wait_time")) * 3600)
# Put the first lines of the anneal string together
anneal_string = ";\n;TYPE:CUSTOM ---------------- Anneal Print\n"
if bed_temperature == bed_temp_during_print:
anneal_string += self.beep_string
if add_messages:
anneal_string += "M117 Cool Down for " + str(round((wait_time + time_span)/3600,2)) + "hr\n"
anneal_string += "M118 Cool Down for " + str(round((wait_time + time_span)/3600,2)) + "hr\n"
anneal_string += self.bv_fan_on_str
if wait_time > 0:
# Add the parking string BEFORE the M190
anneal_string += park_string
if heating_zone == "bed_only":
anneal_string += f"M190 S{bed_temperature} ; Set the bed temp\n{self.beep_string}"
if heating_zone == "bed_chamber":
anneal_string += f"M190 S{bed_temperature} ; Set the bed temp\nM141 S{chamber_temp} ; Set the chamber temp\n{self.beep_string}"
anneal_string += f"G4 S{wait_time} ; Hold for {round(wait_time / 3600,2)} hrs\n"
else:
# Add the parking string AFTER the M140
anneal_string += f"M140 S{step_down} ; Set bed temp\n"
anneal_string += park_string
anneal_string += f"G4 S{time_per_step} ; wait time in seconds\n"
step_down -= deg_per_step
time_remaining = round(time_span/3600,2)
# Step the bed/chamber temps down and add each step to the anneal string. The chamber remains at it's temperature until the bed gets down to that temperature.
for num in range(bed_temperature, lowest_temp, -3):
anneal_string += f"M140 S{step_down} ; Step down bed\n"
if heating_zone == "bed_chamber" and int(step_down) < int(chamber_temp):
anneal_string += f"M141 S{step_down} ; Step down chamber\n"
anneal_string += f"G4 S{time_per_step} ; Wait\n"
if time_remaining >= 1.00:
if add_messages:
anneal_string += f"M117 CoolDown - {round(time_remaining,1)}hr\n"
anneal_string += f"M118 CoolDown - {round(time_remaining,1)}hr\n"
elif time_minutes > 0:
time_minutes = round(time_remaining * 60,1)
if add_messages:
anneal_string += f"M117 CoolDown - {time_minutes}min\n"
anneal_string += f"M118 CoolDown - {time_minutes}min\n"
time_remaining = round((time_span-(step_index*time_per_step))/3600,2)
step_down -= deg_per_step
step_index += 1
if step_down <= lowest_temp:
break
# Close out the anneal string
anneal_string += "M140 S0 ; Shut off the bed heater" + "\n"
if heating_zone == "bed_chamber":
anneal_string += "M141 S0 ; Shut off the chamber heater\n"
anneal_string += self.bv_fan_off_str
anneal_string += self.beep_string
if add_messages:
anneal_string += "M117 CoolDown Complete\n"
anneal_string += "M118 CoolDown Complete\n"
anneal_string += ";TYPE:CUSTOM ---------------- End of Anneal\n;"
# Format the inserted lines.
anneal_lines = anneal_string.split("\n")
for index, line in enumerate(anneal_lines):
if not line.startswith(";") and ";" in line:
front_txt = anneal_lines[index].split(";")[0]
back_txt = anneal_lines[index].split(";")[1]
anneal_lines[index] = front_txt + str(" " * (30 - len(front_txt))) +";" + back_txt
anneal_string = "\n".join(anneal_lines) + "\n"
end_gcode = anneal_data[-1]
end_lines = end_gcode.split("\n")
# Comment out the existing M140 S0 lines in the ending gcode.
for num in range(len(end_lines)-1,-1,-1):
if end_lines[num].startswith("M140 S0"):
end_lines[num] = ";M140 S0 ; Shutoff Overide - Anneal or Dry Filament"
anneal_data[-1] = "\n".join(end_lines)
# If there is a Heated Chamber and it's included then comment out the M141 S0 line
if heating_zone == "bed_chamber" and heated_chamber:
for num in range(0,len(end_lines)-1):
if end_lines[num].startswith("M141 S0"):
end_lines[num] = ";M141 S0 ; Shutoff Overide - Anneal or Dry Filament"
anneal_data[-1] = "\n".join(end_lines)
# If park head is enabled then dont let the steppers disable until the head is parked
disable_string = ""
for num in range(0,len(end_lines)-1):
if end_lines[num][:3] in ("M84", "M18"):
disable_string = end_lines[num] + "\n"
stepper_timeout = int(wait_time + time_span)
if stepper_timeout > 14400: stepper_timeout = 14400
end_lines[num] = ";" + end_lines[num] + " ; Overide - Anneal or Dry Filament"
end_lines.insert(num, "M84 S" + str(stepper_timeout) + " ; Increase stepper timeout - Anneal or Dry Filament")
anneal_data[-1] = "\n".join(end_lines)
break
# The Anneal string is the new end of the gcode so move the 'End of Gcode' comment line in case there are other scripts running
anneal_data[-1] = anneal_data[-1].replace(";End of Gcode", anneal_string + disable_string + ";End of Gcode")
return anneal_data
def _dry_filament_only(
self,
bed_temperature: int,
chamber_temp: int,
drydata: str,
heated_chamber: bool,
heating_zone: str,
max_y: str,
max_z: str,
speed_travel: str) -> str:
"""
This procedure turns the bed on, homes the printer, parks the head. After the time period the bed is turned off.
There is no actual print in the generated gcode, just a couple of moves to get the nozzle out of the way, and the bed heat (and possibly chamber heat) control.
It allows a user to use the bed to warm up and hopefully dry a filament roll.
:param bed_temperature: Bed temperature for drying in degrees Celsius
:param chamber_temp: Chamber/build volume temperature for drying in degrees Celsius
:param drydata: The G-code data to be replaced with filament drying commands
:param heated_chamber: Whether the printer has a heated build volume/chamber
:param heating_zone: Zone selection - "bed_only" or "bed_chamber"
:param max_y: Maximum Y axis position for parking as string
:param max_z: Maximum Z axis position (machine height - 20mm) as string
:param speed_travel: Travel speed for positioning moves in mm/min as string
:return: Modified G-code data containing only filament drying sequence
"""
for num in range(2, len(drydata)):
drydata[num] = ""
drydata[0] = drydata[0].split("\n")[0] + "\n"
add_messages = bool(self.getSettingValueByKey("add_messages"))
pause_cmd = self.getSettingValueByKey("pause_cmd")
if pause_cmd != "":
pause_cmd = self.beep_string + pause_cmd
dry_time = self.getSettingValueByKey("dry_time") * 3600
lines = drydata[1].split("\n")
drying_string = lines[0] + f"\n;............TYPE:CUSTOM: Dry Filament\n{self.beep_string}"
if add_messages:
drying_string += f"M117 Cool Down for {round(dry_time/3600,2)} hr ; Message\n"
drying_string += f"M118 Cool Down for {round(dry_time/3600,2)} hr ; Message\n"
# M113 sends messages to a print server as a 'Keep Alive' and can generate a lot of traffic over the USB
drying_string += "M113 S0 ; No echo\n"
drying_string += f"M84 S{round(dry_time)} ; Set stepper timeout\n"
drying_string += f"M140 S{bed_temperature} ; Heat bed\n"
drying_string += self.bv_fan_on_str
if heated_chamber and heating_zone == "bed_chamber":
drying_string += f"M141 S{chamber_temp} ; Chamber temp\n"
if pause_cmd == "M0":
pause_cmd = "M0 Clear bed and click...; Pause"
if pause_cmd != "":
drying_string += pause_cmd + " ; Pause\n"
drying_string += "G28 ; Auto-Home\n"
drying_string += f"G0 F{speed_travel} Z{max_z} ; Raise Z to 'ZMax - 20'\n"
drying_string += f"G0 F{speed_travel} X0 Y{max_y} ; Park print head\n"
if dry_time <= 3600:
if add_messages:
drying_string += f"M117 {dry_time/3600} hr remaining ; Message\n"
drying_string += f"M118 {dry_time/3600} hr remaining ; Message\n"
drying_string += f"G4 S{dry_time} ; Dry time\n"
elif dry_time > 3600:
temp_time = dry_time
while temp_time > 3600:
if add_messages:
drying_string += f"M117 {temp_time/3600} hr remaining ; Message\n"
drying_string += f"M118 {temp_time/3600} hr remaining ; Message\n"
drying_string += f"G4 S3600 ; Dry time split\n"
if temp_time > 3600:
temp_time -= 3600
if temp_time > 0:
if add_messages:
drying_string += f"M117 {temp_time/3600} hr remaining ; Message\n"
drying_string += f"M118 {temp_time/3600} hr remaining ; Message\n"
drying_string += f"G4 S{temp_time} ; Dry time\n"
if heated_chamber and heating_zone == "bed_chamber":
drying_string += f"M141 S0 ; Shut off chamber\n"
drying_string += "M140 S0 ; Shut off bed\n"
drying_string += self.bv_fan_off_str
if self.getSettingValueByKey("beep_when_done"):
beep_duration = self.getSettingValueByKey("beep_duration")
drying_string += self.beep_string
if add_messages:
drying_string += "M117 End of drying cycle ; Message\n"
drying_string += "M118 End of drying cycle ; Message\n"
drying_string += "M84 X Y E ; Disable steppers except Z\n"
drying_string += ";End of Gcode"
# Format the lines
lines = drying_string.split("\n")
for index, line in enumerate(lines):
if not line.startswith(";") and ";" in line:
front_txt = lines[index].split(";")[0]
back_txt = lines[index].split(";")[1]
lines[index] = front_txt + str(" " * (30 - len(front_txt))) +";" + back_txt
drydata[1] = "\n".join(lines) + "\n"
dry_txt = "; Drying time ...................... " + str(self.getSettingValueByKey("dry_time")) + " hrs\n"
dry_txt += "; Drying temperature ........ " + str(bed_temperature) + "°\n"
if heated_chamber and heating_zone == "bed_chamber":
dry_txt += "; Chamber temperature ... " + str(chamber_temp) + "°\n"
Message(title = "[Dry Filament]", text = dry_txt).show()
drydata[0] = "; <<< This is a filament drying file only. There is no actual print. >>>\n;\n" + dry_txt + ";\n"
return drydata

View file

@ -35,16 +35,21 @@ class CreateThumbnail(Script):
def _convertSnapshotToGcode(self, encoded_snapshot, width, height, chunk_size=78):
gcode = []
use_thumbnail = self.getSettingValueByKey("use_thumbnail")
use_star = self.getSettingValueByKey("use_star")
encoded_snapshot_length = len(encoded_snapshot)
image_type = "thumbnail" if use_thumbnail else "png"
resolution_symbol = '*' if use_star else 'x'
gcode.append(";")
gcode.append("; thumbnail begin {}x{} {}".format(
width, height, encoded_snapshot_length))
gcode.append("; {} begin {}{}{} {}".format(
image_type, width, resolution_symbol, height, encoded_snapshot_length))
chunks = ["; {}".format(encoded_snapshot[i:i+chunk_size])
for i in range(0, len(encoded_snapshot), chunk_size)]
gcode.extend(chunks)
gcode.append("; thumbnail end")
gcode.append("; {} end".format(image_type))
gcode.append(";")
gcode.append("")
@ -79,6 +84,20 @@ class CreateThumbnail(Script):
"minimum_value": "0",
"minimum_value_warning": "12",
"maximum_value_warning": "600"
},
"use_thumbnail":
{
"label": "Thumbnail Begin/End",
"description": "Use Thumbnail Begin/End rather than PNG",
"type": "bool",
"default_value": true
},
"use_star":
{
"label": "Use '*' for size of image",
"description": "Use '*' instead of 'x' for size of image as Width '*' Height",
"type": "bool",
"default_value": false
}
}
}"""

View file

@ -1,31 +1,36 @@
# Display Filename and Layer on the LCD by Amanda de Castilho on August 28, 2018
# Modified: Joshua Pope-Lewis on November 16, 2018
# Display Progress on LCD by Mathias Lyngklip Kjeldgaard, Alexander Gee, Kimmo Toivanen, Inigo Martinez on July 31, 2019
# Show Progress was adapted from Display Progress by Louis Wooters on January 6, 2020. His changes are included here.
#---------------------------------------------------------------
# DisplayNameOrProgressOnLCD.py
# Cura Post-Process plugin
# Combines 'Display Filename and Layer on the LCD' with 'Display Progress'
# Combined and with additions by: GregValiant (Greg Foresi)
# Date: September 8, 2023
# NOTE: This combined post processor will make 'Display Filename and Layer on the LCD' and 'Display Progress' obsolete
# Description: Display Filename and Layer options:
# Status messages sent to the printer...
# - Scrolling (SCROLL_LONG_FILENAMES) if enabled in Marlin and you aren't printing a small item select this option.
# - Name: By default it will use the name generated by Cura (EG: TT_Test_Cube) - You may enter a custom name here
# - Start Num: Choose which number you prefer for the initial layer, 0 or 1
# - Max Layer: Enabling this will show how many layers are in the entire print (EG: Layer 1 of 265!)
# - Add prefix 'Printing': Enabling this will add the prefix 'Printing'
# - Example Line on LCD: Printing Layer 0 of 395 3DBenchy
# Display Progress options:
# - Display Total Layer Count
# - Disply Time Remaining for the print
# - Time Fudge Factor % - Divide the Actual Print Time by the Cura Estimate. Enter as a percentage and the displayed time will be adjusted. This allows you to bring the displayed time closer to reality (Ex: Entering 87.5 would indicate an adjustment to 87.5% of the Cura estimate).
# - Example line on LCD: 1/479 | ET 2h13m
# - Time to Pauses changes the M117/M118 lines to countdown to the next pause as 1/479 | TP 2h36m
# - 'Add M118 Line' is available with either option. M118 will bounce the message back to a remote print server through the USB connection.
# - 'Add M73 Line' is used by 'Display Progress' only. There are options to incluse M73 P(percent) and M73 R(time remaining)
# - Enable 'Finish-Time' Message - when enabled, takes the Print Time and calculates when the print will end. It takes into account the Time Fudge Factor. The user may enter a print start time. This is also available for Display Filename.
"""
Display Filename and Layer on the LCD by Amanda de Castilho on August 28, 2018
Modified: Joshua Pope-Lewis on November 16, 2018
Display Progress on LCD by Mathias Lyngklip Kjeldgaard, Alexander Gee, Kimmo Toivanen, Inigo Martinez on July 31, 2019
Show Progress was adapted from Display Progress by Louis Wooters on January 6, 2020. His changes are included here.
---------------------------------------------------------------
DisplayNameOrProgressOnLCD.py
Cura Post-Process plugin
Combines 'Display Filename and Layer on the LCD' with 'Display Progress'
Combined and with additions by: GregValiant (Greg Foresi)
Date: September 8, 2023
Date: March 31, 2024 - Bug fix for problem with adding M118 lines if 'Remaining Time' was not checked.
NOTE: This combined post processor will make 'Display Filename and Layer on the LCD' and 'Display Progress' obsolete
Description: Display Filename and Layer options:
Status messages sent to the printer...
- Scrolling (SCROLL_LONG_FILENAMES) if enabled in Marlin and you aren't printing a small item select this option.
- Name: By default it will use the name generated by Cura (EG: TT_Test_Cube) - You may enter a custom name here
- Start Num: Choose which number you prefer for the initial layer, 0 or 1
- Max Layer: Enabling this will show how many layers are in the entire print (EG: Layer 1 of 265!)
- Add prefix 'Printing': Enabling this will add the prefix 'Printing'
- Example Line on LCD: Printing Layer 0 of 395 3DBenchy
Display Progress options:
- Display Total Layer Count
- Disply Time Remaining for the print
- Time Fudge Factor % - Divide the Actual Print Time by the Cura Estimate. Enter as a percentage and the displayed time will be adjusted.
This allows you to bring the displayed time closer to reality (Ex: Entering 87.5 would indicate an adjustment to 87.5% of the Cura estimate).
- Example line on LCD: 1/479 | ET 2h13m
- Time to Pauses changes the M117/M118 lines to countdown to the next pause as 1/479 | TP 2h36m
- 'Add M118 Line' is available with either option. M118 will bounce the message back to a remote print server through the USB connection.
- 'Add M73 Line' is used by 'Display Progress' only. There are options to incluse M73 P(percent) and M73 R(time remaining)
- Enable 'Finish-Time' Message - when enabled, takes the Print Time and calculates when the print will end. It uses the Time Fudge Factor. The user may enter a print start time.
Date: June 30, 2025 Cost of electricity added to the other print statistics in '_add_stats'.
"""
from ..Script import Script
from UM.Application import Application
@ -37,6 +42,19 @@ from UM.Message import Message
class DisplayInfoOnLCD(Script):
def initialize(self) -> None:
super().initialize()
try:
if Application.getInstance().getGlobalContainerStack().getProperty("print_sequence", "value") == "all_at_once":
enable_countdown = True
self._instance.setProperty("enable_countdown", "value", enable_countdown)
except AttributeError:
# Handle cases where the global container stack or its properties are not accessible
pass
except KeyError:
# Handle cases where the "print_sequence" property is missing
pass
def getSettingDataString(self):
return """{
"name": "Display Info on LCD",
@ -77,7 +95,7 @@ class DisplayInfoOnLCD(Script):
"label": "Initial layer number:",
"description": "Choose which number you prefer for the initial layer, 0 or 1",
"type": "int",
"default_value": 0,
"default_value": 1,
"minimum_value": 0,
"maximum_value": 1,
"enabled": "display_option == 'filename_layer'"
@ -114,17 +132,40 @@ class DisplayInfoOnLCD(Script):
"default_value": true,
"enabled": "display_option == 'display_progress'"
},
"add_m117_line":
{
"label": "Add M117 Line",
"description": "M117 sends a message to the LCD screen. Some screen firmware will not accept or display messages.",
"type": "bool",
"default_value": true
},
"add_m118_line":
{
"label": "Add M118 Line",
"description": "Adds M118 in addition to the M117. It will bounce the message back through the USB port to a computer print server (if a printer server like Octoprint or Pronterface is in use).",
"type": "bool",
"default_value": false
"default_value": true
},
"add_m118_a1":
{
"label": " Add A1 to M118 Line",
"description": "Adds A1 parameter. A1 adds a double foreslash '//' to the response. Octoprint may require this.",
"type": "bool",
"default_value": false,
"enabled": "add_m118_line"
},
"add_m118_p0":
{
"label": " Add P0 to M118 Line",
"description": "Adds P0 parameter. P0 has the printer send the response out through all it's ports. Octoprint may require this.",
"type": "bool",
"default_value": false,
"enabled": "add_m118_line"
},
"add_m73_line":
{
"label": "Add M73 Line(s)",
"description": "Adds M73 in addition to the M117. For some firmware this will set the printers time and or percentage.",
"description": "Adds M73 in addition to the M117. For some firmware this will set the printers time and or percentage. M75 is added to the beginning of the file and M77 is added to the end of the file. M73 will be added if one or both of the following options is chosen.",
"type": "bool",
"default_value": false,
"enabled": "display_option == 'display_progress'"
@ -132,7 +173,7 @@ class DisplayInfoOnLCD(Script):
"add_m73_percent":
{
"label": " Add M73 Percentage",
"description": "Adds M73 with the P parameter. For some firmware this will set the printers 'percentage' of layers completed and it will count upward.",
"description": "Adds M73 with the P parameter to the start of each layer. For some firmware this will set the printers 'percentage' of layers completed and it will count upward.",
"type": "bool",
"default_value": false,
"enabled": "add_m73_line and display_option == 'display_progress'"
@ -140,10 +181,10 @@ class DisplayInfoOnLCD(Script):
"add_m73_time":
{
"label": " Add M73 Time",
"description": "Adds M73 with the R parameter. For some firmware this will set the printers 'print time' and it will count downward.",
"description": "Adds M73 with the R parameter to the start of each layer. For some firmware this will set the printers 'print time' and it will count downward.",
"type": "bool",
"default_value": false,
"enabled": "add_m73_line and display_option == 'display_progress'"
"enabled": "add_m73_line and display_option == 'display_progress' and display_remaining_time"
},
"speed_factor":
{
@ -154,13 +195,29 @@ class DisplayInfoOnLCD(Script):
"default_value": 100,
"enabled": "enable_end_message or display_option == 'display_progress'"
},
"enable_countdown":
{
"label": "Enable Countdown to Pauses",
"description": "If print sequence is 'one_at_a_time' this is false. This setting is always hidden.",
"type": "bool",
"value": false,
"enabled": false
},
"countdown_to_pause":
{
"label": "Countdown to Pauses",
"description": "Instead of the remaining print time the LCD will show the estimated time to pause (TP).",
"description": "This must run AFTER any script that adds a pause. Instead of the remaining print time the LCD will show the estimated time to the next layer that has a pause (TP). Countdown to Pause is not available when in One-at-a-Time' mode.",
"type": "bool",
"default_value": false,
"enabled": "display_option == 'display_progress'"
"enabled": "display_option == 'display_progress' and enable_countdown and display_remaining_time"
},
"pause_cmd":
{
"label": " What pause command(s) are used?",
"description": "This might be M0, or M25 or M600 if Filament Change is used. If you have mixed commands then delimit them with a comma ',' (Ex: M0,M600). Spaces are not allowed.",
"type": "str",
"default_value": "M0",
"enabled": "display_option == 'display_progress' and countdown_to_pause and enable_countdown and display_remaining_time"
},
"enable_end_message":
{
@ -173,11 +230,29 @@ class DisplayInfoOnLCD(Script):
"print_start_time":
{
"label": "Print Start Time (Ex 16:45)",
"description": "Use 'Military' time. 16:45 would be 4:45PM. 09:30 would be 9:30AM. If you leave this blank it will be assumed that the print will start Now. If you enter a guesstimate of your printer start time and that time is before 'Now' the guesstimate will consider that the print will start tomorrow at the entered time. ",
"description": "Use 'Military' time. 16:45 would be 4:45PM. 09:30 would be 9:30AM. If you leave this blank it will be assumed that the print will start Now. If you enter a guesstimate of your printer start time and that time is before 'Now' then the guesstimate will consider that the print will start tomorrow at the entered time. ",
"type": "str",
"default_value": "",
"unit": "hrs ",
"enabled": "enable_end_message"
},
"electricity_cost":
{
"label": "Electricity Cost per kWh",
"description": "Cost of electricity per kilowatt-hour. This should be on your electric utility bill.",
"type": "float",
"default_value": 0.151,
"minimum_value": 0,
"unit": "€/kWh "
},
"printer_power_usage":
{
"label": "Printer Power Usage",
"description": "Average power usage of the 3D printer in Watts. The actual wattage has many variables. 50% of the power supply rating would be a ballpark figure.",
"type": "float",
"default_value": 175,
"minimum_value": 0,
"unit": "Watts "
}
}
@ -185,239 +260,303 @@ class DisplayInfoOnLCD(Script):
def execute(self, data):
display_option = self.getSettingValueByKey("display_option")
add_m118_line = self.getSettingValueByKey("add_m118_line")
add_m73_line = self.getSettingValueByKey("add_m73_line")
add_m73_time = self.getSettingValueByKey("add_m73_time")
add_m73_percent = self.getSettingValueByKey("add_m73_percent")
# This is Display Filename and Layer on LCD---------------------------------------------------------
self.add_m117_line = self.getSettingValueByKey("add_m117_line")
self.add_m118_line = self.getSettingValueByKey("add_m118_line")
self.add_m118_a1 = self.getSettingValueByKey("add_m118_a1")
self.add_m118_p0 = self.getSettingValueByKey("add_m118_p0")
self.m118_text = "M118 "
self.add_m73_line = self.getSettingValueByKey("add_m73_line")
self.add_m73_time = self.getSettingValueByKey("add_m73_time")
self.add_m73_percent = self.getSettingValueByKey("add_m73_percent")
self.m73_str = ""
para_1 = data[0].split("\n")
for line in para_1:
if line.startswith(";TIME:") or line.startswith(";PRINT.TIME:"):
self.time_total = int(line.split(":")[1])
break
if display_option == "filename_layer":
max_layer = 0
lcd_text = "M117 "
if self.getSettingValueByKey("file_name") != "":
file_name = self.getSettingValueByKey("file_name")
else:
file_name = Application.getInstance().getPrintInformation().jobName
if self.getSettingValueByKey("addPrefixPrinting"):
lcd_text += "Printing "
if not self.getSettingValueByKey("scroll"):
lcd_text += "Layer "
else:
lcd_text += file_name + " - Layer "
i = self.getSettingValueByKey("startNum")
for layer in data:
display_text = lcd_text + str(i)
layer_index = data.index(layer)
lines = layer.split("\n")
for line in lines:
if line.startswith(";LAYER_COUNT:"):
max_layer = line
max_layer = max_layer.split(":")[1]
if self.getSettingValueByKey("startNum") == 0:
max_layer = str(int(max_layer) - 1)
if line.startswith(";LAYER:"):
if self.getSettingValueByKey("maxlayer"):
display_text = display_text + " of " + max_layer
if not self.getSettingValueByKey("scroll"):
display_text = display_text + " " + file_name
else:
if not self.getSettingValueByKey("scroll"):
display_text = display_text + " " + file_name + "!"
else:
display_text = display_text + "!"
line_index = lines.index(line)
lines.insert(line_index + 1, display_text)
if add_m118_line:
lines.insert(line_index + 2, str(display_text.replace("M117", "M118", 1)))
i += 1
final_lines = "\n".join(lines)
data[layer_index] = final_lines
if bool(self.getSettingValueByKey("enable_end_message")):
message_str = self.message_to_user(self.getSettingValueByKey("speed_factor") / 100)
Message(title = "Display Info on LCD - Estimated Finish Time", text = message_str[0] + "\n\n" + message_str[1] + "\n" + message_str[2] + "\n" + message_str[3]).show()
return data
data = self._display_filename_layer(data)
else:
data = self._display_progress(data)
return data
# Display Progress (from 'Show Progress' and 'Display Progress on LCD')---------------------------------------
elif display_option == "display_progress":
# get settings
display_total_layers = self.getSettingValueByKey("display_total_layers")
display_remaining_time = self.getSettingValueByKey("display_remaining_time")
speed_factor = self.getSettingValueByKey("speed_factor") / 100
m73_time = False
m73_percent = False
if add_m73_line and add_m73_time:
m73_time = True
if add_m73_line and add_m73_percent:
m73_percent = True
# initialize global variables
first_layer_index = 0
time_total = 0
number_of_layers = 0
time_elapsed = 0
# if at least one of the settings is disabled, there is enough room on the display to display "layer"
first_section = data[0]
lines = first_section.split("\n")
# This is from the original 'Display Filename and Layer on LCD'
def _display_filename_layer(self, data: str) -> str:
data[0] = self._add_stats(data)
max_layer = 0
format_option = self.getSettingValueByKey("format_option")
lcd_text = "M117 "
octo_text = "M118 "
if self.getSettingValueByKey("file_name") != "":
file_name = self.getSettingValueByKey("file_name")
else:
file_name = Application.getInstance().getPrintInformation().jobName
if self.getSettingValueByKey("addPrefixPrinting"):
lcd_text += "Printing "
octo_text += "Printing "
if not format_option:
lcd_text += "Lay "
octo_text += "Layer "
else:
lcd_text += file_name + " - Layer "
octo_text += file_name + " - Layer "
i = self.getSettingValueByKey("startNum")
for layer in data:
display_text = lcd_text + str(i)
self.m118_text = octo_text + str(i)
layer_index = data.index(layer)
lines = layer.split("\n")
for line in lines:
if line.startswith(";TIME:"):
tindex = lines.index(line)
cura_time = int(line.split(":")[1])
print_time = cura_time * speed_factor
hhh = print_time/3600
hr = round(hhh // 1)
mmm = round((hhh % 1) * 60)
orig_hhh = cura_time/3600
orig_hr = round(orig_hhh // 1)
orig_mmm = math.floor((orig_hhh % 1) * 60)
orig_sec = round((((orig_hhh % 1) * 60) % 1) * 60)
if add_m118_line: lines.insert(tindex + 3,"M118 Adjusted Print Time " + str(hr) + "hr " + str(mmm) + "min")
lines.insert(tindex + 3,"M117 ET " + str(hr) + "hr " + str(mmm) + "min")
# add M73 line at beginning
mins = int(60 * hr + mmm)
if m73_time:
lines.insert(tindex + 3, "M73 R{}".format(mins))
if m73_percent:
lines.insert(tindex + 3, "M73 P0")
# If Countdonw to pause is enabled then count the pauses
pause_str = ""
if bool(self.getSettingValueByKey("countdown_to_pause")):
pause_count = 0
for num in range(2,len(data) - 1, 1):
if "PauseAtHeight.py" in data[num]:
pause_count += 1
pause_str = f" with {pause_count} pause(s)"
# This line goes in to convert seconds to hours and minutes
lines.insert(tindex + 3, f";Cura Time Estimate: {cura_time}sec = {orig_hr}hr {orig_mmm}min {orig_sec}sec {pause_str}")
data[0] = "\n".join(lines)
data[len(data)-1] += "M117 Orig Cura Est " + str(orig_hr) + "hr " + str(orig_mmm) + "min\n"
if add_m118_line: data[len(data)-1] += "M118 Est w/FudgeFactor " + str(speed_factor * 100) + "% was " + str(hr) + "hr " + str(mmm) + "min\n"
if not display_total_layers or not display_remaining_time:
base_display_text = "layer "
else:
base_display_text = ""
layer = data[len(data)-1]
data[len(data)-1] = layer.replace(";End of Gcode" + "\n", "")
data[len(data)-1] += ";End of Gcode" + "\n"
# Search for the number of layers and the total time from the start code
for index in range(len(data)):
data_section = data[index]
# We have everything we need, save the index of the first layer and exit the loop
if ";LAYER:" in data_section:
first_layer_index = index
break
else:
for line in data_section.split("\n"):
if line.startswith(";LAYER_COUNT:"):
number_of_layers = int(line.split(":")[1])
elif line.startswith(";TIME:"):
time_total = int(line.split(":")[1])
# for all layers...
current_layer = 0
for layer_counter in range(len(data)-2):
current_layer += 1
layer_index = first_layer_index + layer_counter
display_text = base_display_text
display_text += str(current_layer)
# create a list where each element is a single line of code within the layer
lines = data[layer_index].split("\n")
if not ";LAYER:" in data[layer_index]:
current_layer -= 1
continue
# add the total number of layers if this option is checked
if display_total_layers:
display_text += "/" + str(number_of_layers)
# if display_remaining_time is checked, it is calculated in this loop
if display_remaining_time:
time_remaining_display = " | ET " # initialize the time display
m = (time_total - time_elapsed) // 60 # estimated time in minutes
m *= speed_factor # correct for printing time
m = int(m)
h, m = divmod(m, 60) # convert to hours and minutes
# add the time remaining to the display_text
if h > 0: # if it's more than 1 hour left, display format = xhxxm
time_remaining_display += str(h) + "h"
if m < 10: # add trailing zero if necessary
time_remaining_display += "0"
time_remaining_display += str(m) + "m"
if line.startswith(";LAYER_COUNT:"):
max_layer = line
max_layer = max_layer.split(":")[1]
if self.getSettingValueByKey("startNum") == 0:
max_layer = str(int(max_layer) - 1)
if line.startswith(";LAYER:"):
if self.getSettingValueByKey("maxlayer"):
display_text += "/" + max_layer
self.m118_text += "/" + max_layer
if not format_option:
display_text += "|" + file_name
self.m118_text += " | " + file_name
else:
time_remaining_display += str(m) + "m"
display_text += time_remaining_display
# find time_elapsed at the end of the layer (used to calculate the remaining time of the next layer)
if not current_layer == number_of_layers:
for line_index in range(len(lines) - 1, -1, -1):
line = lines[line_index]
if line.startswith(";TIME_ELAPSED:"):
# update time_elapsed for the NEXT layer and exit the loop
time_elapsed = int(float(line.split(":")[1]))
break
# insert the text AFTER the first line of the layer (in case other scripts use ";LAYER:")
for l_index, line in enumerate(lines):
if line.startswith(";LAYER:"):
if not format_option:
display_text += "|" + file_name + "!"
self.m118_text += " | " + file_name + "!"
else:
display_text += "!"
self.m118_text += "!"
line_index = lines.index(line)
if self.add_m117_line:
lines.insert(line_index + 1, display_text)
if self.add_m118_line:
if self.add_m118_a1:
self.m118_text = self.m118_text.replace("M118 ","M118 A1 ")
if self.add_m118_p0:
self.m118_text = self.m118_text.replace("M118 ","M118 P0 ")
lines.insert(line_index + 2, self.m118_text)
i += 1
final_lines = "\n".join(lines)
data[layer_index] = final_lines
if bool(self.getSettingValueByKey("enable_end_message")):
message_str = self._message_to_user(self.getSettingValueByKey("speed_factor") / 100)
Message(title = "Display Info on LCD - Estimated Finish Time", text = message_str[0] + "\n\n" + message_str[1] + "\n" + message_str[2] + "\n" + message_str[3]).show()
return data
# This is from 'Show Progress on LCD'
def _display_progress(self, data: str) -> str:
# Add some common print settings to the start of the gcode
data[0] = self._add_stats(data)
# Get settings
print_sequence = Application.getInstance().getGlobalContainerStack().getProperty("print_sequence", "value")
display_total_layers = self.getSettingValueByKey("display_total_layers")
display_remaining_time = self.getSettingValueByKey("display_remaining_time")
speed_factor = self.getSettingValueByKey("speed_factor") / 100
m73_time = False
m73_percent = False
if self.add_m73_line and self.add_m73_time:
m73_time = True
if self.add_m73_line and self.add_m73_percent:
m73_percent = True
if self.add_m73_line:
data[1] = "M75\n" + data[1]
data[len(data)-1] += "M77\n"
# Initialize some variables
first_layer_index = 0
number_of_layers = 0
time_elapsed = 0
# If at least one of the settings is disabled, there is enough room on the display to display "layer"
first_section = data[0]
lines = first_section.split("\n")
pause_cmd = []
for line in lines:
if line.startswith(";TIME:"):
tindex = lines.index(line)
cura_time = int(line.split(":")[1])
print_time = cura_time * speed_factor
hhh = print_time/3600
hr = round(hhh // 1)
mmm = round((hhh % 1) * 60)
orig_hhh = cura_time/3600
orig_hr = round(orig_hhh // 1)
orig_mmm = math.floor((orig_hhh % 1) * 60)
if self.add_m118_line:
lines.insert(len(lines) - 2, f"M118 Adjusted Print Time is {hr} hr {mmm} min")
if self.add_m117_line:
lines.insert(len(lines) - 2, f"M117 ET {hr} hr {mmm} min")
# Add M73 line at beginning
mins = int(60 * hr + mmm)
if self.add_m73_line and (self.add_m73_time or self.add_m73_percent):
if m73_time:
self.m73_str += " R{}".format(mins)
if m73_percent:
self.m73_str += " P0"
lines.insert(tindex + 4, "M73" + self.m73_str)
# If Countdown to pause is enabled then count the pauses
pause_str = ""
if bool(self.getSettingValueByKey("countdown_to_pause")):
pause_count = 0
pause_setting = self.getSettingValueByKey("pause_cmd").upper()
if pause_setting != "":
pause_cmd = []
if "," in pause_setting:
pause_cmd = pause_setting.split(",")
else:
pause_cmd.append(pause_setting)
for q in range(0, len(pause_cmd)):
pause_cmd[q] = "\n" + pause_cmd[q]
for num in range(2,len(data) - 2, 1):
for q in range(0,len(pause_cmd)):
if pause_cmd[q] in data[num]:
pause_count += data[num].count(pause_cmd[q], 0, len(data[num]))
pause_str = f"with {pause_count} pause" + ("s" if pause_count > 1 else "")
else:
pause_str = ""
# This line goes in to convert seconds to hours and minutes
lines.insert(tindex + 1, f";Cura Time Estimate: {orig_hr}hr {orig_mmm}min {pause_str}")
data[0] = "\n".join(lines)
if self.add_m117_line:
data[len(data)-1] += "M117 Orig Cura Est " + str(orig_hr) + "hr " + str(orig_mmm) + "min\n"
if self.add_m118_line:
data[len(data)-1] += "M118 Est w/FudgeFactor " + str(speed_factor * 100) + "% was " + str(hr) + "hr " + str(mmm) + "min\n"
if not display_total_layers or not display_remaining_time:
base_display_text = "layer "
else:
base_display_text = ""
layer = data[len(data)-1]
data[len(data)-1] = layer.replace(";End of Gcode" + "\n", "")
data[len(data)-1] += ";End of Gcode" + "\n"
# Search for the number of layers and the total time from the start code
for index in range(len(data)):
data_section = data[index]
# We have everything we need, save the index of the first layer and exit the loop
if ";LAYER:" in data_section:
first_layer_index = index
break
else:
for line in data_section.split("\n"):
if line.startswith(";LAYER_COUNT:"):
number_of_layers = int(line.split(":")[1])
if print_sequence == "one_at_a_time":
number_of_layers = 1
for lay in range(2,len(data)-1,1):
if ";LAYER:" in data[lay]:
number_of_layers += 1
# for all layers...
current_layer = 0
for layer_counter in range(len(data)-2):
current_layer += 1
layer_index = first_layer_index + layer_counter
display_text = base_display_text
display_text += str(current_layer)
# create a list where each element is a single line of code within the layer
lines = data[layer_index].split("\n")
if not ";LAYER:" in data[layer_index]:
current_layer -= 1
continue
# add the total number of layers if this option is checked
if display_total_layers:
display_text += "/" + str(number_of_layers)
# if display_remaining_time is checked, it is calculated in this loop
if display_remaining_time:
time_remaining_display = " | ET " # initialize the time display
m = (self.time_total - time_elapsed) // 60 # estimated time in minutes
m *= speed_factor # correct for printing time
m = int(m)
h, m = divmod(m, 60) # convert to hours and minutes
# add the time remaining to the display_text
if h > 0: # if it's more than 1 hour left, display format = xhxxm
time_remaining_display += str(h) + "h"
if m < 10: # add trailing zero if necessary
time_remaining_display += "0"
time_remaining_display += str(m) + "m"
else:
time_remaining_display += str(m) + "m"
display_text += time_remaining_display
# find time_elapsed at the end of the layer (used to calculate the remaining time of the next layer)
if not current_layer == number_of_layers:
for line_index in range(len(lines) - 1, -1, -1):
line = lines[line_index]
if line.startswith(";TIME_ELAPSED:"):
# update time_elapsed for the NEXT layer and exit the loop
time_elapsed = int(float(line.split(":")[1]))
break
# insert the text AFTER the first line of the layer (in case other scripts use ";LAYER:")
for l_index, line in enumerate(lines):
if line.startswith(";LAYER:"):
if self.add_m117_line:
lines[l_index] += "\nM117 " + display_text
# add M73 line
if self.add_m118_line:
m118_text = "\nM118 "
if self.add_m118_a1:
m118_text += "A1 "
if self.add_m118_p0:
m118_text += "P0 "
lines[l_index] += m118_text + display_text
# add M73 line
if display_remaining_time:
mins = int(60 * h + m)
if m73_time:
lines[l_index] += "\nM73 R{}".format(mins)
if self.add_m73_line and (self.add_m73_time or self.add_m73_percent):
self.m73_str = ""
if m73_time and display_remaining_time:
self.m73_str += " R{}".format(mins)
if m73_percent:
lines[l_index] += "\nM73 P" + str(round(int(current_layer) / int(number_of_layers) * 100))
if add_m118_line:
lines[l_index] += "\nM118 " + display_text
break
# overwrite the layer with the modified layer
data[layer_index] = "\n".join(lines)
self.m73_str += " P" + str(round(int(current_layer) / int(number_of_layers) * 100))
lines[l_index] += "\nM73" + self.m73_str
break
# overwrite the layer with the modified layer
data[layer_index] = "\n".join(lines)
# If enabled then change the ET to TP for 'Time To Pause'
if bool(self.getSettingValueByKey("countdown_to_pause")):
time_list = []
time_list.append("0")
time_list.append("0")
this_time = 0
pause_index = 1
time_list = []
if bool(self.getSettingValueByKey("countdown_to_pause")):
time_list.append("0")
time_list.append("0")
this_time = 0
pause_index = 1
# Get the layer times
for num in range(2,len(data) - 1):
layer = data[num]
lines = layer.split("\n")
for line in lines:
if line.startswith(";TIME_ELAPSED:"):
this_time = (float(line.split(":")[1]))*speed_factor
time_list.append(str(this_time))
if "PauseAtHeight.py" in layer:
# Get the layer times
for num in range(2,len(data) - 1):
layer = data[num]
lines = layer.split("\n")
for line in lines:
if line.startswith(";TIME_ELAPSED:"):
this_time = (float(line.split(":")[1]))*speed_factor
time_list.append(str(this_time))
for p_cmd in pause_cmd:
if p_cmd in layer:
for qnum in range(num - 1, pause_index, -1):
time_list[qnum] = str(float(this_time) - float(time_list[qnum])) + "P"
pause_index = num-1
break
# Make the adjustments to the M117 (and M118) lines that are prior to a pause
for num in range (2, len(data) - 1,1):
layer = data[num]
lines = layer.split("\n")
for line in lines:
# Make the adjustments to the M117 (and M118) lines that are prior to a pause
for num in range (2, len(data) - 1,1):
layer = data[num]
lines = layer.split("\n")
for line in lines:
try:
if line.startswith("M117") and "|" in line and "P" in time_list[num]:
M117_line = line.split("|")[0] + "| TP "
alt_time = time_list[num][:-1]
hhh = int(float(alt_time) / 3600)
if hhh > 0:
hhr = str(hhh) + "h"
else:
hhr = ""
mmm = ((float(alt_time) / 3600) - (int(float(alt_time) / 3600))) * 60
sss = int((mmm - int(mmm)) * 60)
mmm = str(round(mmm)) + "m"
time_to_go = str(hhr) + str(mmm)
if hhr == "": time_to_go = time_to_go + str(sss) + "s"
M117_line = M117_line + time_to_go
time_to_go = self._get_time_to_go(time_list[num])
M117_line = line.split("|")[0] + "| TP " + time_to_go
layer = layer.replace(line, M117_line)
if line.startswith("M118") and "|" in line and "P" in time_list[num]:
time_to_go = self._get_time_to_go(time_list[num])
M118_line = line.split("|")[0] + "| TP " + time_to_go
layer = layer.replace(line, M118_line)
data[num] = layer
setting_data = ""
if bool(self.getSettingValueByKey("enable_end_message")):
message_str = self.message_to_user(speed_factor)
Message(title = "[Display Info on LCD] - Estimated Finish Time", text = message_str[0] + "\n\n" + message_str[1] + "\n" + message_str[2] + "\n" + message_str[3]).show()
except:
continue
data[num] = layer
if bool(self.getSettingValueByKey("enable_end_message")):
message_str = self._message_to_user(data, speed_factor, pause_cmd)
Message(title = "[Display Info on LCD] - Estimated Finish Time", text = message_str[0] + "\n\n" + message_str[1] + "\n" + message_str[2] + "\n" + message_str[3]).show()
return data
def message_to_user(self, speed_factor: float):
# Message the user of the projected finish time of the print
def _message_to_user(self, data: str, speed_factor: float, pause_cmd: str) -> str:
"""
Message the user of the projected finish time of the print and when any pauses might occur
"""
print_time = Application.getInstance().getPrintInformation().currentPrintTime.getDisplayString(DurationFormat.Format.ISO8601)
print_start_time = self.getSettingValueByKey("print_start_time")
# If the user entered a print start time make sure it is in the correct format or ignore it.
@ -476,8 +615,97 @@ class DisplayInfoOnLCD(Script):
if print_start_time != "":
print_start_str = "Print Start Time................." + str(print_start_time) + "hrs"
else:
print_start_str = "Print Start Time.................Now."
print_start_str = "Print Start Time.................Now"
estimate_str = "Cura Time Estimate.........." + str(print_time)
adjusted_str = "Adjusted Time Estimate..." + str(time_change)
finish_str = week_day + " " + str(mo_str) + " " + str(new_time.strftime("%d")) + ", " + str(new_time.strftime("%Y")) + " at " + str(show_hr) + str(new_time.strftime("%M")) + str(show_ampm)
return finish_str, estimate_str, adjusted_str, print_start_str
finish_str = f"{week_day} {mo_str} {new_time.strftime('%d')}, {new_time.strftime('%Y')} at {show_hr}{new_time.strftime('%M')}{show_ampm}"
# If there are pauses and if countdown is enabled, then add the time-to-pause to the message.
if bool(self.getSettingValueByKey("countdown_to_pause")):
num = 1
for layer in data:
for p_cmd in pause_cmd:
if p_cmd in layer or "Do the actual pause" in layer:
adjusted_str += "\n" + self._get_time_to_go(layer.split("TIME_ELAPSED:")[1].split("\n")[0]) + " ET from start to pause #" + str(num)
num += 1
return finish_str, estimate_str, adjusted_str, print_start_str
def _get_time_to_go(self, time_str: str):
"""
Converts a time string in seconds to a human-readable format (e.g., "2h30m").
:param time_str: The time string in seconds.
:return: A formatted string representing the time.
"""
alt_time = time_str[:-1]
total_seconds = float(alt_time)
hours = int(total_seconds // 3600)
minutes = int((total_seconds % 3600) // 60)
seconds = int(total_seconds % 60)
time_to_go = f"{hours}h" if hours > 0 else ""
time_to_go += f"{minutes}m"
if hours == 0:
time_to_go += f"{seconds}s"
return time_to_go
def _add_stats(self, data: str) -> str:
global_stack = Application.getInstance().getGlobalContainerStack()
"""
Make a list of the models in the file.
Add some of the filament stats to the first section of the gcode.
"""
model_list = []
for mdex, layer in enumerate(data):
layer = data[mdex].split("\n")
for line in layer:
if line.startswith(";MESH:") and "NONMESH" not in line:
model_name = line.split(":")[1]
if not model_name in model_list:
model_list.append(model_name)
# Filament stats
extruder_count = global_stack.getProperty("machine_extruder_count", "value")
layheight_0 = global_stack.getProperty("layer_height_0", "value")
init_layer_hgt_line = ";Initial Layer Height: " + f"{layheight_0:.2f}".format(layheight_0)
filament_line_t0 = ";Extruder 1 (T0)\n"
filament_amount = Application.getInstance().getPrintInformation().materialLengths
filament_line_t0 += f"; Filament used: {filament_amount[0]}m\n"
filament_line_t0 += f"; Filament Type: {global_stack.extruderList[0].material.getMetaDataEntry("material", "")}\n"
filament_line_t0 += f"; Filament Dia.: {global_stack.extruderList[0].getProperty("material_diameter", "value")}mm\n"
filament_line_t0 += f"; Nozzle Size : {global_stack.extruderList[0].getProperty("machine_nozzle_size", "value")}mm\n"
filament_line_t0 += f"; Print Temp. : {global_stack.extruderList[0].getProperty("material_print_temperature", "value")}°\n"
filament_line_t0 += f"; Bed Temp. : {global_stack.extruderList[0].getProperty("material_bed_temperature", "value")}°"
# if there is more than one extruder then get the stats for the second one.
filament_line_t1 = ""
if extruder_count > 1:
filament_line_t1 = "\n;Extruder 2 (T1)\n"
filament_line_t1 += f"; Filament used: {filament_amount[1]}m\n"
filament_line_t1 += f"; Filament Type: {global_stack.extruderList[1].material.getMetaDataEntry("material", "")}\n"
filament_line_t1 += f"; Filament Dia.: {global_stack.extruderList[1].getProperty("material_diameter", "value")}mm\n"
filament_line_t1 += f"; Nozzle Size : {global_stack.extruderList[1].getProperty("machine_nozzle_size", "value")}mm\n"
filament_line_t1 += f"; Print Temp. : {global_stack.extruderList[1].getProperty("material_print_temperature", "value")}°"
# Calculate the cost of electricity for the print
electricity_cost = self.getSettingValueByKey("electricity_cost")
printer_power_usage = self.getSettingValueByKey("printer_power_usage")
currency_unit = Application.getInstance().getPreferences().getValue("cura/currency")
total_cost_electricity = (printer_power_usage / 1000) * (self.time_total / 3600) * electricity_cost
# Add the stats to the gcode file
lines = data[0].split("\n")
for index, line in enumerate(lines):
if line.startswith(";Layer height:") or line.startswith(";TARGET_MACHINE.NAME:"):
lines[index] = ";Layer height: " + f"{global_stack.getProperty("layer_height", "value")}"
lines[index] += f"\n{init_layer_hgt_line}"
lines[index] += f"\n;Base Quality Name : '{global_stack.quality.getMetaDataEntry("name", "")}'"
lines[index] += f"\n;Custom Quality Name: '{global_stack.qualityChanges.getMetaDataEntry("name")}'"
if line.startswith(";Filament used"):
lines[index] = filament_line_t0 + filament_line_t1 + f"\n;Electric Cost: {currency_unit}{total_cost_electricity:.2f}".format(total_cost_electricity)
# The target machine "machine_name" is actually the printer model. This adds the user defined printer name to the "TARGET_MACHINE" line.
if line.startswith(";TARGET_MACHINE"):
machine_model = str(global_stack.getProperty("machine_name", "value"))
machine_name = str(global_stack.getName())
lines[index] += f" / {machine_name}"
if "MINX" in line or "MIN.X" in line:
# Add the Object List
lines[index - 1] += f"\n;Model List: {str(model_list)}"
return "\n".join(lines)

View file

@ -92,7 +92,7 @@ class FilamentChange(Script):
"type": "float",
"default_value": 0,
"minimum_value": 0,
"enabled": "enabled"
"enabled": "enabled and not firmware_config"
},
"retract_method":
{

View file

@ -1,6 +1,7 @@
# Copyright (c) 2020 Ultimaker B.V.
# Created by Wayne Porter
# Re-write in April of 2024 by GregValiant (Greg Foresi)
# Made convert inserted text to upper-case optional March 2025 by HellAholic
# Changes:
# Added an 'Enable' setting
# Added support for multi-line insertions (comma delimited)
@ -82,6 +83,14 @@ class InsertAtLayerChange(Script):
"type": "str",
"default_value": "",
"enabled": "enabled"
},
"convert_to_upper":
{
"label": "Convert to upper-case",
"description": "Convert all inserted text to upper-case as some firmwares don't understand lower-case.",
"type": "bool",
"default_value": true,
"enabled": "enabled"
}
}
}"""
@ -91,7 +100,7 @@ class InsertAtLayerChange(Script):
if not bool(self.getSettingValueByKey("enabled")):
return data
#Initialize variables
mycode = self.getSettingValueByKey("gcode_to_add").upper()
mycode = self.getSettingValueByKey("gcode_to_add").upper() if self.getSettingValueByKey("convert_to_upper") else self.getSettingValueByKey("gcode_to_add")
start_layer = int(self.getSettingValueByKey("start_layer"))
end_layer = int(self.getSettingValueByKey("end_layer"))
when_to_insert = self.getSettingValueByKey("insert_frequency")

View file

@ -35,8 +35,9 @@ class Position(tuple, Enum):
class PurgeLinesAndUnload(Script):
def __init__(self):
super().__init__()
def initialize(self) -> None:
super().initialize()
# Get required values from the global stack and set default values for the script
self.global_stack = Application.getInstance().getGlobalContainerStack()
self.extruder = self.global_stack.extruderList
self.end_purge_location = None
@ -56,9 +57,6 @@ class PurgeLinesAndUnload(Script):
self.machine_back = self.machine_depth - 1.0
self.start_x = None
self.start_y = None
def initialize(self) -> None:
super().initialize()
# Get the StartUp Gcode from Cura and attempt to catch if it contains purge lines. Message the user if an extrusion is in the startup.
startup_gcode = self.global_stack.getProperty("machine_start_gcode", "value")
start_lines = startup_gcode.splitlines()
@ -496,7 +494,7 @@ class PurgeLinesAndUnload(Script):
"""Generates G-code lines for prime blob adjustment."""
gcode_lines = [
f"G1 F{retract_speed} E{retract_distance} ; Unretract",
"G92 E0 ; Reset extruder"
"G92 E0 ; Reset extruder\n"
]
return "\n".join(gcode_lines)

View file

@ -24,25 +24,6 @@ class PreviewStage(CuraStage):
super().__init__(parent)
self._application = application
self._application.engineCreatedSignal.connect(self._engineCreated)
self._previously_active_view = None # type: Optional[View]
def onStageSelected(self) -> None:
"""When selecting the stage, remember which was the previous view so that
we can revert to that view when we go out of the stage later.
"""
self._previously_active_view = self._application.getController().getActiveView()
def onStageDeselected(self) -> None:
"""Called when going to a different stage (away from the Preview Stage).
When going to a different stage, the view should be reverted to what it
was before. Normally, that just reverts it to solid view.
"""
if self._previously_active_view is not None:
self._application.getController().setActiveView(self._previously_active_view.getPluginId())
self._previously_active_view = None
def _engineCreated(self) -> None:
"""Delayed load of the QML files.

View file

@ -101,7 +101,8 @@ class RemovableDriveOutputDevice(OutputDevice):
self._stream = open(file_name, "wt", buffering = 1, encoding = "utf-8")
else: #Binary mode.
self._stream = open(file_name, "wb", buffering = 1)
job = WriteFileJob(writer, self._stream, nodes, preferred_format["mode"])
writer_args = {"mime_type": preferred_format["mime_type"]}
job = WriteFileJob(writer, self._stream, nodes, preferred_format["mode"], writer_args)
job.setFileName(file_name)
job.progress.connect(self._onProgress)
job.finished.connect(self._onFinished)

View file

@ -203,9 +203,9 @@ class SimulationPass(RenderPass):
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 MoveUnretractedType in front for start detection
# 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.MoveUnretractedType], dtype = numpy.float32), layer_data._attributes["line_types"]["value"]])
# Remove the last element
prev_line_types = prev_line_types[0:layer_data._attributes["line_types"]["value"].size]
layer_data._attributes["prev_line_types"] = {'opengl_type': 'float', 'value': prev_line_types, 'opengl_name': 'a_prev_line_type'}

View file

@ -172,13 +172,20 @@ class SimulationView(CuraView):
self._updateSliceWarningVisibility()
self.activityChanged.emit()
def getSimulationPass(self) -> SimulationPass:
def getSimulationPass(self) -> Optional[SimulationPass]:
if not self._layer_pass:
renderer = self.getRenderer()
if renderer is None:
return None
# Currently the RenderPass constructor requires a size > 0
# This should be fixed in RenderPass's constructor.
self._layer_pass = SimulationPass(1, 1)
self._compatibility_mode = self._evaluateCompatibilityMode()
self._layer_pass.setSimulationView(self)
self._layer_pass.setEnabled(False)
renderer.addRenderPass(self._layer_pass)
return self._layer_pass
def getCurrentLayer(self) -> int:
@ -608,8 +615,10 @@ class SimulationView(CuraView):
visible_line_types.append(LayerPolygon.SupportInterfaceType)
visible_line_types_with_extrusion = visible_line_types.copy() # Copy before travel moves are added
if self.getShowTravelMoves():
visible_line_types.append(LayerPolygon.MoveCombingType)
visible_line_types.append(LayerPolygon.MoveRetractionType)
visible_line_types.append(LayerPolygon.MoveUnretractedType)
visible_line_types.append(LayerPolygon.MoveRetractedType)
visible_line_types.append(LayerPolygon.MoveWhileRetractingType)
visible_line_types.append(LayerPolygon.MoveWhileUnretractingType)
for node in DepthFirstIterator(self.getController().getScene().getRoot()):
layer_data = node.callDecoration("getLayerData")
@ -732,11 +741,14 @@ class SimulationView(CuraView):
# Make sure the SimulationPass is created
layer_pass = self.getSimulationPass()
if layer_pass is None:
return False
renderer = self.getRenderer()
if renderer is None:
return False
renderer.addRenderPass(layer_pass)
layer_pass.setEnabled(True)
# Make sure the NozzleNode is add to the root
nozzle = self.getNozzleNode()
@ -776,7 +788,7 @@ class SimulationView(CuraView):
return False
if self._layer_pass is not None:
renderer.removeRenderPass(self._layer_pass)
self._layer_pass.setEnabled(False)
if self._composite_pass:
self._composite_pass.setLayerBindings(cast(List[str], self._old_layer_bindings))
self._composite_pass.setCompositeShader(cast(ShaderProgram, self._old_composite_shader))

View file

@ -227,29 +227,52 @@ Cura.ExpandableComponent
id: typesLegendModel
Component.onCompleted:
{
const travelsTypesModel = [
{
label: catalog.i18nc("@label", "Not retracted"),
colorId: "layerview_move_combing"
},
{
label: catalog.i18nc("@label", "Retracted"),
colorId: "layerview_move_retraction"
},
{
label: catalog.i18nc("@label", "Retracting"),
colorId: "layerview_move_while_retracting"
},
{
label: catalog.i18nc("@label", "Priming"),
colorId: "layerview_move_while_unretracting"
}
];
typesLegendModel.append({
label: catalog.i18nc("@label", "Travels"),
initialValue: viewSettings.show_travel_moves,
preference: "layerview/show_travel_moves",
colorId: "layerview_move_combing"
colorId: "layerview_move_combing",
subTypesModel: travelsTypesModel
});
typesLegendModel.append({
label: catalog.i18nc("@label", "Helpers"),
initialValue: viewSettings.show_helpers,
preference: "layerview/show_helpers",
colorId: "layerview_support"
colorId: "layerview_support",
subTypesModel: []
});
typesLegendModel.append({
label: catalog.i18nc("@label", "Shell"),
initialValue: viewSettings.show_skin,
preference: "layerview/show_skin",
colorId: "layerview_inset_0"
colorId: "layerview_inset_0",
subTypesModel: []
});
typesLegendModel.append({
label: catalog.i18nc("@label", "Infill"),
initialValue: viewSettings.show_infill,
preference: "layerview/show_infill",
colorId: "layerview_infill"
colorId: "layerview_infill",
subTypesModel: []
});
if (! UM.SimulationView.compatibilityMode)
{
@ -257,7 +280,8 @@ Cura.ExpandableComponent
label: catalog.i18nc("@label", "Starts"),
initialValue: viewSettings.show_starts,
preference: "layerview/show_starts",
colorId: "layerview_starts"
colorId: "layerview_starts",
subTypesModel: []
});
}
}
@ -273,6 +297,7 @@ Cura.ExpandableComponent
Rectangle
{
id: rectangleColor
anchors.verticalCenter: parent.verticalCenter
anchors.right: legendModelCheckBox.right
width: UM.Theme.getSize("layerview_legend_size").width
@ -281,6 +306,58 @@ Cura.ExpandableComponent
border.width: UM.Theme.getSize("default_lining").width
border.color: UM.Theme.getColor("lining")
visible: viewSettings.show_legend
MouseArea
{
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
enabled: subTypesModel.count > 0
onEntered: tooltip.show()
onExited: tooltip.hide()
UM.ToolTip
{
id: tooltip
delay: 0
width: subTypesColumn.implicitWidth + 2 * UM.Theme.getSize("thin_margin").width
height: subTypesColumn.implicitHeight + 2 * UM.Theme.getSize("thin_margin").width
contentItem: Column
{
id: subTypesColumn
padding: 0
spacing: UM.Theme.getSize("layerview_row_spacing").height
Repeater
{
model: subTypesModel
UM.Label
{
text: label
height: UM.Theme.getSize("layerview_row").height + UM.Theme.getSize("default_lining").height
width: UM.Theme.getSize("layerview_menu_size").width
color: UM.Theme.getColor("tooltip_text")
Rectangle
{
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
width: UM.Theme.getSize("layerview_legend_size").width
height: UM.Theme.getSize("layerview_legend_size").height
color: UM.Theme.getColor(model.colorId)
border.width: UM.Theme.getSize("default_lining").width
border.color: UM.Theme.getColor("lining")
}
}
}
}
}
}
}
UM.Label

View file

@ -22,8 +22,8 @@ vertex =
gl_Position = u_projectionMatrix * u_viewMatrix * u_modelMatrix * a_vertex;
// shade the color depending on the extruder index
v_color = a_color;
// 8 and 9 are travel moves
if ((a_line_type != 8.0) && (a_line_type != 9.0)) {
// 8, 9, 12 and 13 are travel moves
if ((a_line_type != 8.0) && (a_line_type != 9.0) && (a_line_type != 12.0) && (a_line_type != 13.0)) {
v_color = (a_extruder == u_active_extruder) ? v_color : vec4(u_shade_factor * v_color.rgb, v_color.a);
}
@ -48,7 +48,9 @@ fragment =
void main()
{
if ((u_show_travel_moves == 0) && (v_line_type >= 7.5) && (v_line_type <= 9.5)) { // actually, 8 and 9
// travel moves: 8, 9, 12, 13
if ((u_show_travel_moves == 0) && (((v_line_type >= 7.5) && (v_line_type <= 9.5)) ||
((v_line_type >= 11.5) && (v_line_type <= 13.5)))) {
// discard movements
discard;
}
@ -100,7 +102,7 @@ vertex41core =
{
gl_Position = u_projectionMatrix * u_viewMatrix * u_modelMatrix * a_vertex;
v_color = a_color;
if ((a_line_type != 8) && (a_line_type != 9)) {
if ((a_line_type != 8) && (a_line_type != 9) && (a_line_type != 12) && (a_line_type != 13)) {
v_color = (a_extruder == u_active_extruder) ? v_color : vec4(u_shade_factor * v_color.rgb, v_color.a);
}
@ -120,7 +122,9 @@ fragment41core =
void main()
{
if ((u_show_travel_moves == 0) && (v_line_type >= 7.5) && (v_line_type <= 9.5)) { // actually, 8 and 9
// travel moves: 8, 9, 12, 13
if ((u_show_travel_moves == 0) && (((v_line_type >= 7.5) && (v_line_type <= 9.5)) ||
((v_line_type >= 11.5) && (v_line_type <= 13.5)))) {
// discard movements
discard;
}

View file

@ -228,22 +228,26 @@ geometry41core =
{
highp mat4 viewProjectionMatrix = u_projectionMatrix * u_viewMatrix;
vec4 g_vertex_delta;
vec3 g_vertex_normal_horz; // horizontal and vertical in respect to layers
vec4 g_vertex_offset_horz; // vec4 to match gl_in[x].gl_Position
// Vertices are declared as vec4 so that they can be used for calculations with gl_in[x].gl_Position
vec3 g_vertex_delta;
vec3 g_vertex_normal_horz;
vec4 g_vertex_offset_horz;
vec3 g_vertex_normal_vert;
vec4 g_vertex_offset_vert;
vec3 g_vertex_normal_horz_head;
vec4 g_vertex_offset_horz_head;
vec3 g_axial_plan_vector;
vec3 g_radial_plan_vector;
float size_x;
float size_y;
if ((v_extruder_opacity[0][int(mod(v_extruder[0], 4))][v_extruder[0] / 4] == 0.0) && (v_line_type[0] != 8) && (v_line_type[0] != 9)) {
if ((v_extruder_opacity[0][int(mod(v_extruder[0], 4))][v_extruder[0] / 4] == 0.0) &&
(v_line_type[0] != 8) && (v_line_type[0] != 9) && (v_line_type[0] != 12) && (v_line_type[0] != 13)) {
return;
}
// See LayerPolygon; 8 is MoveCombingType, 9 is RetractionType
if ((u_show_travel_moves == 0) && ((v_line_type[0] == 8) || (v_line_type[0] == 9))) {
// See LayerPolygon; 8 is MoveUnretractedType, 9 is RetractionType, 12 is MoveWhileRetractingType, 13 is MoveWhileUnretractingType
if ((u_show_travel_moves == 0) && ((v_line_type[0] == 8) || (v_line_type[0] == 9) || (v_line_type[0] == 12) || (v_line_type[0] == 13))) {
return;
}
if ((u_show_helpers == 0) && ((v_line_type[0] == 4) || (v_line_type[0] == 5) || (v_line_type[0] == 7) || (v_line_type[0] == 10) || v_line_type[0] == 11)) {
@ -256,7 +260,7 @@ geometry41core =
return;
}
if ((v_line_type[0] == 8) || (v_line_type[0] == 9)) {
if ((v_line_type[0] == 8) || (v_line_type[0] == 9) || (v_line_type[0] == 12) || (v_line_type[0] == 13)) {
// fixed size for movements
size_x = 0.05;
} else {
@ -264,26 +268,47 @@ geometry41core =
}
size_y = v_line_dim[1].y / 2 + 0.01;
g_vertex_delta = gl_in[1].gl_Position - gl_in[0].gl_Position; //Actual movement exhibited by the line.
g_vertex_normal_horz_head = normalize(vec3(-g_vertex_delta.x, -g_vertex_delta.y, -g_vertex_delta.z)); //Lengthwise normal vector pointing backwards.
g_vertex_offset_horz_head = vec4(g_vertex_normal_horz_head * size_x, 0.0); //Lengthwise offset vector pointing backwards.
g_vertex_delta = (gl_in[1].gl_Position - gl_in[0].gl_Position).xyz; //Actual movement exhibited by the line.
g_vertex_normal_horz = normalize(vec3(g_vertex_delta.z, g_vertex_delta.y, -g_vertex_delta.x)); //Normal vector pointing right.
if (g_vertex_delta == vec3(0.0)) {
return;
}
if (g_vertex_delta.y == 0.0)
{
// vector is in the horizontal plan, radial vector is a simple rotation around Y axis
g_radial_plan_vector = vec3(g_vertex_delta.z, 0.0, -g_vertex_delta.x);
}
else if(g_vertex_delta.x == 0.0 && g_vertex_delta.z == 0.0)
{
// delta vector is purely vertical, display the line rotated vertically so that it is visible in front and side views
g_radial_plan_vector = vec3(1.0, 0.0, -1.0);
}
else
{
// delta vector is completely 3D
g_axial_plan_vector = vec3(g_vertex_delta.x, 0.0, g_vertex_delta.z); // Vector projected in the horizontal plan
g_radial_plan_vector = cross(g_vertex_delta, g_axial_plan_vector); // Radial vector in the horizontal plan, pointing right.
}
g_vertex_normal_horz_head = normalize(g_vertex_delta); //Lengthwise normal vector
g_vertex_offset_horz_head = vec4(g_vertex_normal_horz_head * size_x, 0.0); //Lengthwise offset vector
g_vertex_normal_horz = normalize(g_radial_plan_vector); //Normal vector pointing right.
g_vertex_offset_horz = vec4(g_vertex_normal_horz * size_x, 0.0); //Offset vector pointing right.
g_vertex_normal_vert = vec3(0.0, 1.0, 0.0); //Upwards normal vector.
g_vertex_offset_vert = vec4(g_vertex_normal_vert * size_y, 0.0); //Upwards offset vector. Goes up by half the layer thickness.
if ((v_line_type[0] == 8) || (v_line_type[0] == 9)) { //Travel or retraction moves.
vec4 va_head = viewProjectionMatrix * (gl_in[0].gl_Position + g_vertex_offset_horz_head + g_vertex_offset_vert);
if ((v_line_type[0] == 8) || (v_line_type[0] == 9) || (v_line_type[0] == 12) || (v_line_type[0] == 13)) { //Travel or retraction moves.
vec4 va_head = viewProjectionMatrix * (gl_in[0].gl_Position - g_vertex_offset_horz_head + g_vertex_offset_vert);
vec4 va_up = viewProjectionMatrix * (gl_in[0].gl_Position + g_vertex_offset_horz + g_vertex_offset_vert);
vec4 va_down = viewProjectionMatrix * (gl_in[0].gl_Position - g_vertex_offset_horz + g_vertex_offset_vert);
vec4 vb_head = viewProjectionMatrix * (gl_in[1].gl_Position - g_vertex_offset_horz_head + g_vertex_offset_vert);
vec4 vb_head = viewProjectionMatrix * (gl_in[1].gl_Position + g_vertex_offset_horz_head + g_vertex_offset_vert);
vec4 vb_down = viewProjectionMatrix * (gl_in[1].gl_Position - g_vertex_offset_horz + g_vertex_offset_vert);
vec4 vb_up = viewProjectionMatrix * (gl_in[1].gl_Position + g_vertex_offset_horz + g_vertex_offset_vert);
// Travels: flat plane with pointy ends
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_vert, va_up);
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_vert, va_head);
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_vert, va_down);
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_vert, va_up);
@ -308,8 +333,8 @@ geometry41core =
vec4 vb_p_horz = viewProjectionMatrix * (gl_in[1].gl_Position + g_vertex_offset_horz); //Line end, right vertex.
vec4 va_m_vert = viewProjectionMatrix * (gl_in[0].gl_Position - g_vertex_offset_vert); //Line start, bottom vertex.
vec4 vb_m_vert = viewProjectionMatrix * (gl_in[1].gl_Position - g_vertex_offset_vert); //Line end, bottom vertex.
vec4 va_head = viewProjectionMatrix * (gl_in[0].gl_Position + g_vertex_offset_horz_head); //Line start, tip.
vec4 vb_head = viewProjectionMatrix * (gl_in[1].gl_Position - g_vertex_offset_horz_head); //Line end, tip.
vec4 va_head = viewProjectionMatrix * (gl_in[0].gl_Position - g_vertex_offset_horz_head); //Line start, tip.
vec4 vb_head = viewProjectionMatrix * (gl_in[1].gl_Position + g_vertex_offset_horz_head); //Line end, tip.
// All normal lines are rendered as 3d tubes.
myEmitVertex(v_vertex[0], v_color[1], -g_vertex_normal_horz, va_m_horz);
@ -328,14 +353,14 @@ geometry41core =
// left side
myEmitVertex(v_vertex[0], v_color[1], -g_vertex_normal_horz, va_m_horz);
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_vert, va_p_vert);
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_horz_head, va_head);
myEmitVertex(v_vertex[0], v_color[1], -g_vertex_normal_horz_head, va_head);
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_horz, va_p_horz);
EndPrimitive();
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_horz, va_p_horz);
myEmitVertex(v_vertex[0], v_color[1], -g_vertex_normal_vert, va_m_vert);
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_horz_head, va_head);
myEmitVertex(v_vertex[0], v_color[1], -g_vertex_normal_horz_head, va_head);
myEmitVertex(v_vertex[0], v_color[1], -g_vertex_normal_horz, va_m_horz);
EndPrimitive();
@ -343,14 +368,14 @@ geometry41core =
// right side
myEmitVertex(v_vertex[1], v_color[1], g_vertex_normal_horz, vb_p_horz);
myEmitVertex(v_vertex[1], v_color[1], g_vertex_normal_vert, vb_p_vert);
myEmitVertex(v_vertex[1], v_color[1], -g_vertex_normal_horz_head, vb_head);
myEmitVertex(v_vertex[1], v_color[1], g_vertex_normal_horz_head, vb_head);
myEmitVertex(v_vertex[1], v_color[1], -g_vertex_normal_horz, vb_m_horz);
EndPrimitive();
myEmitVertex(v_vertex[1], v_color[1], -g_vertex_normal_horz, vb_m_horz);
myEmitVertex(v_vertex[1], v_color[1], -g_vertex_normal_vert, vb_m_vert);
myEmitVertex(v_vertex[1], v_color[1], -g_vertex_normal_horz_head, vb_head);
myEmitVertex(v_vertex[1], v_color[1], g_vertex_normal_horz_head, vb_head);
myEmitVertex(v_vertex[1], v_color[1], g_vertex_normal_horz, vb_p_horz);
EndPrimitive();

View file

@ -95,22 +95,26 @@ geometry41core =
{
highp mat4 viewProjectionMatrix = u_projectionMatrix * u_viewMatrix;
vec4 g_vertex_delta;
vec3 g_vertex_normal_horz; // horizontal and vertical in respect to layers
vec4 g_vertex_offset_horz; // vec4 to match gl_in[x].gl_Position
// Vertices are declared as vec4 so that they can be used for calculations with gl_in[x].gl_Position
vec3 g_vertex_delta;
vec3 g_vertex_normal_horz;
vec4 g_vertex_offset_horz;
vec3 g_vertex_normal_vert;
vec4 g_vertex_offset_vert;
vec3 g_vertex_normal_horz_head;
vec4 g_vertex_offset_horz_head;
vec3 g_axial_plane_vector;
vec3 g_radial_plane_vector;
float size_x;
float size_y;
if ((v_extruder_opacity[0][int(mod(v_extruder[0], 4))][v_extruder[0] / 4] == 0.0) && (v_line_type[0] != 8) && (v_line_type[0] != 9)) {
if ((v_extruder_opacity[0][int(mod(v_extruder[0], 4))][v_extruder[0] / 4] == 0.0) &&
(v_line_type[0] != 8) && (v_line_type[0] != 9) && (v_line_type[0] != 12) && (v_line_type[0] != 13)) {
return;
}
// See LayerPolygon; 8 is MoveCombingType, 9 is RetractionType
if ((u_show_travel_moves == 0) && ((v_line_type[0] == 8) || (v_line_type[0] == 9))) {
// See LayerPolygon; 8 is MoveUnretractedType, 9 is RetractionType, 12 is MoveWhileRetractingType, 13 is MoveWhileUnretractingType
if ((u_show_travel_moves == 0) && ((v_line_type[0] == 8) || (v_line_type[0] == 9) || (v_line_type[0] == 12) || (v_line_type[0] == 13))) {
return;
}
if ((u_show_helpers == 0) && ((v_line_type[0] == 4) || (v_line_type[0] == 5) || (v_line_type[0] == 7) || (v_line_type[0] == 10))) {
@ -123,7 +127,7 @@ geometry41core =
return;
}
if ((v_line_type[0] == 8) || (v_line_type[0] == 9)) {
if ((v_line_type[0] == 8) || (v_line_type[0] == 9) || (v_line_type[0] == 12) || (v_line_type[0] == 13)) {
// fixed size for movements
size_x = 0.05;
} else {
@ -131,93 +135,114 @@ geometry41core =
}
size_y = v_line_dim[1].y / 2 + 0.01;
g_vertex_delta = gl_in[1].gl_Position - gl_in[0].gl_Position;
g_vertex_normal_horz_head = normalize(vec3(-g_vertex_delta.x, -g_vertex_delta.y, -g_vertex_delta.z));
g_vertex_offset_horz_head = vec4(g_vertex_normal_horz_head * size_x, 0.0);
g_vertex_delta = (gl_in[1].gl_Position - gl_in[0].gl_Position).xyz; //Actual movement exhibited by the line.
g_vertex_normal_horz = normalize(vec3(g_vertex_delta.z, g_vertex_delta.y, -g_vertex_delta.x));
if (g_vertex_delta == vec3(0.0)) {
return;
}
g_vertex_offset_horz = vec4(g_vertex_normal_horz * size_x, 0.0); //size * g_vertex_normal_horz;
g_vertex_normal_vert = vec3(0.0, 1.0, 0.0);
g_vertex_offset_vert = vec4(g_vertex_normal_vert * size_y, 0.0);
if (g_vertex_delta.y == 0.0)
{
// vector is in the horizontal plane, radial vector is a simple rotation around Y axis
g_radial_plane_vector = vec3(g_vertex_delta.z, 0.0, -g_vertex_delta.x);
}
else if(g_vertex_delta.x == 0.0 && g_vertex_delta.z == 0.0)
{
// delta vector is purely vertical, display the line rotated vertically so that it is visible in front and side views
g_radial_plane_vector = vec3(1.0, 0.0, -1.0);
}
else
{
// delta vector is completely 3D
g_axial_plane_vector = vec3(g_vertex_delta.x, 0.0, g_vertex_delta.z); // Vector projected in the horizontal plane
g_radial_plane_vector = cross(g_vertex_delta, g_axial_plane_vector); // Radial vector in the horizontal plane, pointing right.
}
if ((v_line_type[0] == 8) || (v_line_type[0] == 9)) {
vec4 va_head = viewProjectionMatrix * (gl_in[0].gl_Position + g_vertex_offset_horz_head + g_vertex_offset_vert);
g_vertex_normal_horz_head = normalize(g_vertex_delta); //Lengthwise normal vector
g_vertex_offset_horz_head = vec4(g_vertex_normal_horz_head * size_x, 0.0); //Lengthwise offset vector
g_vertex_normal_horz = normalize(g_radial_plane_vector); //Normal vector pointing right.
g_vertex_offset_horz = vec4(g_vertex_normal_horz * size_x, 0.0); //Offset vector pointing right.
g_vertex_normal_vert = vec3(0.0, 1.0, 0.0); //Upwards normal vector.
g_vertex_offset_vert = vec4(g_vertex_normal_vert * size_y, 0.0); //Upwards offset vector. Goes up by half the layer thickness.
if ((v_line_type[0] == 8) || (v_line_type[0] == 9) || (v_line_type[0] == 12) || (v_line_type[0] == 13)) {
vec4 va_head = viewProjectionMatrix * (gl_in[0].gl_Position - g_vertex_offset_horz_head + g_vertex_offset_vert);
vec4 va_up = viewProjectionMatrix * (gl_in[0].gl_Position + g_vertex_offset_horz + g_vertex_offset_vert);
vec4 va_down = viewProjectionMatrix * (gl_in[0].gl_Position - g_vertex_offset_horz + g_vertex_offset_vert);
vec4 vb_head = viewProjectionMatrix * (gl_in[1].gl_Position - g_vertex_offset_horz_head + g_vertex_offset_vert);
vec4 vb_head = viewProjectionMatrix * (gl_in[1].gl_Position + g_vertex_offset_horz_head + g_vertex_offset_vert);
vec4 vb_down = viewProjectionMatrix * (gl_in[1].gl_Position - g_vertex_offset_horz + g_vertex_offset_vert);
vec4 vb_up = viewProjectionMatrix * (gl_in[1].gl_Position + g_vertex_offset_horz + g_vertex_offset_vert);
// Travels: flat plane with pointy ends
myEmitVertex(v_vertex[0], v_color[0], g_vertex_normal_vert, va_up);
myEmitVertex(v_vertex[0], v_color[0], g_vertex_normal_vert, va_head);
myEmitVertex(v_vertex[0], v_color[0], g_vertex_normal_vert, va_down);
myEmitVertex(v_vertex[0], v_color[0], g_vertex_normal_vert, va_up);
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_vert, va_head);
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_vert, va_down);
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_vert, va_up);
myEmitVertex(v_vertex[1], v_color[1], g_vertex_normal_vert, vb_down);
myEmitVertex(v_vertex[1], v_color[1], g_vertex_normal_vert, vb_up);
myEmitVertex(v_vertex[1], v_color[1], g_vertex_normal_vert, vb_head);
//And reverse so that the line is also visible from the back side.
myEmitVertex(v_vertex[1], v_color[1], g_vertex_normal_vert, vb_up);
myEmitVertex(v_vertex[1], v_color[1], g_vertex_normal_vert, vb_down);
myEmitVertex(v_vertex[0], v_color[0], g_vertex_normal_vert, va_up);
myEmitVertex(v_vertex[0], v_color[0], g_vertex_normal_vert, va_down);
myEmitVertex(v_vertex[0], v_color[0], g_vertex_normal_vert, va_head);
myEmitVertex(v_vertex[0], v_color[0], g_vertex_normal_vert, va_up);
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_vert, va_up);
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_vert, va_down);
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_vert, va_head);
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_vert, va_up);
EndPrimitive();
} else {
vec4 va_m_horz = viewProjectionMatrix * (gl_in[0].gl_Position - g_vertex_offset_horz);
vec4 vb_m_horz = viewProjectionMatrix * (gl_in[1].gl_Position - g_vertex_offset_horz);
vec4 va_p_vert = viewProjectionMatrix * (gl_in[0].gl_Position + g_vertex_offset_vert);
vec4 vb_p_vert = viewProjectionMatrix * (gl_in[1].gl_Position + g_vertex_offset_vert);
vec4 va_p_horz = viewProjectionMatrix * (gl_in[0].gl_Position + g_vertex_offset_horz);
vec4 vb_p_horz = viewProjectionMatrix * (gl_in[1].gl_Position + g_vertex_offset_horz);
vec4 va_m_vert = viewProjectionMatrix * (gl_in[0].gl_Position - g_vertex_offset_vert);
vec4 vb_m_vert = viewProjectionMatrix * (gl_in[1].gl_Position - g_vertex_offset_vert);
vec4 va_head = viewProjectionMatrix * (gl_in[0].gl_Position + g_vertex_offset_horz_head);
vec4 vb_head = viewProjectionMatrix * (gl_in[1].gl_Position - g_vertex_offset_horz_head);
vec4 va_m_horz = viewProjectionMatrix * (gl_in[0].gl_Position - g_vertex_offset_horz); //Line start, left vertex.
vec4 vb_m_horz = viewProjectionMatrix * (gl_in[1].gl_Position - g_vertex_offset_horz); //Line end, left vertex.
vec4 va_p_vert = viewProjectionMatrix * (gl_in[0].gl_Position + g_vertex_offset_vert); //Line start, top vertex.
vec4 vb_p_vert = viewProjectionMatrix * (gl_in[1].gl_Position + g_vertex_offset_vert); //Line end, top vertex.
vec4 va_p_horz = viewProjectionMatrix * (gl_in[0].gl_Position + g_vertex_offset_horz); //Line start, right vertex.
vec4 vb_p_horz = viewProjectionMatrix * (gl_in[1].gl_Position + g_vertex_offset_horz); //Line end, right vertex.
vec4 va_m_vert = viewProjectionMatrix * (gl_in[0].gl_Position - g_vertex_offset_vert); //Line start, bottom vertex.
vec4 vb_m_vert = viewProjectionMatrix * (gl_in[1].gl_Position - g_vertex_offset_vert); //Line end, bottom vertex.
vec4 va_head = viewProjectionMatrix * (gl_in[0].gl_Position - g_vertex_offset_horz_head); //Line start, tip.
vec4 vb_head = viewProjectionMatrix * (gl_in[1].gl_Position + g_vertex_offset_horz_head); //Line end, tip.
// All normal lines are rendered as 3d tubes.
myEmitVertex(v_vertex[0], v_color[0], -g_vertex_normal_horz, va_m_horz);
myEmitVertex(v_vertex[0], v_color[1], -g_vertex_normal_horz, va_m_horz);
myEmitVertex(v_vertex[1], v_color[1], -g_vertex_normal_horz, vb_m_horz);
myEmitVertex(v_vertex[0], v_color[0], g_vertex_normal_vert, va_p_vert);
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_vert, va_p_vert);
myEmitVertex(v_vertex[1], v_color[1], g_vertex_normal_vert, vb_p_vert);
myEmitVertex(v_vertex[0], v_color[0], g_vertex_normal_horz, va_p_horz);
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_horz, va_p_horz);
myEmitVertex(v_vertex[1], v_color[1], g_vertex_normal_horz, vb_p_horz);
myEmitVertex(v_vertex[0], v_color[0], -g_vertex_normal_vert, va_m_vert);
myEmitVertex(v_vertex[0], v_color[1], -g_vertex_normal_vert, va_m_vert);
myEmitVertex(v_vertex[1], v_color[1], -g_vertex_normal_vert, vb_m_vert);
myEmitVertex(v_vertex[0], v_color[0], -g_vertex_normal_horz, va_m_horz);
myEmitVertex(v_vertex[0], v_color[1], -g_vertex_normal_horz, va_m_horz);
myEmitVertex(v_vertex[1], v_color[1], -g_vertex_normal_horz, vb_m_horz);
EndPrimitive();
// left side
myEmitVertex(v_vertex[0], v_color[0], -g_vertex_normal_horz, va_m_horz);
myEmitVertex(v_vertex[0], v_color[0], g_vertex_normal_vert, va_p_vert);
myEmitVertex(v_vertex[0], v_color[0], g_vertex_normal_horz_head, va_head);
myEmitVertex(v_vertex[0], v_color[0], g_vertex_normal_horz, va_p_horz);
myEmitVertex(v_vertex[0], v_color[1], -g_vertex_normal_horz, va_m_horz);
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_vert, va_p_vert);
myEmitVertex(v_vertex[0], v_color[1], -g_vertex_normal_horz_head, va_head);
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_horz, va_p_horz);
EndPrimitive();
myEmitVertex(v_vertex[0], v_color[0], g_vertex_normal_horz, va_p_horz);
myEmitVertex(v_vertex[0], v_color[0], -g_vertex_normal_vert, va_m_vert);
myEmitVertex(v_vertex[0], v_color[0], g_vertex_normal_horz_head, va_head);
myEmitVertex(v_vertex[0], v_color[0], -g_vertex_normal_horz, va_m_horz);
myEmitVertex(v_vertex[0], v_color[1], g_vertex_normal_horz, va_p_horz);
myEmitVertex(v_vertex[0], v_color[1], -g_vertex_normal_vert, va_m_vert);
myEmitVertex(v_vertex[0], v_color[1], -g_vertex_normal_horz_head, va_head);
myEmitVertex(v_vertex[0], v_color[1], -g_vertex_normal_horz, va_m_horz);
EndPrimitive();
// right side
myEmitVertex(v_vertex[1], v_color[1], g_vertex_normal_horz, vb_p_horz);
myEmitVertex(v_vertex[1], v_color[1], g_vertex_normal_vert, vb_p_vert);
myEmitVertex(v_vertex[1], v_color[1], -g_vertex_normal_horz_head, vb_head);
myEmitVertex(v_vertex[1], v_color[1], g_vertex_normal_horz_head, vb_head);
myEmitVertex(v_vertex[1], v_color[1], -g_vertex_normal_horz, vb_m_horz);
EndPrimitive();
myEmitVertex(v_vertex[1], v_color[1], -g_vertex_normal_horz, vb_m_horz);
myEmitVertex(v_vertex[1], v_color[1], -g_vertex_normal_vert, vb_m_vert);
myEmitVertex(v_vertex[1], v_color[1], -g_vertex_normal_horz_head, vb_head);
myEmitVertex(v_vertex[1], v_color[1], g_vertex_normal_horz_head, vb_head);
myEmitVertex(v_vertex[1], v_color[1], g_vertex_normal_horz, vb_p_horz);
EndPrimitive();

View file

@ -48,8 +48,10 @@ fragment =
void main()
{
if ((u_show_travel_moves == 0) && (v_line_type >= 7.5) && (v_line_type <= 9.5))
{ // actually, 8 and 9
// travel moves: 8, 9, 12, 13
if ((u_show_travel_moves == 0) && (((v_line_type >= 7.5) && (v_line_type <= 9.5)) ||
((v_line_type >= 11.5) && (v_line_type <= 13.5)))) {
{
// discard movements
discard;
}
@ -124,7 +126,9 @@ fragment41core =
void main()
{
if ((u_show_travel_moves == 0) && (v_line_type >= 7.5) && (v_line_type <= 9.5)) { // actually, 8 and 9
// travel moves: 8, 9, 12, 13
if ((u_show_travel_moves == 0) && (((v_line_type >= 7.5) && (v_line_type <= 9.5)) ||
((v_line_type >= 11.5) && (v_line_type <= 13.5)))) {
// discard movements
discard;
}

View file

@ -1,13 +1,12 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import os.path
from UM.View.View import View
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Scene.Selection import Selection
from UM.Resources import Resources
from PyQt6.QtGui import QOpenGLContext, QDesktopServices, QImage
from PyQt6.QtCore import QSize, QUrl
from PyQt6.QtGui import QDesktopServices, QImage
from PyQt6.QtCore import QUrl
import numpy as np
import time
@ -36,11 +35,12 @@ class SolidView(View):
"""Standard view for mesh models."""
_show_xray_warning_preference = "view/show_xray_warning"
_show_overhang_preference = "view/show_overhang"
def __init__(self):
super().__init__()
application = Application.getInstance()
application.getPreferences().addPreference("view/show_overhang", True)
application.getPreferences().addPreference(self._show_overhang_preference, True)
application.globalContainerStackChanged.connect(self._onGlobalContainerChanged)
self._enabled_shader = None
self._disabled_shader = None
@ -212,7 +212,7 @@ class SolidView(View):
global_container_stack = Application.getInstance().getGlobalContainerStack()
if global_container_stack:
if Application.getInstance().getPreferences().getValue("view/show_overhang"):
if Application.getInstance().getPreferences().getValue(self._show_overhang_preference):
# Make sure the overhang angle is valid before passing it to the shader
if self._support_angle >= 0 and self._support_angle <= 90:
self._enabled_shader.setUniformValue("u_overhangAngle", math.cos(math.radians(90 - self._support_angle)))
@ -289,8 +289,9 @@ class SolidView(View):
def endRendering(self):
# check whether the xray overlay is showing badness
if time.time() > self._next_xray_checking_time\
and Application.getInstance().getPreferences().getValue(self._show_xray_warning_preference):
if (time.time() > self._next_xray_checking_time
and Application.getInstance().getPreferences().getValue(self._show_xray_warning_preference)
and self._xray_pass is not None):
self._next_xray_checking_time = time.time() + self._xray_checking_update_time
xray_img = self._xray_pass.getOutput()

View file

@ -1,6 +1,8 @@
# Copyright (c) 2018 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtWidgets import QApplication
@ -35,6 +37,7 @@ class SupportEraser(Tool):
self._controller = self.getController()
self._selection_pass = None
self._picking_pass: Optional[PickingPass] = None
CuraApplication.getInstance().globalContainerStackChanged.connect(self._updateEnabled)
# Note: if the selection is cleared with this tool active, there is no way to switch to
@ -84,12 +87,13 @@ class SupportEraser(Tool):
# Only "normal" meshes can have anti_overhang_meshes added to them
return
# Create a pass for picking a world-space location from the mouse location
active_camera = self._controller.getScene().getActiveCamera()
picking_pass = PickingPass(active_camera.getViewportWidth(), active_camera.getViewportHeight())
picking_pass.render()
# Get the pass for picking a world-space location from the mouse location
if self._picking_pass is None:
self._picking_pass = Application.getInstance().getRenderer().getRenderPass("picking_selected")
if not self._picking_pass:
return
picked_position = picking_pass.getPickedPosition(event.x, event.y)
picked_position = self._picking_pass.getPickedPosition(event.x, event.y)
# Add the anti_overhang_mesh cube at the picked location
self._createEraserMesh(picked_node, picked_position)
@ -189,3 +193,6 @@ class SupportEraser(Tool):
mesh.calculateNormals()
return mesh
def getRequiredExtraRenderingPasses(self) -> list[str]:
return ["picking_selected"]

View file

@ -51,7 +51,7 @@ class UFPWriter(MeshWriter):
# Qt thread. The File read/write operations right now are executed on separated threads because they are scheduled
# by the Job class.
@call_on_qt_thread
def write(self, stream, nodes, mode = MeshWriter.OutputMode.BinaryMode):
def write(self, stream, nodes, mode = MeshWriter.OutputMode.BinaryMode, **kwargs):
archive = VirtualFile()
archive.openStream(stream, "application/x-ufp", OpenMode.WriteOnly)

View file

@ -163,7 +163,7 @@ class CloudApiClient:
scope=self._scope,
data=b"",
callback=self._parseCallback(on_finished, CloudPrintResponse),
error_callback=on_error,
error_callback=self._parseError(on_error),
timeout=self.DEFAULT_REQUEST_TIMEOUT)
def doPrintJobAction(self, cluster_id: str, cluster_job_id: str, action: str,
@ -256,7 +256,6 @@ class CloudApiClient:
"""Creates a callback function so that it includes the parsing of the response into the correct model.
The callback is added to the 'finished' signal of the reply.
:param reply: The reply that should be listened to.
:param on_finished: The callback in case the response is successful. Depending on the endpoint it will be either
a list or a single item.
:param model: The type of the model to convert the response to.
@ -281,6 +280,25 @@ class CloudApiClient:
self._anti_gc_callbacks.append(parse)
return parse
def _parseError(self,
on_error: Callable[[CloudError, "QNetworkReply.NetworkError", int], None]) -> Callable[[QNetworkReply, "QNetworkReply.NetworkError"], None]:
"""Creates a callback function so that it includes the parsing of an explicit error response into the correct model.
:param on_error: The callback in case the response gives an explicit error
"""
def parse(reply: QNetworkReply, error: "QNetworkReply.NetworkError") -> None:
self._anti_gc_callbacks.remove(parse)
http_code, response = self._parseReply(reply)
result = CloudError(**response["errors"][0])
on_error(result, error, http_code)
self._anti_gc_callbacks.append(parse)
return parse
@classmethod
def getMachineIDMap(cls) -> Dict[str, str]:
if cls._machine_id_to_name is None:

View file

@ -27,9 +27,11 @@ from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOut
from ..Messages.PrintJobUploadBlockedMessage import PrintJobUploadBlockedMessage
from ..Messages.PrintJobUploadErrorMessage import PrintJobUploadErrorMessage
from ..Messages.PrintJobUploadQueueFullMessage import PrintJobUploadQueueFullMessage
from ..Messages.PrintJobUploadPrinterInactiveMessage import PrintJobUploadPrinterInactiveMessage
from ..Messages.PrintJobUploadSuccessMessage import PrintJobUploadSuccessMessage
from ..Models.Http.CloudClusterResponse import CloudClusterResponse
from ..Models.Http.CloudClusterStatus import CloudClusterStatus
from ..Models.Http.CloudError import CloudError
from ..Models.Http.CloudPrintJobUploadRequest import CloudPrintJobUploadRequest
from ..Models.Http.CloudPrintResponse import CloudPrintResponse
from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse
@ -87,7 +89,8 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
address="",
connection_type=ConnectionType.CloudConnection,
properties=properties,
parent=parent
parent=parent,
active=cluster.display_status != "inactive"
)
self._api = api_client
@ -190,6 +193,8 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
self._received_print_jobs = status.print_jobs
self._updatePrintJobs(status.print_jobs)
self._setActive(status.active)
def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False,
file_handler: Optional[FileHandler] = None, filter_by_machine: bool = False, **kwargs) -> None:
@ -291,19 +296,21 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
self.writeFinished.emit()
def _onPrintUploadSpecificError(self, reply: "QNetworkReply", _: "QNetworkReply.NetworkError"):
def _onPrintUploadSpecificError(self, error: CloudError, _: "QNetworkReply.NetworkError", http_error: int):
"""
Displays a message when an error occurs specific to uploading print job (i.e. queue is full).
"""
error_code = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
if error_code == 409:
PrintJobUploadQueueFullMessage().show()
if http_error == 409:
if error.code == "printerInactive":
PrintJobUploadPrinterInactiveMessage().show()
else:
PrintJobUploadQueueFullMessage().show()
else:
PrintJobUploadErrorMessage(I18N_CATALOG.i18nc("@error:send",
"Unknown error code when uploading print job: {0}",
error_code)).show()
http_error)).show()
Logger.log("w", "Upload of print job failed specifically with error code {}".format(error_code))
Logger.log("w", "Upload of print job failed specifically with error code {}".format(http_error))
self._progress.hide()
self._pre_upload_print_job = None

View file

@ -0,0 +1,20 @@
# Copyright (c) 2020 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM import i18nCatalog
from UM.Message import Message
I18N_CATALOG = i18nCatalog("cura")
class PrintJobUploadPrinterInactiveMessage(Message):
"""Message shown when uploading a print job to a cluster and the printer is inactive."""
def __init__(self) -> None:
super().__init__(
text = I18N_CATALOG.i18nc("@info:status", "The printer is inactive and cannot accept a new print job."),
title = I18N_CATALOG.i18nc("@info:title", "Printer inactive"),
lifetime = 10,
message_type=Message.MessageType.ERROR
)

View file

@ -10,7 +10,7 @@ class CloudClusterResponse(BaseModel):
"""Class representing a cloud connected cluster."""
def __init__(self, cluster_id: str, host_guid: str, host_name: str, is_online: bool, status: str,
host_internal_ip: Optional[str] = None, host_version: Optional[str] = None,
display_status: str, host_internal_ip: Optional[str] = None, host_version: Optional[str] = None,
friendly_name: Optional[str] = None, printer_type: str = "ultimaker3", printer_count: int = 1,
capabilities: Optional[List[str]] = None, **kwargs) -> None:
"""Creates a new cluster response object.
@ -20,6 +20,7 @@ class CloudClusterResponse(BaseModel):
:param host_name: The name of the printer as configured during the Wi-Fi setup. Used as identifier for end users.
:param is_online: Whether this cluster is currently connected to the cloud.
:param status: The status of the cluster authentication (active or inactive).
:param display_status: The display status of the cluster.
:param host_version: The firmware version of the cluster host. This is where the Stardust client is running on.
:param host_internal_ip: The internal IP address of the host printer.
:param friendly_name: The human readable name of the host printer.
@ -31,6 +32,7 @@ class CloudClusterResponse(BaseModel):
self.host_guid = host_guid
self.host_name = host_name
self.status = status
self.display_status = display_status
self.is_online = is_online
self.host_version = host_version
self.host_internal_ip = host_internal_ip
@ -51,5 +53,5 @@ class CloudClusterResponse(BaseModel):
Convenience function for printing when debugging.
:return: A human-readable representation of the data in this object.
"""
return str({k: v for k, v in self.__dict__.items() if k in {"cluster_id", "host_guid", "host_name", "status", "is_online", "host_version", "host_internal_ip", "friendly_name", "printer_type", "printer_count", "capabilities"}})
return str({k: v for k, v in self.__dict__.items() if k in {"cluster_id", "host_guid", "host_name", "status", "display_status", "is_online", "host_version", "host_internal_ip", "friendly_name", "printer_type", "printer_count", "capabilities"}})

View file

@ -14,6 +14,7 @@ class CloudClusterStatus(BaseModel):
def __init__(self, printers: List[Union[ClusterPrinterStatus, Dict[str, Any]]],
print_jobs: List[Union[ClusterPrintJobStatus, Dict[str, Any]]],
generated_time: Union[str, datetime],
unavailable: bool = False,
**kwargs) -> None:
"""Creates a new cluster status model object.
@ -23,6 +24,7 @@ class CloudClusterStatus(BaseModel):
"""
self.generated_time = self.parseDate(generated_time)
self.active = not unavailable
self.printers = self.parseModels(ClusterPrinterStatus, printers)
self.print_jobs = self.parseModels(ClusterPrintJobStatus, print_jobs)
super().__init__(**kwargs)

View file

@ -20,13 +20,23 @@ from ..BaseModel import BaseModel
class ClusterPrinterStatus(BaseModel):
"""Class representing a cluster printer"""
def __init__(self, enabled: bool, firmware_version: str, friendly_name: str, ip_address: str, machine_variant: str,
status: str, unique_name: str, uuid: str,
configuration: List[Union[Dict[str, Any], ClusterPrintCoreConfiguration]],
reserved_by: Optional[str] = None, maintenance_required: Optional[bool] = None,
firmware_update_status: Optional[str] = None, latest_available_firmware: Optional[str] = None,
build_plate: Union[Dict[str, Any], ClusterBuildPlate] = None,
material_station: Union[Dict[str, Any], ClusterPrinterMaterialStation] = None, **kwargs) -> None:
def __init__(self,
enabled: Optional[bool] = True,
friendly_name: Optional[str] = "",
machine_variant: Optional[str] = "",
status: Optional[str] = "unknown",
unique_name: Optional[str] = "",
uuid: Optional[str] = "",
configuration: Optional[List[Union[Dict[str, Any], ClusterPrintCoreConfiguration]]] = None,
firmware_version: Optional[str] = None,
ip_address: Optional[str] = None,
reserved_by: Optional[str] = "",
maintenance_required: Optional[bool] = False,
firmware_update_status: Optional[str] = "",
latest_available_firmware: Optional[str] = "",
build_plate: Optional[Union[Dict[str, Any], ClusterBuildPlate]] = None,
material_station: Optional[Union[Dict[str, Any], ClusterPrinterMaterialStation]] = None,
**kwargs) -> None:
"""
Creates a new cluster printer status
:param enabled: A printer can be disabled if it should not receive new jobs. By default, every printer is enabled.
@ -47,7 +57,7 @@ class ClusterPrinterStatus(BaseModel):
:param material_station: The material station that is on the printer.
"""
self.configuration = self.parseModels(ClusterPrintCoreConfiguration, configuration)
self.configuration = self.parseModels(ClusterPrintCoreConfiguration, configuration) if configuration else []
self.enabled = enabled
self.firmware_version = firmware_version
self.friendly_name = friendly_name
@ -70,7 +80,7 @@ class ClusterPrinterStatus(BaseModel):
:param controller: - The controller of the model.
"""
model = PrinterOutputModel(controller, len(self.configuration), firmware_version = self.firmware_version)
model = PrinterOutputModel(controller, len(self.configuration), firmware_version = self.firmware_version or "")
self.updateOutputModel(model)
return model
@ -86,7 +96,8 @@ class ClusterPrinterStatus(BaseModel):
model.updateType(self.machine_variant)
model.updateState(self.status if self.enabled else "disabled")
model.updateBuildplate(self.build_plate.type if self.build_plate else "glass")
model.setCameraUrl(QUrl("http://{}:8080/?action=stream".format(self.ip_address)))
if self.ip_address:
model.setCameraUrl(QUrl("http://{}:8080/?action=stream".format(self.ip_address)))
if not model.printerConfiguration:
# Prevent accessing printer configuration when not available.

View file

@ -46,10 +46,10 @@ class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice):
QUEUED_PRINT_JOBS_STATES = {"queued", "error"}
def __init__(self, device_id: str, address: str, properties: Dict[bytes, bytes], connection_type: ConnectionType,
parent=None) -> None:
parent=None, active: bool = True) -> None:
super().__init__(device_id=device_id, address=address, properties=properties, connection_type=connection_type,
parent=parent)
parent=parent, active=active)
# Trigger the printersChanged signal when the private signal is triggered.
self.printersChanged.connect(self._clusterPrintersChanged)

View file

@ -1 +1 @@
version: "5.10.2"
version: "5.11.0-alpha.0"

View file

@ -0,0 +1,52 @@
{
"version": 2,
"name": "Anycubic Kobra 3 v2",
"inherits": "fdmprinter",
"metadata":
{
"visible": true,
"author": "Sam Bonnekamp",
"manufacturer": "Anycubic",
"file_formats": "text/x-gcode",
"platform": "anycubic_kobra3v2_buildplate.stl",
"has_textured_buildplate": true,
"machine_extruder_trains": { "0": "anycubic_kobra3v2_extruder_0" }
},
"overrides":
{
"adhesion_type": { "value": "'skirt'" },
"layer_height": { "default_value": 0.2 },
"machine_buildplate_type": { "default_value": "PEI Spring Steel" },
"machine_center_is_zero": { "default_value": false },
"machine_depth": { "default_value": 250 },
"machine_end_gcode": { "default_value": "G1 X5 Y{machine_depth*0.95} F{speed_travel*60} ; present print\nM140 S0 ; turn off heat bed\nM104 S0 ; turn off temperature\nM84; disable motors ; disable stepper motors" },
"machine_heated_bed": { "default_value": true },
"machine_height": { "default_value": 260 },
"machine_name":
{
"default_value": "Anycubic Kobra 3 v2",
"description": "Anycubic Kobra 3 v2"
},
"machine_start_gcode": { "default_value": "; thumbnail begin 32x32\n; iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAX\n; NSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAARWSURBVHgB1Vc7bx1FFD5n/cC+YOQ4RBTXlg0STfIH\n; gM78h0SAhGgQjyqARO8eFEQFBaKhCHIoKagcOkJJQUWBI19XJLaJgp+5M8x5zZzdO+sg0ZCR1zM7u3\n; e+73znMbMAT0KLMVqPdKVhvuze+o2NjUafget7Gz4GeO3s7Ow29YiYiXSJ2TPft0BwEmZ2dpYnp+Ec\n; 8NPT09tpyOAhhF4CXSBPwhOvEZk+B/ynNFzd2QvwxlfHMEq9PqR//mVaWcegmqKCIf9FKGS2Px30Ey\n; A/Hh8fE/gWgRPomww+VkxVgYFoWcz3xXpUJo2QsXvEFu8qAQJvmobA13b3I1u+Q+AhQuRfB1kTBDS6\n; FUVmVBx5iwmqNIgNv9ONkUzg4ODgBQNn2b80yyNbCzFQqMsC4MCNg/eveSQGdQM6glhXYDAYbJHvRw\n; q+ux90dQ8eeBxacaD+5T5ZSdLbfIcT61hTgCZTuq1R/zqDjxMOva6SKzBdMQRoO1OsovcLZrpPkmOO\n; ytibCdPejOQCGJHl5l+zlEGNxBiuvjxI1zNwZTgDzw4a6LbV6/fTT8eqSGD/G3hVgTQRT05OOv6Nyl\n; zBwxgW5iN8/e4leOWlOTivESjAlNhvYJoEvTEA6gq7wIqMyZ76zevPw5XlWdi5/wi+2foLNu88hAeH\n; QQKQJG/ScjjFY1cUFD3mYKxmgeef+1z9AstO4KMEfu3zXdi594gXlHIj3hYFY6su5fVQU7OjgAvZNg\n; kpaDFn8jvrC9zf+GEvgZ+16HqX5QRVctl6FmAyXhq3TmHnROBhWuBysp7aj7/+rS+IWybKO8oPWTns\n; zFX2kxalEKTe+6pZXR9kx0X3olhXdmqJ+GIF6lyfC8ArUCnZ8PPvJ9xfXn4KLMCkuBlo306payJMWD\n; 9BoGspE9LrVop4ajfevgQrz82U5xkQyg7onyEWfpUzxSQBlUnjWRZM8t66cwi/jU5h5eIMbH40hKuv\n; LqS6UFwh7mhv1RL4AYojY9VQbqkQRWL34icPtQ5o6dXqR9fwQlLgrYuPLUSrH96TvZDigsg1UwxFc3\n; c/e5rfsRNRI/JFNNns8MBy8QKNvJYKzGgf4NoXf8LH3+5xTDw4jP0szH16ZvBlybdcitMhpJzrwKUB\n; jcmCiDkDvv/liK92qRWLeTdsZYSsQaPhhXOOZD44uLJhqXAIxYKIQUIaSqBGg+Di09CuBiUYxQ0E/t\n; 0Hc2bk9gQB26kiYJEK7a6kJyqdvL/bKSiKOmx9dGumfpnA35/jnsDH4/H6BAH7Qba3dobTTQYrOY0N\n; usOnbr9JieEiCPgSzzH4/Pz8dpVASw3T2OY0Y2V7FkBrdjDOarErkLPmpgNPkb/u5e8l4CqHG1IqhU\n; xEoQuZztlwuIRw8705WFma8uB3a0jcUhb05pQ/zdp+Ufso8R8hdLqygDs6OnptcXHxD+gx9V8R6H6a\n; dQ8WrSwq81XZ/1NToLztuY9T8D3U687/r/0D2siIlZoKRzIAAAAASUVORK5CYII=\n; thumbnail end\n; external perimeters extrusion width = 0.42mm\n; perimeters extrusion width = 0.45mm\n; infill extrusion width = 0.45mm\n; solid infill extrusion width = 0.45mm\n; top infill extrusion width = 0.42mm\n; support material extrusion width = 0.42mm\n; first layer extrusion width = 0.50mm\n;TYPE:Custom\nG9111 bedTemp={material_bed_temperature} extruderTemp={material_print_temperature}\nM117 ;display LCD message\nM900 K0.05 ;linear advance factor, ive only seen this set to k0.05\n;START HEADER\nG21 ; set units to millimeters\nG90 ; use absolute coordinates\nT0 ;set or report the current extruder or other tool\nM107 ; turn fan off" },
"machine_start_gcode_first": { "default_value": true },
"machine_width": { "default_value": 250 },
"material_bed_temperature":
{
"maximum_value": "110",
"maximum_value_warning": "90"
},
"material_diameter": { "default_value": 1.75 },
"material_initial_print_temperature":
{
"maximum_value_warning": 295,
"value": "material_print_temperature + 5"
},
"material_print_temperature": { "maximum_value_warning": 250 },
"material_print_temperature_layer_0":
{
"maximum_value_warning": 295,
"value": "material_print_temperature + 5"
},
"relative_extrusion": { "value": true }
}
}

View file

@ -0,0 +1,61 @@
{
"version": 2,
"name": "Anycubic Kobra 3 v2 ACE PRO",
"inherits": "fdmprinter",
"metadata":
{
"visible": true,
"author": "Sam Bonnekamp",
"manufacturer": "Anycubic",
"file_formats": "text/x-gcode",
"platform": "anycubic_kobra3v2_buildplate.stl",
"has_textured_buildplate": true,
"machine_extruder_trains":
{
"0": "anycubic_kobra3v2_ACEPRO_extruder_0",
"1": "anycubic_kobra3v2_ACEPRO_extruder_1",
"2": "anycubic_kobra3v2_ACEPRO_extruder_2",
"3": "anycubic_kobra3v2_ACEPRO_extruder_3"
}
},
"overrides":
{
"adhesion_type": { "value": "'skirt'" },
"layer_height": { "default_value": 0.2 },
"machine_buildplate_type": { "default_value": "PEI Spring Steel" },
"machine_center_is_zero": { "default_value": false },
"machine_depth": { "default_value": 250 },
"machine_end_gcode": { "default_value": "G1 X5 Y{machine_depth*0.95} F{speed_travel*60} ; present print\nM140 S0 ; turn off heat bed\nM104 S0 ; turn off temperature\nM84; disable motors ; disable stepper motors" },
"machine_extruder_count": { "default_value": 4 },
"machine_heated_bed": { "default_value": true },
"machine_height": { "default_value": 260 },
"machine_name":
{
"default_value": "Anycubic Kobra 3 v2",
"description": "Anycubic Kobra 3 v2"
},
"machine_start_gcode": { "default_value": "; thumbnail begin 32x32\n; iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAX\n; NSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAARWSURBVHgB1Vc7bx1FFD5n/cC+YOQ4RBTXlg0STfIH\n; gM78h0SAhGgQjyqARO8eFEQFBaKhCHIoKagcOkJJQUWBI19XJLaJgp+5M8x5zZzdO+sg0ZCR1zM7u3\n; e+73znMbMAT0KLMVqPdKVhvuze+o2NjUafget7Gz4GeO3s7Ow29YiYiXSJ2TPft0BwEmZ2dpYnp+Ec\n; 8NPT09tpyOAhhF4CXSBPwhOvEZk+B/ynNFzd2QvwxlfHMEq9PqR//mVaWcegmqKCIf9FKGS2Px30Ey\n; A/Hh8fE/gWgRPomww+VkxVgYFoWcz3xXpUJo2QsXvEFu8qAQJvmobA13b3I1u+Q+AhQuRfB1kTBDS6\n; FUVmVBx5iwmqNIgNv9ONkUzg4ODgBQNn2b80yyNbCzFQqMsC4MCNg/eveSQGdQM6glhXYDAYbJHvRw\n; q+ux90dQ8eeBxacaD+5T5ZSdLbfIcT61hTgCZTuq1R/zqDjxMOva6SKzBdMQRoO1OsovcLZrpPkmOO\n; ytibCdPejOQCGJHl5l+zlEGNxBiuvjxI1zNwZTgDzw4a6LbV6/fTT8eqSGD/G3hVgTQRT05OOv6Nyl\n; zBwxgW5iN8/e4leOWlOTivESjAlNhvYJoEvTEA6gq7wIqMyZ76zevPw5XlWdi5/wi+2foLNu88hAeH\n; QQKQJG/ScjjFY1cUFD3mYKxmgeef+1z9AstO4KMEfu3zXdi594gXlHIj3hYFY6su5fVQU7OjgAvZNg\n; kpaDFn8jvrC9zf+GEvgZ+16HqX5QRVctl6FmAyXhq3TmHnROBhWuBysp7aj7/+rS+IWybKO8oPWTns\n; zFX2kxalEKTe+6pZXR9kx0X3olhXdmqJ+GIF6lyfC8ArUCnZ8PPvJ9xfXn4KLMCkuBlo306payJMWD\n; 9BoGspE9LrVop4ajfevgQrz82U5xkQyg7onyEWfpUzxSQBlUnjWRZM8t66cwi/jU5h5eIMbH40hKuv\n; LqS6UFwh7mhv1RL4AYojY9VQbqkQRWL34icPtQ5o6dXqR9fwQlLgrYuPLUSrH96TvZDigsg1UwxFc3\n; c/e5rfsRNRI/JFNNns8MBy8QKNvJYKzGgf4NoXf8LH3+5xTDw4jP0szH16ZvBlybdcitMhpJzrwKUB\n; jcmCiDkDvv/liK92qRWLeTdsZYSsQaPhhXOOZD44uLJhqXAIxYKIQUIaSqBGg+Di09CuBiUYxQ0E/t\n; 0Hc2bk9gQB26kiYJEK7a6kJyqdvL/bKSiKOmx9dGumfpnA35/jnsDH4/H6BAH7Qba3dobTTQYrOY0N\n; usOnbr9JieEiCPgSzzH4/Pz8dpVASw3T2OY0Y2V7FkBrdjDOarErkLPmpgNPkb/u5e8l4CqHG1IqhU\n; xEoQuZztlwuIRw8705WFma8uB3a0jcUhb05pQ/zdp+Ufso8R8hdLqygDs6OnptcXHxD+gx9V8R6H6a\n; dQ8WrSwq81XZ/1NToLztuY9T8D3U687/r/0D2siIlZoKRzIAAAAASUVORK5CYII=\n; thumbnail end\n; external perimeters extrusion width = 0.42mm\n; perimeters extrusion width = 0.45mm\n; infill extrusion width = 0.45mm\n; solid infill extrusion width = 0.45mm\n; top infill extrusion width = 0.42mm\n; support material extrusion width = 0.42mm\n; first layer extrusion width = 0.50mm\n;TYPE:Custom\nG9111 bedTemp={material_bed_temperature} extruderTemp={material_print_temperature}\nM117 ;display LCD message\nM900 K0.05 ;linear advance factor, ive only seen this set to k0.05\n;START HEADER\nG21 ; set units to millimeters\nG90 ; use absolute coordinates\nT0 ;set or report the current extruder or other tool\nM107 ; turn fan off" },
"machine_start_gcode_first": { "default_value": true },
"machine_width": { "default_value": 250 },
"material_bed_temperature":
{
"maximum_value": "110",
"maximum_value_warning": "90"
},
"material_diameter": { "default_value": 1.75 },
"material_initial_print_temperature":
{
"maximum_value_warning": 295,
"value": "material_print_temperature + 5"
},
"material_print_temp_wait": { "value": true },
"material_print_temperature": { "maximum_value": 300 },
"material_print_temperature_layer_0":
{
"maximum_value_warning": 295,
"value": "material_print_temperature + 5"
},
"material_standby_temperature": { "default_value": "material_print_temperature" },
"relative_extrusion": { "value": true }
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,267 @@
{
"version": 2,
"name": "BambuLab base definition",
"inherits": "fdmprinter",
"metadata":
{
"visible": false,
"author": "UltiMaker",
"manufacturer": "BambuLab",
"file_formats": "application/vnd.bambulab-package.3dmanufacturing-3dmodel+xml"
},
"overrides":
{
"acceleration_infill": { "value": "acceleration_print" },
"acceleration_layer_0": { "value": 2000 },
"acceleration_prime_tower": { "value": "acceleration_print" },
"acceleration_print": { "value": 20000 },
"acceleration_print_layer_0": { "value": "acceleration_layer_0" },
"acceleration_roofing": { "value": "acceleration_wall_0" },
"acceleration_skirt_brim": { "value": "acceleration_layer_0" },
"acceleration_support": { "value": "acceleration_print" },
"acceleration_support_bottom": { "value": "acceleration_support_interface" },
"acceleration_support_infill": { "value": "acceleration_support" },
"acceleration_support_interface": { "value": "acceleration_support" },
"acceleration_support_roof": { "value": "acceleration_support_interface" },
"acceleration_topbottom": { "value": "acceleration_print" },
"acceleration_travel": { "value": 20000 },
"acceleration_travel_enabled": { "value": true },
"acceleration_travel_layer_0": { "value": "acceleration_layer_0" },
"acceleration_wall": { "value": "acceleration_print/8" },
"acceleration_wall_0": { "value": "acceleration_wall" },
"acceleration_wall_0_roofing": { "value": "acceleration_wall_0" },
"acceleration_wall_x": { "value": "acceleration_print" },
"acceleration_wall_x_roofing": { "value": "acceleration_wall" },
"adhesion_type": { "value": "'skirt'" },
"bottom_thickness": { "value": 0.6 },
"bridge_skin_speed":
{
"unit": "mm/s",
"value": "bridge_wall_speed"
},
"bridge_sparse_infill_max_density": { "value": 50 },
"bridge_wall_min_length": { "value": 10 },
"bridge_wall_speed":
{
"unit": "mm/s",
"value": 50
},
"cool_min_layer_time": { "value": 6 },
"cool_min_speed": { "value": 6 },
"cool_min_temperature": { "value": "material_print_temperature-15" },
"default_material_print_temperature": { "maximum_value_warning": 320 },
"extra_infill_lines_to_support_skins": { "value": "'walls_and_lines'" },
"gradual_flow_enabled": { "value": false },
"hole_xy_offset": { "value": 0.075 },
"infill_overlap": { "value": 10 },
"infill_pattern": { "value": "'zigzag' if infill_sparse_density > 80 else 'gyroid'" },
"infill_sparse_density": { "value": 15 },
"infill_wall_line_count": { "value": "1 if infill_sparse_density > 80 else 0" },
"jerk_infill": { "value": "jerk_print" },
"jerk_layer_0": { "value": "jerk_print/2" },
"jerk_prime_tower": { "value": "jerk_print" },
"jerk_print": { "value": "50" },
"jerk_print_layer_0": { "value": "jerk_layer_0" },
"jerk_roofing": { "value": "jerk_wall_0" },
"jerk_skirt_brim": { "value": "jerk_layer_0" },
"jerk_support": { "value": "jerk_print" },
"jerk_support_bottom": { "value": "jerk_support_interface" },
"jerk_support_infill": { "value": "jerk_support" },
"jerk_support_interface": { "value": "jerk_support" },
"jerk_support_roof": { "value": "jerk_support_interface" },
"jerk_topbottom": { "value": "jerk_print" },
"jerk_travel": { "value": 50 },
"jerk_travel_enabled": { "value": true },
"jerk_travel_layer_0": { "value": "jerk_travel" },
"jerk_wall": { "value": "jerk_print/5" },
"jerk_wall_0": { "value": "jerk_wall" },
"jerk_wall_0_roofing": { "value": "jerk_wall_0" },
"jerk_wall_x": { "value": "jerk_print" },
"jerk_wall_x_roofing": { "value": "jerk_wall_0" },
"line_width": { "value": 0.42 },
"machine_acceleration": { "value": 10000 },
"machine_buildplate_type":
{
"default_value": "textured_pei_plate",
"options":
{
"cool_plate": "Cool Plate",
"engineering_plate": "Engineering Plate",
"high_temp_plate": "High Temp Plate",
"textured_pei_plate": "Textured PEI Plate"
}
},
"machine_center_is_zero": { "default_value": false },
"machine_gcode_flavor": { "default_value": "BambuLab" },
"machine_heated_bed": { "default_value": true },
"machine_max_feedrate_e": { "value": 150 },
"machine_max_feedrate_x": { "value": 500 },
"machine_max_feedrate_y": { "value": 500 },
"machine_max_feedrate_z": { "value": 15 },
"machine_max_jerk_e": { "default_value": 100 },
"machine_max_jerk_xy": { "default_value": 5000 },
"machine_max_jerk_z": { "default_value": 100 },
"machine_nozzle_cool_down_speed": { "default_value": 1.3 },
"machine_nozzle_heat_up_speed": { "default_value": 1.9 },
"machine_nozzle_size": { "default_value": 0.4 },
"machine_show_variants": { "value": true },
"machine_use_extruder_offset_to_offset_coords": { "value": false },
"material_diameter": { "default_value": 1.75 },
"material_flush_purge_length":
{
"default_value": 80,
"enabled": "not prime_tower_enable"
},
"material_flush_purge_speed":
{
"default_value": 500,
"enabled": "not prime_tower_enable"
},
"material_max_flowrate": { "enabled": true },
"max_skin_angle_for_expansion": { "value": 45 },
"meshfix_maximum_resolution": { "value": 0.4 },
"min_infill_area": { "default_value": 10 },
"optimize_wall_printing_order": { "value": false },
"prime_tower_enable": { "default_value": true },
"prime_tower_line_width": { "value": "1.5 * line_width" },
"prime_tower_min_volume": { "default_value": 250 },
"prime_tower_size": { "default_value": 40 },
"relative_extrusion": { "value": true },
"retraction_amount": { "value": 0.5 },
"retraction_combing_max_distance": { "value": 100 },
"retraction_extra_prime_amount": { "value": 0.12 },
"retraction_hop": { "value": 0.2 },
"retraction_hop_after_extruder_switch_height": { "value": 2 },
"retraction_hop_enabled": { "value": true },
"retraction_min_travel": { "value": "5 if support_enable and support_structure=='tree' else line_width * 2" },
"retraction_prime_speed": { "value": 15 },
"retraction_speed": { "value": 30 },
"skin_edge_support_thickness": { "value": 0 },
"skin_material_flow": { "value": 95 },
"skin_overlap": { "value": 0 },
"skin_preshrink": { "value": 0 },
"skirt_brim_speed": { "maximum_value_warning": 500 },
"skirt_line_count": { "value": 5 },
"small_skin_on_surface": { "value": false },
"small_skin_width": { "value": 4 },
"speed_infill":
{
"maximum_value_warning": 500,
"value": "speed_print"
},
"speed_ironing":
{
"maximum_value_warning": 500,
"value": 20
},
"speed_layer_0":
{
"maximum_value_warning": 500,
"value": "speed_print/6"
},
"speed_prime_tower":
{
"maximum_value_warning": 500,
"value": "speed_wall"
},
"speed_print":
{
"maximum_value_warning": 500,
"value": 300
},
"speed_print_layer_0":
{
"maximum_value_warning": 500,
"value": "speed_layer_0"
},
"speed_roofing":
{
"maximum_value_warning": 500,
"value": "speed_wall"
},
"speed_support":
{
"maximum_value_warning": 500,
"value": "speed_wall_0"
},
"speed_support_bottom":
{
"maximum_value_warning": 500,
"value": "speed_support_interface"
},
"speed_support_infill":
{
"maximum_value_warning": 500,
"value": "speed_support"
},
"speed_support_interface":
{
"maximum_value_warning": 500,
"value": 50
},
"speed_support_roof":
{
"maximum_value_warning": 500,
"value": "speed_support_interface"
},
"speed_topbottom":
{
"maximum_value_warning": 500,
"value": "speed_print"
},
"speed_travel":
{
"maximum_value": 500,
"value": 500
},
"speed_travel_layer_0":
{
"maximum_value": 500,
"value": 150
},
"speed_wall":
{
"maximum_value_warning": 500,
"value": "speed_print*2/3"
},
"speed_wall_0":
{
"maximum_value_warning": 500,
"value": "speed_wall"
},
"speed_wall_0_roofing":
{
"maximum_value_warning": 500,
"value": "speed_wall"
},
"speed_wall_x":
{
"maximum_value_warning": 500,
"value": "speed_print"
},
"speed_wall_x_roofing":
{
"maximum_value_warning": 500,
"value": "speed_wall"
},
"support_brim_line_count": { "value": 5 },
"support_infill_rate": { "value": "80 if gradual_support_infill_steps != 0 else 15" },
"support_pattern": { "value": "'gyroid'" },
"support_structure": { "value": "'tree'" },
"switch_extruder_retraction_amount": { "value": 5 },
"travel_avoid_other_parts": { "value": false },
"wall_0_acceleration": { "value": 1000 },
"wall_0_deceleration": { "value": 1000 },
"wall_0_end_speed_ratio": { "value": 100 },
"wall_0_speed_split_distance": { "value": 0.2 },
"wall_0_start_speed_ratio": { "value": 100 },
"wall_0_wipe_dist": { "value": 0 },
"wall_material_flow": { "value": 95 },
"wall_overhang_angle": { "value": 10 },
"wall_overhang_speed_factors": { "default_value": "[25,15,5,5]" },
"wall_x_material_flow": { "value": 100 },
"z_seam_corner": { "value": "'z_seam_corner_weighted'" },
"z_seam_position": { "value": "'backright'" },
"z_seam_type": { "value": "'sharpest_corner'" }
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,39 @@
{
"version": 2,
"name": "Biqu B2",
"inherits": "biqu_b1",
"metadata":
{
"visible": true,
"author": "Boris Juraga",
"has_textured_buildplate": true,
"machine_extruder_trains":
{
"0": "biqu_b2_extruder_0",
"1": "biqu_b2_extruder_1"
},
"quality_definition": "biqu_b2"
},
"overrides":
{
"gantry_height": { "value": 27.5 },
"machine_end_gcode": { "default_value": ";BEGIN OF CUSTOM END GCODE\nM104 S0\nM140 S0\n;Retract the filament\nG91\nG1 E-30 F300\nG1 Z5\nG90\nG28 X0 Y{machine_depth}\n;END OF CUSTOM END GCODE" },
"machine_extruder_count": { "default_value": 2 },
"machine_extruders_share_heater": { "default_value": true },
"machine_extruders_share_nozzle": { "default_value": true },
"machine_extruders_shared_nozzle_initial_retraction": { "default_value": 30 },
"machine_head_with_fans_polygon":
{
"default_value": [
[-33, 35],
[-33, -23],
[33, -23],
[33, 35]
]
},
"machine_name": { "default_value": "BIQU B2" },
"machine_start_gcode": { "default_value": ";BEGIN OF CUSTOM START GCODE\nG28 ;Home\nG1 Z15.0 F6000 ;Move the platform down 15mm\n;Prime the extruder\nM109 S{material_print_temperature_layer_0} ; Wait for Extruder temperature\nT0\nG92 E0\nG1 F1200 E-30\nG92 E0\nM109 S{material_print_temperature_layer_0} ; Wait for Extruder temperature\nT1\nG92 E0\nG1 Z2.0 F3000 ; Move Z Axis up little to prevent scratching of Heat Bed\nG1 X6.1 Y20 Z0.3 F5000.0 ; Move to start position\nM117 Purging\nG1 X6.1 Y200.0 Z0.3 F1500.0 E10 ; Draw the first line\nG1 X6.4 Y200.0 Z0.3 F5000.0 ; Move to side a little\nG1 X6.4 Y20 Z0.3 F1500.0 E20 ; Draw the second line\nG1 X6.7 Y20 Z0.3 F5000.0 ; Move to side a little\nG1 X6.7 Y200.0 Z0.3 F1500.0 E30 ; Draw the three line\nG1 X7.0 Y200.0 Z0.3 F5000.0 ; Move to side a little\nG1 X7.0 Y20 Z0.3 F1500.0 E40 ; Draw the four line\nG1 X7.3 Y20 Z0.3 F5000.0 ; Move to side a little\nG1 X7.3 Y200.0 Z0.3 F1500.0 E50 ; Draw the four line\nG92 E0 \nT1\nG92 E0\nG1 F1200 E-30\nG92 E0\nT0\nG92 E0\nG1 F1200 E30\nG92 E0\nG1 Z2.0 F3000 ; Move Z Axis up little to prevent scratching of Heat Bed\nG1 X9.1 Y20 Z0.3 F5000.0 ; Move to start position\nM117 Purging\nG1 X9.1 Y200.0 Z0.3 F1500.0 E10 ; Draw the first line\nG1 X9.4 Y200.0 Z0.3 F5000.0 ; Move to side a little\nG1 X9.4 Y20 Z0.3 F1500.0 E20 ; Draw the second line\nG1 X9.7 Y20 Z0.3 F5000.0 ; Move to side a little\nG1 X9.7 Y200.0 Z0.3 F1500.0 E30 ; Draw the three line\nG1 X10.0 Y200.0 Z0.3 F5000.0 ; Move to side a little\nG1 X10.0 Y20 Z0.3 F1500.0 E40 ; Draw the four line\nG1 X10.3 Y20 Z0.3 F5000.0 ; Move to side a little\nG1 X10.3 Y200.0 Z0.3 F1500.0 E50 ; Draw the four line\nT0\nG92 E0\nG1 F1200 E-30\nG92 E0\nG92 E0\nT{initial_extruder_nr} ; RESET EXTRUDER TO INITIAL\n; start print\n;END OF CUSTOM START GCODE" },
"prime_tower_enable": { "default_value": true },
"prime_tower_mode": { "default_value": "interleaved" }
}
}

View file

@ -214,8 +214,8 @@
},
"machine_buildplate_type":
{
"label": "Build Plate Material",
"description": "The material of the build plate installed on the printer.",
"label": "Build Plate Type",
"description": "The type of build plate installed on the printer.",
"default_value": "glass",
"type": "enum",
"options":
@ -388,7 +388,8 @@
"Makerbot": "Makerbot",
"BFB": "Bits from Bytes",
"MACH3": "Mach3",
"Repetier": "Repetier"
"Repetier": "Repetier",
"BambuLab": "BambuLab"
},
"default_value": "RepRap (Marlin/Sprinter)",
"settable_per_mesh": false,
@ -1680,7 +1681,7 @@
"maximum_value": "999999",
"type": "int",
"minimum_value_warning": "2",
"value": "0 if infill_sparse_density == 100 else math.ceil(round(top_thickness / resolveOrValue('layer_height'), 4))",
"value": "math.ceil(round(top_thickness / resolveOrValue('layer_height'), 4))",
"limit_to_extruder": "top_bottom_extruder_nr",
"settable_per_mesh": true
}
@ -1710,7 +1711,7 @@
"default_value": 6,
"maximum_value": "999999",
"type": "int",
"value": "999999 if infill_sparse_density == 100 and not magic_spiralize else math.ceil(round(bottom_thickness / resolveOrValue('layer_height'), 4))",
"value": "math.ceil(round(bottom_thickness / resolveOrValue('layer_height'), 4))",
"limit_to_extruder": "top_bottom_extruder_nr",
"settable_per_mesh": true
},
@ -3376,6 +3377,20 @@
"settable_per_mesh": false,
"settable_per_extruder": true,
"settable_per_meshgroup": false
},
"material_max_flowrate":
{
"default_value": 16,
"description": "Maximum flow rate that the printer can extrude for the material",
"enabled": false,
"label": "Material Maximum Flow Rate",
"maximum_value": "machine_max_feedrate_e * (material_diameter/2)**2 * math.pi",
"minimum_value": "0",
"settable_per_extruder": true,
"settable_per_mesh": false,
"type": "float",
"unit": "mm\u00b3/s",
"value": "16"
}
}
},
@ -4553,7 +4568,7 @@
"minimum_value_warning": "-0.0001",
"maximum_value_warning": "10.0",
"enabled": "retraction_enable and machine_gcode_flavor != \"UltiGCode\"",
"settable_per_mesh": false,
"settable_per_mesh": true,
"settable_per_extruder": true
},
"retraction_speed":
@ -4643,7 +4658,7 @@
"maximum_value": 999999999,
"type": "int",
"enabled": "retraction_enable",
"settable_per_mesh": false,
"settable_per_mesh": true,
"settable_per_extruder": true
},
"retraction_extrusion_window":
@ -4657,7 +4672,7 @@
"maximum_value_warning": "retraction_amount * 2",
"value": "retraction_amount",
"enabled": "retraction_enable",
"settable_per_mesh": false,
"settable_per_mesh": true,
"settable_per_extruder": true
},
"retraction_combing":
@ -4855,7 +4870,7 @@
},
"build_fan_full_at_height":
{
"label": "Build Fan Speed at Height",
"label": "Build Volume Fan Speed at Height",
"description": "The height at which the fans spin on regular fan speed. At the layers below the fan speed gradually increases from Initial Fan Speed to Regular Fan Speed.",
"unit": "mm",
"type": "float",
@ -4870,8 +4885,8 @@
{
"build_fan_full_layer":
{
"label": "Build Fan Speed at Layer",
"description": "The layer at which the build fans spin on full fan speed. This value is calculated and rounded to a whole number.",
"label": "Build Volume Fan Speed at Layer",
"description": "The layer at which the build-volume fans spin on full fan speed. This value is calculated and rounded to a whole number.",
"type": "int",
"default_value": 0,
"minimum_value": "0",
@ -4884,6 +4899,34 @@
}
}
},
"build_volume_fan_speed_0":
{
"label": "Initial Layers Build Volume Fan Speed",
"description": "The fan speed (as a percentage) for the auxiliary or build-volume fan, that is set until the layer specified at 'Build Volume Fan Speed at Layer' is reached. After that, the speed is set by 'Build Volume Fan Speed' instead (so not this 'Initial Layers' one).",
"unit": "%",
"type": "float",
"minimum_value": "0",
"maximum_value": "100",
"default_value": 0,
"enabled": "build_volume_fan_nr != 0",
"settable_per_mesh": false,
"settable_per_extruder": false,
"settable_per_meshgroup": false
},
"build_volume_fan_speed":
{
"label": "Build Volume Fan Speed",
"description": "The fan speed (as a percentage) for the auxiliary or build-volume fan, that is set from the moment that the layer specified at 'Build Volume Fan Speed at Layer' is reached and onwards. Before that, the speed is set by 'Initial Layers Build Volume Fan Speed' instead.",
"unit": "%",
"type": "float",
"minimum_value": "0",
"maximum_value": "100",
"default_value": 100,
"enabled": "build_volume_fan_nr != 0",
"settable_per_mesh": false,
"settable_per_extruder": false,
"settable_per_meshgroup": false
},
"cool_fan_speed":
{
"label": "Fan Speed",
@ -7465,6 +7508,17 @@
"limit_to_extruder": "raft_surface_extruder_nr"
}
}
},
"machine_scan_first_layer":
{
"default_value": false,
"description": "Whether to scan the first layer for layer adhesion problems.",
"enabled": false,
"label": "Scan the first layer",
"settable_per_extruder": false,
"settable_per_mesh": false,
"settable_per_meshgroup": false,
"type": "bool"
}
}
},
@ -9257,6 +9311,42 @@
"default_value": true,
"settable_per_mesh": true
},
"retraction_during_travel_ratio":
{
"label": "Retraction During Travel Move",
"description": "<html>The ratio of retraction performed during the travel move, with the remainder completed while the nozzle is stationary, before traveling<ul><li>When 0, the entire retraction is performed while stationary, before the travel begins</li><li>When 100, the entire retraction is performed during the travel move, bypassing the stationary phase</li></ul></html>",
"unit": "%",
"type": "float",
"default_value": 0,
"minimum_value": 0,
"maximum_value": 100,
"enabled": "retraction_enable and not machine_firmware_retract and machine_gcode_flavor != \"UltiGCode\" and machine_gcode_flavor != \"BFB\"",
"settable_per_mesh": false,
"settable_per_extruder": true
},
"keep_retracting_during_travel":
{
"label": "Keep Retracting During Travel",
"description": "When retraction during travel is enabled, and there is more than enough time to perform a full retract during a travel move, spread the retraction over the whole travel move with a lower retraction speed, so that we do not travel with a non-retracting nozzle. This can help reducing oozing.",
"type": "bool",
"default_value": false,
"enabled": "retraction_enable and not machine_firmware_retract and machine_gcode_flavor != \"UltiGCode\" and machine_gcode_flavor != \"BFB\" and retraction_during_travel_ratio > 0",
"settable_per_mesh": false,
"settable_per_extruder": true
},
"prime_during_travel_ratio":
{
"label": "Prime During Travel Move",
"description": "<html>The ratio of priming performed during the travel move, with the remainder completed while the nozzle is stationary, after traveling<ul><li>When 0, the entire priming is performed while stationary, after the travel ends</li><li>When 100, the entire priming is performed during the travel move, allowing the print to start immediately</li></ul></html>",
"unit": "%",
"type": "float",
"default_value": 0,
"minimum_value": 0,
"maximum_value": 100,
"enabled": "retraction_enable and not machine_firmware_retract and machine_gcode_flavor != \"UltiGCode\" and machine_gcode_flavor != \"BFB\"",
"settable_per_mesh": false,
"settable_per_extruder": true
},
"scarf_joint_seam_length":
{
"label": "Scarf Seam Length",

View file

@ -0,0 +1,59 @@
{
"version": 2,
"name": "Geeetech M1",
"inherits": "Geeetech_Base_Single_Extruder",
"metadata":
{
"visible": true,
"machine_extruder_trains": { "0": "Geeetech_Single_Extruder" }
},
"overrides":
{
"adhesion_type": { "value": "'brim'" },
"brim_width": { "value": 2 },
"gantry_height": { "value": 35 },
"machine_depth": { "default_value": 105 },
"machine_end_gcode": { "default_value": "G91 ;Switch to relative positioning\nG1 E-2.5 F2700 ;Retract filament\nG1 E-1.5 Z0.2 F2400 ;Retract and raise Z\nG1 X5 Y5 F3000 ;Move away\nG1 Z10 ;lift print head\nG90 ;Switch to absolute positioning\nG28 X Y ;homing XY\nM106 S0 ;off Fan\nM104 S0 ;Cooldown hotend\nM140 S0 ;Cooldown bed\nM84 X Y E ;Disable steppers" },
"machine_head_with_fans_polygon":
{
"default_value": [
[-31, 31],
[34, 31],
[34, -40],
[-31, -40]
]
},
"machine_height": { "default_value": 95 },
"machine_name": { "default_value": "Geeetech M1" },
"machine_start_gcode": { "default_value": ";Official wiki URL for Geeetech M1:https://www.geeetech.com/wiki/index.php/Geeetech_M1_3D_printer \nM104 S{material_print_temperature_layer_0} ; Set Hotend Temperature\nM140 S{material_bed_temperature_layer_0} ; Set Bed Temperature\n;M190 S{material_bed_temperature_layer_0} ; Wait for Bed Temperature\nM109 S{material_print_temperature_layer_0} ; Wait for Hotend Temperature\nG92 E0 ; Reset Extruder\nG28 ; Home all axes\nM107 ;Off Fan\nM300 S2500 P1000 ;Play a short tune\nG1 Z0.28 ;Move Z Axis up little to prevent scratching of Heat Bed\nG92 E0 ;Reset Extruder\nG1 Y3 F2400 ;Move to start position\nG1 X75 E40 F500 ;Draw a filament line\nG92 E0 ;Reset Extruder\n;G1 E-0.2 F3000 ;Retract a little\nG1 Z2.0 F3000 ;Move Z Axis up little to prevent scratching of Heat Bed\nG1 X70 Y3 Z0.27 F3000 ;Quickly wipe away from the filament line\nG92 E0 ;Reset Extruder" },
"machine_width": { "default_value": 105 },
"material_bed_temperature": { "maximum_value": 60 },
"material_print_temperature": { "maximum_value": 230 },
"retraction_amount": { "value": 2 },
"speed_print":
{
"maximum_value_warning": "200",
"value": 120
},
"speed_topbottom":
{
"maximum_value_warning": "200",
"value": 60
},
"speed_wall":
{
"maximum_value_warning": "200",
"value": 80
},
"speed_wall_0":
{
"maximum_value_warning": "200",
"value": 50
},
"speed_wall_x":
{
"maximum_value_warning": "200",
"value": 80
}
}
}

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