Merge branch 'master' into issue-forms

This commit is contained in:
Fenne 2021-04-26 10:12:29 +02:00 committed by GitHub
commit 0c8e9297e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 3530 additions and 21 deletions

View file

@ -1,5 +1,5 @@
---
name: Bug report
name: Old Bug report
about: Create a report to help us fix issues.
title: ''
labels: 'Type: Bug'

View file

@ -78,4 +78,5 @@ body:
label: Additional information & file uploads
description: You can add these files and additional information that is relevant to the issue in the comments below.
validations:
required: true
required: true

View file

@ -36,6 +36,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
found_solution_for_all: Whether the algorithm found a place on the buildplate for all the objects
node_items: A list of the nodes return by libnest2d, which contain the new positions on the buildplate
"""
spacing = int(1.5 * factor) # 1.5mm spacing.
machine_width = build_volume.getWidth()
machine_depth = build_volume.getDepth()
@ -99,7 +100,7 @@ def findNodePlacement(nodes_to_arrange: List["SceneNode"], build_volume: "BuildV
config = NfpConfig()
config.accuracy = 1.0
num_bins = nest(node_items, build_plate_bounding_box, 10000, config)
num_bins = nest(node_items, build_plate_bounding_box, spacing, config)
# Strip the fixed items (previously placed) and the disallowed areas from the results again.
node_items = list(filter(lambda item: not item.isFixed(), node_items))

View file

@ -0,0 +1,17 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from UM.MimeTypeDatabase import MimeType, MimeTypeDatabase
from .src import DigitalFactoryFileProvider, DigitalFactoryOutputDevicePlugin, DigitalFactoryController
def getMetaData():
return {}
def register(app):
df_controller = DigitalFactoryController.DigitalFactoryController(app)
return {
"file_provider": DigitalFactoryFileProvider.DigitalFactoryFileProvider(df_controller),
"output_device": DigitalFactoryOutputDevicePlugin.DigitalFactoryOutputDevicePlugin(df_controller)
}

View file

@ -0,0 +1,8 @@
{
"name": "Ultimaker Digital Library",
"author": "Ultimaker B.V.",
"description": "Connects to the Digital Library, allowing Cura to open files from and save files to the Digital Library.",
"version": "1.0.0",
"api": "7.5.0",
"i18n-catalog": "cura"
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Artwork" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<polygon style="fill:#000E1A;" points="19.7,13.3 18.3,11.9 13,17.2 13,3 11,3 11,17.2 5.7,11.9 4.3,13.3 12,21 "/>
</svg>

After

Width:  |  Height:  |  Size: 470 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<path d="M24,44,7,33.4V14.6L24,4,41,14.6V33.4ZM9,32.3l15,9.3,15-9.3V15.7L24,6.4,9,15.7Z"/>
</svg>

After

Width:  |  Height:  |  Size: 184 B

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Artwork" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<path d="M12,19.6L4.4,12L7,9.4V12h2V6H3v2h2.6L3.7,9.9c-1.2,1.2-1.2,3.1,0,4.2l6.2,6.2c1.2,1.2,3.1,1.2,4.2,0l0.6-0.6l-1.4-1.4
L12,19.6z"/>
<path d="M20.3,9.9l-6.2-6.2c-1.2-1.2-3.1-1.2-4.2,0L9.3,4.3l1.4,1.4L12,4.4l7.6,7.6L17,14.6V12h-2v6h6v-2h-2.6l1.9-1.9
C21.5,12.9,21.5,11.1,20.3,9.9z"/>
</svg>

After

Width:  |  Height:  |  Size: 646 B

View file

@ -0,0 +1,159 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM
import Cura 1.6 as Cura
import DigitalFactory 1.0 as DF
Popup
{
id: base
padding: UM.Theme.getSize("default_margin").width
closePolicy: Popup.CloseOnEscape
focus: true
modal: true
background: Cura.RoundedRectangle
{
cornerSide: Cura.RoundedRectangle.Direction.All
border.color: UM.Theme.getColor("lining")
border.width: UM.Theme.getSize("default_lining").width
radius: UM.Theme.getSize("default_radius").width
width: parent.width
height: parent.height
color: UM.Theme.getColor("main_background")
}
Connections
{
target: manager
function onCreatingNewProjectStatusChanged(status)
{
if (status == DF.RetrievalStatus.Success)
{
base.close();
}
}
}
onOpened:
{
newProjectNameTextField.text = ""
newProjectNameTextField.focus = true
}
Label
{
id: createNewLibraryProjectLabel
text: "Create new Library project"
font: UM.Theme.getFont("medium")
color: UM.Theme.getColor("small_button_text")
anchors
{
top: parent.top
left: parent.left
right: parent.right
}
}
Label
{
id: projectNameLabel
text: "Project Name"
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("text")
anchors
{
top: createNewLibraryProjectLabel.bottom
topMargin: UM.Theme.getSize("default_margin").width
left: parent.left
right: parent.right
}
}
Cura.TextField
{
id: newProjectNameTextField
width: parent.width
anchors
{
top: projectNameLabel.bottom
topMargin: UM.Theme.getSize("thin_margin").width
left: parent.left
right: parent.right
}
validator: RegExpValidator
{
regExp: /^[^\\\/\*\?\|\[\]]{0,96}$/
}
text: PrintInformation.jobName
font: UM.Theme.getFont("default")
placeholderText: "Enter a name for your new project."
onAccepted:
{
if (verifyProjectCreationButton.enabled)
{
verifyProjectCreationButton.clicked()
}
}
}
Label
{
id: errorWhileCreatingProjectLabel
text: manager.projectCreationErrorText
width: parent.width
wrapMode: Text.WordWrap
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("error")
visible: manager.creatingNewProjectStatus == DF.RetrievalStatus.Failed
anchors
{
top: newProjectNameTextField.bottom
left: parent.left
right: parent.right
}
}
Cura.SecondaryButton
{
id: cancelProjectCreationButton
anchors.bottom: parent.bottom
anchors.left: parent.left
text: "Cancel"
onClicked:
{
base.close()
}
busy: false
}
Cura.PrimaryButton
{
id: verifyProjectCreationButton
anchors.bottom: parent.bottom
anchors.right: parent.right
text: "Create"
enabled: newProjectNameTextField.text != "" && !busy
onClicked:
{
manager.createLibraryProjectAndSetAsPreselected(newProjectNameTextField.text)
}
busy: manager.creatingNewProjectStatus == DF.RetrievalStatus.InProgress
}
}

View file

@ -0,0 +1,61 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM
import Cura 1.6 as Cura
import DigitalFactory 1.0 as DF
Window
{
id: digitalFactoryOpenDialogBase
title: "Open file from Library"
modality: Qt.ApplicationModal
width: 800 * screenScaleFactor
height: 600 * screenScaleFactor
minimumWidth: 800 * screenScaleFactor
minimumHeight: 600 * screenScaleFactor
Shortcut
{
sequence: "Esc"
onActivated: digitalFactoryOpenDialogBase.close()
}
color: UM.Theme.getColor("main_background")
SelectProjectPage
{
visible: manager.selectedProjectIndex == -1
createNewProjectButtonVisible: false
}
OpenProjectFilesPage
{
visible: manager.selectedProjectIndex >= 0
onOpenFilePressed: digitalFactoryOpenDialogBase.close()
}
BusyIndicator
{
// Shows up while Cura is waiting to receive the user's projects from the digital factory library
id: retrievingProjectsBusyIndicator
anchors {
verticalCenter: parent.verticalCenter
horizontalCenter: parent.horizontalCenter
}
width: parent.width / 4
height: width
visible: manager.retrievingProjectsStatus == DF.RetrievalStatus.InProgress
running: visible
palette.dark: UM.Theme.getColor("text")
}
}

View file

@ -0,0 +1,62 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM
import Cura 1.6 as Cura
import DigitalFactory 1.0 as DF
Window
{
id: digitalFactorySaveDialogBase
title: "Save Cura project to Library"
modality: Qt.ApplicationModal
width: 800 * screenScaleFactor
height: 600 * screenScaleFactor
minimumWidth: 800 * screenScaleFactor
minimumHeight: 600 * screenScaleFactor
Shortcut
{
sequence: "Esc"
onActivated: digitalFactorySaveDialogBase.close()
}
color: UM.Theme.getColor("main_background")
SelectProjectPage
{
visible: manager.selectedProjectIndex == -1
createNewProjectButtonVisible: true
}
SaveProjectFilesPage
{
visible: manager.selectedProjectIndex >= 0
onSavePressed: digitalFactorySaveDialogBase.close()
onSelectDifferentProjectPressed: manager.clearProjectSelection()
}
BusyIndicator
{
// Shows up while Cura is waiting to receive the user's projects from the digital factory library
id: retrievingProjectsBusyIndicator
anchors {
verticalCenter: parent.verticalCenter
horizontalCenter: parent.horizontalCenter
}
width: parent.width / 4
height: width
visible: manager.retrievingProjectsStatus == DF.RetrievalStatus.InProgress
running: visible
palette.dark: UM.Theme.getColor("text")
}
}

View file

@ -0,0 +1,129 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Controls 2.3
import UM 1.2 as UM
import Cura 1.6 as Cura
Cura.RoundedRectangle
{
id: base
cornerSide: Cura.RoundedRectangle.Direction.All
border.color: UM.Theme.getColor("lining")
border.width: UM.Theme.getSize("default_lining").width
radius: UM.Theme.getSize("default_radius").width
signal clicked()
property var hasMoreProjectsToLoad
enabled: hasMoreProjectsToLoad
color: UM.Theme.getColor("main_background")
MouseArea
{
id: cardMouseArea
anchors.fill: parent
hoverEnabled: true
}
Row
{
id: projectInformationRow
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
UM.RecolorImage
{
id: projectImage
anchors.verticalCenter: parent.verticalCenter
width: UM.Theme.getSize("section").height
height: width
color: UM.Theme.getColor("text_link")
source: "../images/arrow_down.svg"
}
Label
{
id: displayNameLabel
anchors.verticalCenter: parent.verticalCenter
text: "Load more projects"
color: UM.Theme.getColor("text_link")
font: UM.Theme.getFont("medium_bold")
}
}
Component.onCompleted:
{
cardMouseArea.clicked.connect(base.clicked)
}
states:
[
State
{
name: "canLoadMoreProjectsAndHovered";
when: base.hasMoreProjectsToLoad && cardMouseArea.containsMouse
PropertyChanges
{
target: projectImage
color: UM.Theme.getColor("text_link")
source: "../images/arrow_down.svg"
}
PropertyChanges
{
target: displayNameLabel
color: UM.Theme.getColor("text_link")
text: "Load more projects"
}
PropertyChanges
{
target: base
color: UM.Theme.getColor("action_button_hovered")
}
},
State
{
name: "canLoadMoreProjectsAndNotHovered";
when: base.hasMoreProjectsToLoad && !cardMouseArea.containsMouse
PropertyChanges
{
target: projectImage
color: UM.Theme.getColor("text_link")
source: "../images/arrow_down.svg"
}
PropertyChanges
{
target: displayNameLabel
color: UM.Theme.getColor("text_link")
text: "Load more projects"
}
PropertyChanges
{
target: base
color: UM.Theme.getColor("main_background")
}
},
State
{
name: "noMoreProjectsToLoad"
when: !base.hasMoreProjectsToLoad
PropertyChanges
{
target: projectImage
color: UM.Theme.getColor("action_button_disabled_text")
source: "../images/update.svg"
}
PropertyChanges
{
target: displayNameLabel
color: UM.Theme.getColor("action_button_disabled_text")
text: "No more projects to load"
}
PropertyChanges
{
target: base
color: UM.Theme.getColor("action_button_disabled")
}
}
]
}

View file

@ -0,0 +1,198 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM
import Cura 1.6 as Cura
import DigitalFactory 1.0 as DF
Item
{
id: base
width: parent.width
height: parent.height
property var fileModel: manager.digitalFactoryFileModel
signal openFilePressed()
signal selectDifferentProjectPressed()
anchors
{
fill: parent
margins: UM.Theme.getSize("default_margin").width
}
ProjectSummaryCard
{
id: projectSummaryCard
anchors.top: parent.top
property var selectedItem: manager.digitalFactoryProjectModel.getItem(manager.selectedProjectIndex)
imageSource: selectedItem.thumbnailUrl || "../images/placeholder.svg"
projectNameText: selectedItem.displayName || ""
projectUsernameText: selectedItem.username || ""
projectLastUpdatedText: "Last updated: " + selectedItem.lastUpdated
cardMouseAreaEnabled: false
}
Rectangle
{
id: projectFilesContent
width: parent.width
anchors.top: projectSummaryCard.bottom
anchors.topMargin: UM.Theme.getSize("default_margin").width
anchors.bottom: selectDifferentProjectButton.top
anchors.bottomMargin: UM.Theme.getSize("default_margin").width
color: UM.Theme.getColor("main_background")
border.width: UM.Theme.getSize("default_lining").width
border.color: UM.Theme.getColor("lining")
Cura.TableView
{
id: filesTableView
anchors.fill: parent
model: manager.digitalFactoryFileModel
visible: model.count != 0 && manager.retrievingFileStatus != DF.RetrievalStatus.InProgress
selectionMode: OldControls.SelectionMode.SingleSelection
OldControls.TableViewColumn
{
id: fileNameColumn
role: "fileName"
title: "Name"
width: Math.round(filesTableView.width / 3)
}
OldControls.TableViewColumn
{
id: usernameColumn
role: "username"
title: "Uploaded by"
width: Math.round(filesTableView.width / 3)
}
OldControls.TableViewColumn
{
role: "uploadedAt"
title: "Uploaded at"
}
Connections
{
target: filesTableView.selection
function onSelectionChanged()
{
let newSelection = [];
filesTableView.selection.forEach(function(rowIndex) { newSelection.push(rowIndex); });
manager.setSelectedFileIndices(newSelection);
}
}
}
Label
{
id: emptyProjectLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
text: "Select a project to view its files."
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("setting_category_text")
Connections
{
target: manager
function onSelectedProjectIndexChanged(newProjectIndex)
{
emptyProjectLabel.visible = (newProjectIndex == -1)
}
}
}
Label
{
id: noFilesInProjectLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
visible: (manager.digitalFactoryFileModel.count == 0 && !emptyProjectLabel.visible && !retrievingFilesBusyIndicator.visible)
text: "No supported files in this project."
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("setting_category_text")
}
BusyIndicator
{
// Shows up while Cura is waiting to receive the files of a project from the digital factory library
id: retrievingFilesBusyIndicator
anchors
{
verticalCenter: parent.verticalCenter
horizontalCenter: parent.horizontalCenter
}
width: parent.width / 4
height: width
visible: manager.retrievingFilesStatus == DF.RetrievalStatus.InProgress
running: visible
palette.dark: UM.Theme.getColor("text")
}
Connections
{
target: manager.digitalFactoryFileModel
function onItemsChanged()
{
// Make sure no files are selected when the file model changes
filesTableView.currentRow = -1
filesTableView.selection.clear()
}
}
}
Cura.SecondaryButton
{
id: selectDifferentProjectButton
anchors.bottom: parent.bottom
anchors.left: parent.left
text: "Change Library project"
onClicked:
{
manager.clearProjectSelection()
}
busy: false
}
Cura.PrimaryButton
{
id: openFilesButton
anchors.bottom: parent.bottom
anchors.right: parent.right
text: "Open"
enabled: filesTableView.selection.count > 0
onClicked:
{
manager.openSelectedFiles()
}
busy: false
}
Component.onCompleted:
{
openFilesButton.clicked.connect(base.openFilePressed)
selectDifferentProjectButton.clicked.connect(base.selectDifferentProjectPressed)
}
}

View file

@ -0,0 +1,92 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Controls 2.3
import UM 1.2 as UM
import Cura 1.6 as Cura
Cura.RoundedRectangle
{
id: base
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.width: UM.Theme.getSize("default_lining").width
radius: UM.Theme.getSize("default_radius").width
color: UM.Theme.getColor("main_background")
signal clicked()
property alias imageSource: projectImage.source
property alias projectNameText: displayNameLabel.text
property alias projectUsernameText: usernameLabel.text
property alias projectLastUpdatedText: lastUpdatedLabel.text
property alias cardMouseAreaEnabled: cardMouseArea.enabled
onVisibleChanged: color = UM.Theme.getColor("main_background")
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")
onClicked: base.clicked()
}
Row
{
id: projectInformationRow
width: parent.width
padding: UM.Theme.getSize("default_margin").width
spacing: UM.Theme.getSize("default_margin").width
Image
{
id: projectImage
anchors.verticalCenter: parent.verticalCenter
width: UM.Theme.getSize("toolbox_thumbnail_small").width
height: Math.round(width * 3/4)
sourceSize.width: width
sourceSize.height: height
fillMode: Image.PreserveAspectFit
mipmap: true
}
Column
{
id: projectLabelsColumn
height: projectImage.height
width: parent.width - x - UM.Theme.getSize("default_margin").width
anchors.verticalCenter: parent.verticalCenter
Label
{
id: displayNameLabel
width: parent.width
height: Math.round(parent.height / 3)
elide: Text.ElideRight
color: UM.Theme.getColor("text")
font: UM.Theme.getFont("default_bold")
}
Label
{
id: usernameLabel
width: parent.width
height: Math.round(parent.height / 3)
elide: Text.ElideRight
color: UM.Theme.getColor("small_button_text")
font: UM.Theme.getFont("default")
}
Label
{
id: lastUpdatedLabel
width: parent.width
height: Math.round(parent.height / 3)
elide: Text.ElideRight
color: UM.Theme.getColor("small_button_text")
font: UM.Theme.getFont("default")
}
}
}
}

View file

@ -0,0 +1,259 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM
import Cura 1.6 as Cura
import DigitalFactory 1.0 as DF
Item
{
id: base
width: parent.width
height: parent.height
property var fileModel: manager.digitalFactoryFileModel
signal savePressed()
signal selectDifferentProjectPressed()
anchors
{
fill: parent
margins: UM.Theme.getSize("default_margin").width
}
ProjectSummaryCard
{
id: projectSummaryCard
anchors.top: parent.top
property var selectedItem: manager.digitalFactoryProjectModel.getItem(manager.selectedProjectIndex)
imageSource: selectedItem.thumbnailUrl || "../images/placeholder.svg"
projectNameText: selectedItem.displayName || ""
projectUsernameText: selectedItem.username || ""
projectLastUpdatedText: "Last updated: " + selectedItem.lastUpdated
cardMouseAreaEnabled: false
}
Label
{
id: fileNameLabel
anchors.top: projectSummaryCard.bottom
anchors.topMargin: UM.Theme.getSize("default_margin").height
text: "Cura project name"
font: UM.Theme.getFont("medium")
color: UM.Theme.getColor("text")
}
Cura.TextField
{
id: dfFilenameTextfield
width: parent.width
anchors.left: parent.left
anchors.top: fileNameLabel.bottom
anchors.topMargin: UM.Theme.getSize("thin_margin").height
validator: RegExpValidator
{
regExp: /^[^\\\/\*\?\|\[\]]{0,96}$/
}
text: PrintInformation.jobName
font: UM.Theme.getFont("medium")
placeholderText: "Enter the name of the file."
onAccepted: { if (saveButton.enabled) {saveButton.clicked()}}
}
Rectangle
{
id: projectFilesContent
width: parent.width
anchors.top: dfFilenameTextfield.bottom
anchors.topMargin: UM.Theme.getSize("wide_margin").height
anchors.bottom: selectDifferentProjectButton.top
anchors.bottomMargin: UM.Theme.getSize("default_margin").width
color: UM.Theme.getColor("main_background")
border.width: UM.Theme.getSize("default_lining").width
border.color: UM.Theme.getColor("lining")
Cura.TableView
{
id: filesTableView
anchors.fill: parent
model: manager.digitalFactoryFileModel
visible: model.count != 0 && manager.retrievingFileStatus != DF.RetrievalStatus.InProgress
selectionMode: OldControls.SelectionMode.NoSelection
OldControls.TableViewColumn
{
id: fileNameColumn
role: "fileName"
title: "@tableViewColumn:title", "Name"
width: Math.round(filesTableView.width / 3)
}
OldControls.TableViewColumn
{
id: usernameColumn
role: "username"
title: "Uploaded by"
width: Math.round(filesTableView.width / 3)
}
OldControls.TableViewColumn
{
role: "uploadedAt"
title: "Uploaded at"
}
}
Label
{
id: emptyProjectLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
text: "Select a project to view its files."
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("setting_category_text")
Connections
{
target: manager
function onSelectedProjectIndexChanged()
{
emptyProjectLabel.visible = (manager.newProjectIndex == -1)
}
}
}
Label
{
id: noFilesInProjectLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
visible: (manager.digitalFactoryFileModel.count == 0 && !emptyProjectLabel.visible && !retrievingFilesBusyIndicator.visible)
text: "No supported files in this project."
font: UM.Theme.getFont("default")
color: UM.Theme.getColor("setting_category_text")
}
BusyIndicator
{
// Shows up while Cura is waiting to receive the files of a project from the digital factory library
id: retrievingFilesBusyIndicator
anchors
{
verticalCenter: parent.verticalCenter
horizontalCenter: parent.horizontalCenter
}
width: parent.width / 4
height: width
visible: manager.retrievingFilesStatus == DF.RetrievalStatus.InProgress
running: visible
palette.dark: UM.Theme.getColor("text")
}
Connections
{
target: manager.digitalFactoryFileModel
function onItemsChanged()
{
// Make sure no files are selected when the file model changes
filesTableView.currentRow = -1
filesTableView.selection.clear()
}
}
}
Cura.SecondaryButton
{
id: selectDifferentProjectButton
anchors.bottom: parent.bottom
anchors.left: parent.left
text: "Change Library project"
onClicked:
{
manager.selectedProjectIndex = -1
}
busy: false
}
Cura.PrimaryButton
{
id: saveButton
anchors.bottom: parent.bottom
anchors.right: parent.right
text: "Save"
enabled: (asProjectCheckbox.checked || asSlicedCheckbox.checked) && dfFilenameTextfield.text != ""
onClicked:
{
let saveAsFormats = [];
if (asProjectCheckbox.checked)
{
saveAsFormats.push("3mf");
}
if (asSlicedCheckbox.checked)
{
saveAsFormats.push("ufp");
}
manager.saveFileToSelectedProject(dfFilenameTextfield.text, saveAsFormats);
}
busy: false
}
Row
{
id: saveAsFormatRow
anchors.verticalCenter: saveButton.verticalCenter
anchors.right: saveButton.left
anchors.rightMargin: UM.Theme.getSize("thin_margin").height
width: childrenRect.width
spacing: UM.Theme.getSize("default_margin").width
Cura.CheckBox
{
id: asProjectCheckbox
height: UM.Theme.getSize("checkbox").height
anchors.verticalCenter: parent.verticalCenter
checked: true
text: "Save Cura project"
font: UM.Theme.getFont("medium")
}
Cura.CheckBox
{
id: asSlicedCheckbox
height: UM.Theme.getSize("checkbox").height
anchors.verticalCenter: parent.verticalCenter
enabled: UM.Backend.state == UM.Backend.Done
checked: UM.Backend.state == UM.Backend.Done
text: "Save print file"
font: UM.Theme.getFont("medium")
}
}
Component.onCompleted:
{
saveButton.clicked.connect(base.savePressed)
selectDifferentProjectButton.clicked.connect(base.selectDifferentProjectPressed)
}
}

View file

@ -0,0 +1,202 @@
// Copyright (C) 2021 Ultimaker B.V.
import QtQuick 2.10
import QtQuick.Window 2.2
import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one
import QtQuick.Controls 2.3
import QtQuick.Controls.Styles 1.4
import UM 1.2 as UM
import Cura 1.6 as Cura
import DigitalFactory 1.0 as DF
Item
{
id: base
width: parent.width
height: parent.height
property alias createNewProjectButtonVisible: createNewProjectButton.visible
anchors
{
top: parent.top
bottom: parent.bottom
left: parent.left
right: parent.right
margins: UM.Theme.getSize("default_margin").width
}
Label
{
id: selectProjectLabel
text: "Select Project"
font: UM.Theme.getFont("medium")
color: UM.Theme.getColor("small_button_text")
anchors.top: parent.top
anchors.left: parent.left
visible: projectListContainer.visible
}
Cura.SecondaryButton
{
id: createNewProjectButton
anchors.verticalCenter: selectProjectLabel.verticalCenter
anchors.right: parent.right
text: "New Library project"
onClicked:
{
createNewProjectPopup.open()
}
busy: manager.creatingNewProjectStatus == DF.RetrievalStatus.InProgress
}
Item
{
id: noLibraryProjectsContainer
anchors
{
top: parent.top
bottom: parent.bottom
left: parent.left
right: parent.right
}
visible: manager.digitalFactoryProjectModel.count == 0 && (manager.retrievingProjectsStatus == DF.RetrievalStatus.Success || manager.retrievingProjectsStatus == DF.RetrievalStatus.Failed)
Column
{
anchors.centerIn: parent
spacing: UM.Theme.getSize("thin_margin").height
Image
{
id: digitalFactoryImage
anchors.horizontalCenter: parent.horizontalCenter
source: "../images/digital_factory.svg"
fillMode: Image.PreserveAspectFit
width: parent.width - 2 * UM.Theme.getSize("thick_margin").width
sourceSize.width: width
sourceSize.height: height
}
Label
{
id: noLibraryProjectsLabel
anchors.horizontalCenter: parent.horizontalCenter
text: "It appears that you don't have any projects in the Library yet."
font: UM.Theme.getFont("medium")
}
Cura.TertiaryButton
{
id: visitDigitalLibraryButton
anchors.horizontalCenter: parent.horizontalCenter
text: "Visit Digital Library"
onClicked: Qt.openUrlExternally(CuraApplication.ultimakerDigitalFactoryUrl + "/app/library")
}
}
}
Item
{
id: projectListContainer
anchors
{
top: selectProjectLabel.bottom
topMargin: UM.Theme.getSize("default_margin").height
bottom: parent.bottom
left: parent.left
right: parent.right
}
visible: manager.digitalFactoryProjectModel.count > 0
// Use a flickable and a column with a repeater instead of a ListView in a ScrollView, because the ScrollView cannot
// have additional children (aside from the view inside it), which wouldn't allow us to add the LoadMoreProjectsCard
// in it.
Flickable
{
id: flickableView
clip: true
contentWidth: parent.width
contentHeight: projectsListView.implicitHeight
anchors.fill: parent
ScrollBar.vertical: ScrollBar
{
// Vertical ScrollBar, styled similarly to the scrollBar in the settings panel
id: verticalScrollBar
visible: flickableView.contentHeight > flickableView.height
background: Rectangle
{
implicitWidth: UM.Theme.getSize("scrollbar").width
radius: Math.round(implicitWidth / 2)
color: UM.Theme.getColor("scrollbar_background")
}
contentItem: Rectangle
{
id: scrollViewHandle
implicitWidth: UM.Theme.getSize("scrollbar").width
radius: Math.round(implicitWidth / 2)
color: verticalScrollBar.pressed ? UM.Theme.getColor("scrollbar_handle_down") : verticalScrollBar.hovered ? UM.Theme.getColor("scrollbar_handle_hover") : UM.Theme.getColor("scrollbar_handle")
Behavior on color { ColorAnimation { duration: 50; } }
}
}
Column
{
id: projectsListView
width: verticalScrollBar.visible ? parent.width - verticalScrollBar.width - UM.Theme.getSize("default_margin").width : parent.width
anchors.top: parent.top
spacing: UM.Theme.getSize("narrow_margin").width
Repeater
{
model: manager.digitalFactoryProjectModel
delegate: ProjectSummaryCard
{
id: projectSummaryCard
imageSource: model.thumbnailUrl || "../images/placeholder.svg"
projectNameText: model.displayName
projectUsernameText: model.username
projectLastUpdatedText: "Last updated: " + model.lastUpdated
onClicked:
{
manager.selectedProjectIndex = index
}
}
}
LoadMoreProjectsCard
{
id: loadMoreProjectsCard
height: UM.Theme.getSize("toolbox_thumbnail_small").height
width: parent.width
visible: manager.digitalFactoryProjectModel.count > 0
hasMoreProjectsToLoad: manager.hasMoreProjectsToLoad
onClicked:
{
manager.loadMoreProjects()
}
}
}
}
}
CreateNewProjectPopup
{
id: createNewProjectPopup
width: 400 * screenScaleFactor
height: 220 * screenScaleFactor
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
}
}

View file

@ -0,0 +1,74 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from datetime import datetime, timezone
from typing import TypeVar, Dict, List, Any, Type, Union
# Type variable used in the parse methods below, which should be a subclass of BaseModel.
T = TypeVar("T", bound="BaseModel")
class BaseModel:
def __init__(self, **kwargs) -> None:
self.__dict__.update(kwargs)
self.validate()
# Validates the model, raising an exception if the model is invalid.
def validate(self) -> None:
pass
def __eq__(self, other):
"""Checks whether the two models are equal.
:param other: The other model.
:return: True if they are equal, False if they are different.
"""
return type(self) == type(other) and self.toDict() == other.toDict()
def __ne__(self, other) -> bool:
"""Checks whether the two models are different.
:param other: The other model.
:return: True if they are different, False if they are the same.
"""
return type(self) != type(other) or self.toDict() != other.toDict()
def toDict(self) -> Dict[str, Any]:
"""Converts the model into a serializable dictionary"""
return self.__dict__
@staticmethod
def parseModel(model_class: Type[T], values: Union[T, Dict[str, Any]]) -> T:
"""Parses a single model.
:param model_class: The model class.
:param values: The value of the model, which is usually a dictionary, but may also be already parsed.
:return: An instance of the model_class given.
"""
if isinstance(values, dict):
return model_class(**values)
return values
@classmethod
def parseModels(cls, model_class: Type[T], values: List[Union[T, Dict[str, Any]]]) -> List[T]:
"""Parses a list of models.
:param model_class: The model class.
:param values: The value of the list. Each value is usually a dictionary, but may also be already parsed.
:return: A list of instances of the model_class given.
"""
return [cls.parseModel(model_class, value) for value in values]
@staticmethod
def parseDate(date: Union[str, datetime]) -> datetime:
"""Parses the given date string.
:param date: The date to parse.
:return: The parsed date.
"""
if isinstance(date, datetime):
return date
return datetime.strptime(date, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc)

View file

@ -0,0 +1,31 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Dict, Optional, Any
from .BaseModel import BaseModel
class CloudError(BaseModel):
"""Class representing errors generated by the servers, according to the JSON-API standard."""
def __init__(self, id: str, code: str, title: str, http_status: str, detail: Optional[str] = None,
meta: Optional[Dict[str, Any]] = None, **kwargs) -> None:
"""Creates a new error object.
:param id: Unique identifier for this particular occurrence of the problem.
:param title: A short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence
of the problem, except for purposes of localization.
:param code: An application-specific error code, expressed as a string value.
:param detail: A human-readable explanation specific to this occurrence of the problem. Like title, this field's
value can be localized.
:param http_status: The HTTP status code applicable to this problem, converted to string.
:param meta: Non-standard meta-information about the error, depending on the error code.
"""
self.id = id
self.code = code
self.http_status = http_status
self.title = title
self.detail = detail
self.meta = meta
super().__init__(**kwargs)

View file

@ -0,0 +1,361 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import json
import threading
from typing import List, Dict, Any, Callable, Union, Optional
from PyQt5.QtCore import QUrl
from PyQt5.QtGui import QDesktopServices
from PyQt5.QtNetwork import QNetworkReply
from UM.FileHandler.FileHandler import FileHandler
from UM.Logger import Logger
from UM.Message import Message
from UM.Scene.SceneNode import SceneNode
from cura.CuraApplication import CuraApplication
from .DFLibraryFileUploadRequest import DFLibraryFileUploadRequest
from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse
from .DFPrintJobUploadRequest import DFPrintJobUploadRequest
from .DFPrintJobUploadResponse import DFPrintJobUploadResponse
from .DigitalFactoryApiClient import DigitalFactoryApiClient
from .ExportFileJob import ExportFileJob
class DFFileExportAndUploadManager:
"""
Class responsible for exporting the scene and uploading the exported data to the Digital Factory Library. Since 3mf
and UFP files may need to be uploaded at the same time, this class keeps a single progress and success message for
both files and updates those messages according to the progress of both the file job uploads.
"""
def __init__(self, file_handlers: Dict[str, FileHandler],
nodes: List[SceneNode],
library_project_id: str,
library_project_name: str,
file_name: str,
formats: List[str],
on_upload_error: Callable[[], Any],
on_upload_success: Callable[[], Any],
on_upload_finished: Callable[[], Any] ,
on_upload_progress: Callable[[int], Any]) -> None:
self._file_handlers = file_handlers # type: Dict[str, FileHandler]
self._nodes = nodes # type: List[SceneNode]
self._library_project_id = library_project_id # type: str
self._library_project_name = library_project_name # type: str
self._file_name = file_name # type: str
self._formats = formats # type: List[str]
self._api = DigitalFactoryApiClient(application = CuraApplication.getInstance(), on_error = lambda error: Logger.log("e", str(error)))
# Functions of the parent class that should be called based on the upload process output
self._on_upload_error = on_upload_error
self._on_upload_success = on_upload_success
self._on_upload_finished = on_upload_finished
self._on_upload_progress = on_upload_progress
# Lock used for updating the progress message (since the progress is changed by two parallel upload jobs) or
# show the success message (once both upload jobs are done)
self._message_lock = threading.Lock()
self._file_upload_job_metadata = self.initializeFileUploadJobMetadata() # type: Dict[str, Dict[str, Any]]
self.progress_message = Message(
title = "Uploading...",
text = "Uploading files to '{}'".format(self._library_project_name),
progress = -1,
lifetime = 0,
dismissable = False,
use_inactivity_timer = False
)
self._generic_success_message = Message(
text = "Your {} uploaded to '{}'.".format("file was" if len(self._file_upload_job_metadata) <= 1 else "files were", self._library_project_name),
title = "Upload successful",
lifetime = 0,
)
self._generic_success_message.addAction(
"open_df_project",
"Open project",
"open-folder", "Open the project containing the file in Digital Library"
)
self._generic_success_message.actionTriggered.connect(self._onMessageActionTriggered)
def _onCuraProjectFileExported(self, job: ExportFileJob) -> None:
"""Handler for when the DF Library workspace file (3MF) has been created locally.
It can now be sent over the Digital Factory API.
"""
if not job.getOutput():
self._onJobExportError(job.getFileName())
return
self._file_upload_job_metadata[job.getFileName()]["export_job_output"] = job.getOutput()
request = DFLibraryFileUploadRequest(
content_type = job.getMimeType(),
file_name = job.getFileName(),
file_size = len(job.getOutput()),
library_project_id = self._library_project_id
)
self._api.requestUpload3MF(request, on_finished = self._uploadFileData, on_error = self._onRequestUploadCuraProjectFileFailed)
def _onPrintFileExported(self, job: ExportFileJob) -> None:
"""Handler for when the DF Library print job file (UFP) has been created locally.
It can now be sent over the Digital Factory API.
"""
if not job.getOutput():
self._onJobExportError(job.getFileName())
return
self._file_upload_job_metadata[job.getFileName()]["export_job_output"] = job.getOutput()
request = DFPrintJobUploadRequest(
content_type = job.getMimeType(),
job_name = job.getFileName(),
file_size = len(job.getOutput()),
library_project_id = self._library_project_id
)
self._api.requestUploadUFP(request, on_finished = self._uploadFileData, on_error = self._onRequestUploadPrintFileFailed)
def _uploadFileData(self, file_upload_response: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse]) -> None:
"""Uploads the exported file data after the file or print job upload has been registered at the Digital Factory
Library API.
:param file_upload_response: The response received from the Digital Factory Library API.
"""
if isinstance(file_upload_response, DFLibraryFileUploadResponse):
file_name = file_upload_response.file_name
elif isinstance(file_upload_response, DFPrintJobUploadResponse):
file_name = file_upload_response.job_name if file_upload_response.job_name is not None else ""
else:
Logger.log("e", "Wrong response type received. Aborting uploading file to the Digital Library")
return
with self._message_lock:
self.progress_message.show()
self._file_upload_job_metadata[file_name]["file_upload_response"] = file_upload_response
job_output = self._file_upload_job_metadata[file_name]["export_job_output"]
with self._message_lock:
self._file_upload_job_metadata[file_name]["upload_status"] = "uploading"
self._api.uploadExportedFileData(file_upload_response,
job_output,
on_finished = self._onFileUploadFinished,
on_success = self._onUploadSuccess,
on_progress = self._onUploadProgress,
on_error = self._onUploadError)
def _onUploadProgress(self, filename: str, progress: int) -> None:
"""
Updates the progress message according to the total progress of the two files and displays it to the user. It is
made thread-safe with a lock, since the progress can be updated by two separate upload jobs
:param filename: The name of the file for which we have progress (including the extension).
:param progress: The progress percentage
"""
with self._message_lock:
self._file_upload_job_metadata[filename]["upload_progress"] = progress
self._file_upload_job_metadata[filename]["upload_status"] = "uploading"
total_progress = self.getTotalProgress()
self.progress_message.setProgress(total_progress)
self.progress_message.show()
self._on_upload_progress(progress)
def _onUploadSuccess(self, filename: str) -> None:
"""
Sets the upload status to success and the progress of the file with the given filename to 100%. This function is
should be called only if the file has uploaded all of its data successfully (i.e. no error occurred during the
upload process).
:param filename: The name of the file that was uploaded successfully (including the extension).
"""
with self._message_lock:
self._file_upload_job_metadata[filename]["upload_status"] = "success"
self._file_upload_job_metadata[filename]["upload_progress"] = 100
self._on_upload_success()
def _onFileUploadFinished(self, filename: str) -> None:
"""
Callback that makes sure the correct messages are displayed according to the statuses of the individual jobs.
This function is called whenever an upload job has finished, regardless if it had errors or was successful.
Both jobs have to have finished for the messages to show.
:param filename: The name of the file that has finished uploading (including the extension).
"""
with self._message_lock:
# All files have finished their uploading process
if all([(file_upload_job["upload_progress"] == 100 and file_upload_job["upload_status"] != "uploading") for file_upload_job in self._file_upload_job_metadata.values()]):
# Reset and hide the progress message
self.progress_message.setProgress(-1)
self.progress_message.hide()
# All files were successfully uploaded.
if all([(file_upload_job["upload_status"] == "success") for file_upload_job in self._file_upload_job_metadata.values()]):
# Show a single generic success message for all files
self._generic_success_message.show()
else: # One or more files failed to upload.
# Show individual messages for each file, according to their statuses
for filename, upload_job_metadata in self._file_upload_job_metadata.items():
if upload_job_metadata["upload_status"] == "success":
upload_job_metadata["file_upload_success_message"].show()
else:
upload_job_metadata["file_upload_failed_message"].show()
# Call the parent's finished function
self._on_upload_finished()
def _onJobExportError(self, filename: str) -> None:
"""
Displays an appropriate message when the process to export a file fails.
:param filename: The name of the file that failed to be exported (including the extension).
"""
Logger.log("d", "Error while exporting file '{}'".format(filename))
with self._message_lock:
# Set the progress to 100% when the upload job fails, to avoid having the progress message stuck
self._file_upload_job_metadata[filename]["upload_status"] = "failed"
self._file_upload_job_metadata[filename]["upload_progress"] = 100
self._file_upload_job_metadata[filename]["file_upload_failed_message"] = Message(
text = "Failed to export the file '{}'. The upload process is aborted.".format(filename),
title = "Export error",
lifetime = 0
)
self._on_upload_error()
self._onFileUploadFinished(filename)
def _onRequestUploadCuraProjectFileFailed(self, reply: "QNetworkReply", network_error: "QNetworkReply.NetworkError") -> None:
"""
Displays an appropriate message when the request to upload the Cura project file (.3mf) to the Digital Library fails.
This means that something went wrong with the initial request to create a "file" entry in the digital library.
"""
reply_string = bytes(reply.readAll()).decode()
filename_3mf = self._file_name + ".3mf"
Logger.log("d", "An error occurred while uploading the Cura project file '{}' to the Digital Library project '{}': {}".format(filename_3mf, self._library_project_id, reply_string))
with self._message_lock:
# Set the progress to 100% when the upload job fails, to avoid having the progress message stuck
self._file_upload_job_metadata[filename_3mf]["upload_status"] = "failed"
self._file_upload_job_metadata[filename_3mf]["upload_progress"] = 100
human_readable_error = self.extractErrorTitle(reply_string)
self._file_upload_job_metadata[filename_3mf]["file_upload_failed_message"] = Message(
text = "Failed to upload the file '{}' to '{}'. {}".format(filename_3mf, self._library_project_name, human_readable_error),
title = "File upload error",
lifetime = 0
)
self._on_upload_error()
self._onFileUploadFinished(filename_3mf)
def _onRequestUploadPrintFileFailed(self, reply: "QNetworkReply", network_error: "QNetworkReply.NetworkError") -> None:
"""
Displays an appropriate message when the request to upload the print file (.ufp) to the Digital Library fails.
This means that something went wrong with the initial request to create a "file" entry in the digital library.
"""
reply_string = bytes(reply.readAll()).decode()
filename_ufp = self._file_name + ".ufp"
Logger.log("d", "An error occurred while uploading the print job file '{}' to the Digital Library project '{}': {}".format(filename_ufp, self._library_project_id, reply_string))
with self._message_lock:
# Set the progress to 100% when the upload job fails, to avoid having the progress message stuck
self._file_upload_job_metadata[filename_ufp]["upload_status"] = "failed"
self._file_upload_job_metadata[filename_ufp]["upload_progress"] = 100
human_readable_error = self.extractErrorTitle(reply_string)
self._file_upload_job_metadata[filename_ufp]["file_upload_failed_message"] = Message(
title = "File upload error",
text = "Failed to upload the file '{}' to '{}'. {}".format(filename_ufp, self._library_project_name, human_readable_error),
lifetime = 0
)
self._on_upload_error()
self._onFileUploadFinished(filename_ufp)
@staticmethod
def extractErrorTitle(reply_body: Optional[str]) -> str:
error_title = ""
if reply_body:
reply_dict = json.loads(reply_body)
if "errors" in reply_dict and len(reply_dict["errors"]) >= 1 and "title" in reply_dict["errors"][0]:
error_title = reply_dict["errors"][0]["title"]
return error_title
def _onUploadError(self, filename: str, reply: "QNetworkReply", error: "QNetworkReply.NetworkError") -> None:
"""
Displays the given message if uploading the mesh has failed due to a generic error (i.e. lost connection).
If one of the two files fail, this error function will set its progress as finished, to make sure that the
progress message doesn't get stuck.
:param filename: The name of the file that failed to upload (including the extension).
"""
reply_string = bytes(reply.readAll()).decode()
Logger.log("d", "Error while uploading '{}' to the Digital Library project '{}'. Reply: {}".format(filename, self._library_project_id, reply_string))
with self._message_lock:
# Set the progress to 100% when the upload job fails, to avoid having the progress message stuck
self._file_upload_job_metadata[filename]["upload_status"] = "failed"
self._file_upload_job_metadata[filename]["upload_progress"] = 100
human_readable_error = self.extractErrorTitle(reply_string)
self._file_upload_job_metadata[filename]["file_upload_failed_message"] = Message(
title = "File upload error",
text = "Failed to upload the file '{}' to '{}'. {}".format(self._file_name, self._library_project_name, human_readable_error),
lifetime = 0
)
self._on_upload_error()
def getTotalProgress(self) -> int:
"""
Returns the total upload progress of all the upload jobs
:return: The average progress percentage
"""
return int(sum([file_upload_job["upload_progress"] for file_upload_job in self._file_upload_job_metadata.values()]) / len(self._file_upload_job_metadata.values()))
def _onMessageActionTriggered(self, message, action):
if action == "open_df_project":
project_url = "{}/app/library/project/{}?wait_for_new_files=true".format(CuraApplication.getInstance().ultimakerDigitalFactoryUrl, self._library_project_id)
QDesktopServices.openUrl(QUrl(project_url))
message.hide()
def initializeFileUploadJobMetadata(self) -> Dict[str, Any]:
metadata = {}
if "3mf" in self._formats and "3mf" in self._file_handlers and self._file_handlers["3mf"]:
filename_3mf = self._file_name + ".3mf"
metadata[filename_3mf] = {
"export_job_output" : None,
"upload_progress" : -1,
"upload_status" : "",
"file_upload_response": None,
"file_upload_success_message": Message(
text = "'{}' was uploaded to '{}'.".format(filename_3mf, self._library_project_name),
title = "Upload successful",
lifetime = 0,
),
"file_upload_failed_message": Message(
text = "Failed to upload the file '{}' to '{}'.".format(filename_3mf, self._library_project_name),
title = "File upload error",
lifetime = 0
)
}
job_3mf = ExportFileJob(self._file_handlers["3mf"], self._nodes, self._file_name, "3mf")
job_3mf.finished.connect(self._onCuraProjectFileExported)
job_3mf.start()
if "ufp" in self._formats and "ufp" in self._file_handlers and self._file_handlers["ufp"]:
filename_ufp = self._file_name + ".ufp"
metadata[filename_ufp] = {
"export_job_output" : None,
"upload_progress" : -1,
"upload_status" : "",
"file_upload_response": None,
"file_upload_success_message": Message(
text = "'{}' was uploaded to '{}'.".format(filename_ufp, self._library_project_name),
title = "Upload successful",
lifetime = 0,
),
"file_upload_failed_message": Message(
text = "Failed to upload the file '{}' to '{}'.".format(filename_ufp, self._library_project_name),
title = "File upload error",
lifetime = 0
)
}
job_ufp = ExportFileJob(self._file_handlers["ufp"], self._nodes, self._file_name, "ufp")
job_ufp.finished.connect(self._onPrintFileExported)
job_ufp.start()
return metadata

View file

@ -0,0 +1,149 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply
from typing import Callable, Any, cast, Optional, Union
from UM.Logger import Logger
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse
from .DFPrintJobUploadResponse import DFPrintJobUploadResponse
class DFFileUploader:
"""Class responsible for uploading meshes to the the digital factory library in separate requests."""
# The maximum amount of times to retry if the server returns one of the RETRY_HTTP_CODES
MAX_RETRIES = 10
# The HTTP codes that should trigger a retry.
RETRY_HTTP_CODES = {500, 502, 503, 504}
def __init__(self,
http: HttpRequestManager,
df_file: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse],
data: bytes,
on_finished: Callable[[str], Any],
on_success: Callable[[str], Any],
on_progress: Callable[[str, int], Any],
on_error: Callable[[str, "QNetworkReply", "QNetworkReply.NetworkError"], Any]
) -> None:
"""Creates a mesh upload object.
:param http: The network access manager that will handle the HTTP requests.
:param df_file: The file response that was received by the Digital Factory after registering the upload.
:param data: The mesh bytes to be uploaded.
:param on_finished: The method to be called when done.
:param on_success: The method to be called when the upload is successful.
:param on_progress: The method to be called when the progress changes (receives a percentage 0-100).
:param on_error: The method to be called when an error occurs.
"""
self._http = http # type: HttpRequestManager
self._df_file = df_file # type: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse]
self._file_name = ""
if isinstance(self._df_file, DFLibraryFileUploadResponse):
self._file_name = self._df_file.file_name
elif isinstance(self._df_file, DFPrintJobUploadResponse):
if self._df_file.job_name is not None:
self._file_name = self._df_file.job_name
else:
self._file_name = ""
else:
raise TypeError("Incorrect input type")
self._data = data # type: bytes
self._on_finished = on_finished
self._on_success = on_success
self._on_progress = on_progress
self._on_error = on_error
self._retries = 0
self._finished = False
def start(self) -> None:
"""Starts uploading the mesh."""
if self._finished:
# reset state.
self._retries = 0
self._finished = False
self._upload()
def stop(self):
"""Stops uploading the mesh, marking it as finished."""
Logger.log("i", "Finished uploading")
self._finished = True # Signal to any ongoing retries that we should stop retrying.
self._on_finished(self._file_name)
def _upload(self) -> None:
"""
Uploads the file to the Digital Factory Library project
"""
if self._finished:
raise ValueError("The upload is already finished")
if isinstance(self._df_file, DFLibraryFileUploadResponse):
Logger.log("i", "Uploading Cura project file '{file_name}' via link '{upload_url}'".format(file_name = self._df_file.file_name, upload_url = self._df_file.upload_url))
elif isinstance(self._df_file, DFPrintJobUploadResponse):
Logger.log("i", "Uploading Cura print file '{file_name}' via link '{upload_url}'".format(file_name = self._df_file.job_name, upload_url = self._df_file.upload_url))
self._http.put(
url = cast(str, self._df_file.upload_url),
headers_dict = {"Content-Type": cast(str, self._df_file.content_type)},
data = self._data,
callback = self._onUploadFinished,
error_callback = self._onUploadError,
upload_progress_callback = self._onUploadProgressChanged
)
def _onUploadProgressChanged(self, bytes_sent: int, bytes_total: int) -> None:
"""Handles an update to the upload progress
:param bytes_sent: The amount of bytes sent in the current request.
:param bytes_total: The amount of bytes to send in the current request.
"""
Logger.debug("Cloud upload progress %s / %s", bytes_sent, bytes_total)
if bytes_total:
self._on_progress(self._file_name, int(bytes_sent / len(self._data) * 100))
def _onUploadError(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
"""Handles an error uploading."""
body = bytes(reply.peek(reply.bytesAvailable())).decode()
Logger.log("e", "Received error while uploading: %s", body)
self._on_error(self._file_name, reply, error)
self.stop()
def _onUploadFinished(self, reply: QNetworkReply) -> None:
"""
Checks whether a chunk of data was uploaded successfully, starting the next chunk if needed.
"""
Logger.log("i", "Finished callback %s %s",
reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString())
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) # type: Optional[int]
if not status_code:
Logger.log("e", "Reply contained no status code.")
self._onUploadError(reply, None)
return
# check if we should retry the last chunk
if self._retries < self.MAX_RETRIES and status_code in self.RETRY_HTTP_CODES:
self._retries += 1
Logger.log("i", "Retrying %s/%s request %s", self._retries, self.MAX_RETRIES, reply.url().toString())
try:
self._upload()
except ValueError: # Asynchronously it could have completed in the meanwhile.
pass
return
# Http codes that are not to be retried are assumed to be errors.
if status_code > 308:
self._onUploadError(reply, None)
return
Logger.log("d", "status_code: %s, Headers: %s, body: %s", status_code,
[bytes(header).decode() for header in reply.rawHeaderList()], bytes(reply.readAll()).decode())
self._on_success(self._file_name)
self.stop()

View file

@ -0,0 +1,16 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
# Model that represents the request to upload a file to a DF Library project
from .BaseModel import BaseModel
class DFLibraryFileUploadRequest(BaseModel):
def __init__(self, content_type: str, file_name: str, file_size: int, library_project_id: str, **kwargs) -> None:
self.content_type = content_type
self.file_name = file_name
self.file_size = file_size
self.library_project_id = library_project_id
super().__init__(**kwargs)

View file

@ -0,0 +1,49 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from datetime import datetime
from typing import Optional
from .BaseModel import BaseModel
class DFLibraryFileUploadResponse(BaseModel):
"""
Model that represents the response received from the Digital Factory after requesting to upload a file in a Library project
"""
def __init__(self, client_id: str, content_type: str, file_id: str, file_name: str, library_project_id: str,
status: str, uploaded_at: str, user_id: str, username: str, download_url: Optional[str] = None,
file_size: Optional[int] = None, status_description: Optional[str] = None,
upload_url: Optional[str] = None, **kwargs) -> None:
"""
:param client_id: The ID of the OAuth2 client that uploaded this file
:param content_type: The content type of the Digital Library project file
:param file_id: The ID of the library project file
:param file_name: The name of the file
:param library_project_id: The ID of the library project, in which the file will be uploaded
:param status: The status of the Digital Library project file
:param uploaded_at: The time on which the file was uploaded
:param user_id: The ID of the user that uploaded this file
:param username: The user's unique username
:param download_url: A signed URL to download the resulting file. Only available when the job is finished
:param file_size: The size of the uploaded file (in bytes)
:param status_description: Contains more details about the status, e.g. the cause of failures
:param upload_url: The one-time use URL where the file must be uploaded to (only if status is uploading)
:param kwargs: Other keyword arguments that may be included in the response
"""
self.client_id = client_id # type: str
self.content_type = content_type # type: str
self.file_id = file_id # type: str
self.file_name = file_name # type: str
self.library_project_id = library_project_id # type: str
self.status = status # type: str
self.uploaded_at = self.parseDate(uploaded_at) # type: datetime
self.user_id = user_id # type: str
self.username = username # type: str
self.download_url = download_url # type: Optional[str]
self.file_size = file_size # type: Optional[int]
self.status_description = status_description # type: Optional[str]
self.upload_url = upload_url # type: Optional[str]
super().__init__(**kwargs)

View file

@ -0,0 +1,21 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from .BaseModel import BaseModel
# Model that represents the request to upload a print job to the cloud
class DFPrintJobUploadRequest(BaseModel):
def __init__(self, job_name: str, file_size: int, content_type: str, library_project_id: str, **kwargs) -> None:
"""Creates a new print job upload request.
:param job_name: The name of the print job.
:param file_size: The size of the file in bytes.
:param content_type: The content type of the print job (e.g. text/plain or application/gzip)
"""
self.job_name = job_name
self.file_size = file_size
self.content_type = content_type
self.library_project_id = library_project_id
super().__init__(**kwargs)

View file

@ -0,0 +1,35 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional
from .BaseModel import BaseModel
# Model that represents the response received from the cloud after requesting to upload a print job
class DFPrintJobUploadResponse(BaseModel):
def __init__(self, job_id: str, status: str, download_url: Optional[str] = None, job_name: Optional[str] = None,
upload_url: Optional[str] = None, content_type: Optional[str] = None,
status_description: Optional[str] = None, slicing_details: Optional[dict] = None, **kwargs) -> None:
"""Creates a new print job response model.
:param job_id: The job unique ID, e.g. 'kBEeZWEifXbrXviO8mRYLx45P8k5lHVGs43XKvRniPg='.
:param status: The status of the print job.
:param status_description: Contains more details about the status, e.g. the cause of failures.
:param download_url: A signed URL to download the resulting status. Only available when the job is finished.
:param job_name: The name of the print job.
:param slicing_details: Model for slice information.
:param upload_url: The one-time use URL where the toolpath must be uploaded to (only if status is uploading).
:param content_type: The content type of the print job (e.g. text/plain or application/gzip)
:param generated_time: The datetime when the object was generated on the server-side.
"""
self.job_id = job_id
self.status = status
self.download_url = download_url
self.job_name = job_name
self.upload_url = upload_url
self.content_type = content_type
self.status_description = status_description
self.slicing_details = slicing_details
super().__init__(**kwargs)

View file

@ -0,0 +1,319 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import json
from json import JSONDecodeError
import re
from time import time
from typing import List, Any, Optional, Union, Type, Tuple, Dict, cast, TypeVar, Callable
from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
from UM.Logger import Logger
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope
from cura.CuraApplication import CuraApplication
from cura.UltimakerCloud import UltimakerCloudConstants
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
from .DFPrintJobUploadResponse import DFPrintJobUploadResponse
from .BaseModel import BaseModel
from .CloudError import CloudError
from .DFFileUploader import DFFileUploader
from .DFLibraryFileUploadRequest import DFLibraryFileUploadRequest
from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse
from .DFPrintJobUploadRequest import DFPrintJobUploadRequest
from .DigitalFactoryFileResponse import DigitalFactoryFileResponse
from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse
from .PaginationLinks import PaginationLinks
from .PaginationManager import PaginationManager
CloudApiClientModel = TypeVar("CloudApiClientModel", bound=BaseModel)
"""The generic type variable used to document the methods below."""
class DigitalFactoryApiClient:
# The URL to access the digital factory.
ROOT_PATH = UltimakerCloudConstants.CuraCloudAPIRoot
CURA_API_ROOT = "{}/cura/v1".format(ROOT_PATH)
DEFAULT_REQUEST_TIMEOUT = 10 # seconds
# In order to avoid garbage collection we keep the callbacks in this list.
_anti_gc_callbacks = [] # type: List[Callable[[Any], None]]
def __init__(self, application: CuraApplication, on_error: Callable[[List[CloudError]], None], projects_limit_per_page: Optional[int] = None) -> None:
"""Initializes a new digital factory API client.
:param application:
:param on_error: The callback to be called whenever we receive errors from the server.
"""
super().__init__()
self._application = application
self._account = application.getCuraAPI().account
self._scope = JsonDecoratorScope(UltimakerCloudScope(application))
self._http = HttpRequestManager.getInstance()
self._on_error = on_error
self._file_uploader = None # type: Optional[DFFileUploader]
self._projects_pagination_mgr = PaginationManager(limit = projects_limit_per_page) if projects_limit_per_page else None # type: Optional[PaginationManager]
def getProject(self, library_project_id: str, on_finished: Callable[[DigitalFactoryProjectResponse], Any], failed: Callable) -> None:
"""
Retrieves a digital factory project by its library project id.
:param library_project_id: The id of the library project
:param on_finished: The function to be called after the result is parsed.
:param failed: The function to be called if the request fails.
"""
url = "{}/projects/{}".format(self.CURA_API_ROOT, library_project_id)
self._http.get(url,
scope = self._scope,
callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse, failed),
error_callback = failed,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def getProjectsFirstPage(self, on_finished: Callable[[List[DigitalFactoryProjectResponse]], Any], failed: Callable) -> None:
"""
Retrieves digital factory projects for the user that is currently logged in.
If a projects pagination manager exists, then it attempts to get the first page of the paginated projects list,
according to the limit set in the pagination manager. If there is no projects pagination manager, this function
leaves the project limit to the default set on the server side (999999).
:param on_finished: The function to be called after the result is parsed.
:param failed: The function to be called if the request fails.
"""
url = "{}/projects".format(self.CURA_API_ROOT)
if self._projects_pagination_mgr:
self._projects_pagination_mgr.reset() # reset to clear all the links and response metadata
url += "?limit={}".format(self._projects_pagination_mgr.limit)
self._http.get(url,
scope = self._scope,
callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse, failed, pagination_manager = self._projects_pagination_mgr),
error_callback = failed,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def getMoreProjects(self,
on_finished: Callable[[List[DigitalFactoryProjectResponse]], Any],
failed: Callable) -> None:
"""Retrieves the next page of the paginated projects list from the API, provided that there is any.
:param on_finished: The function to be called after the result is parsed.
:param failed: The function to be called if the request fails.
"""
if self.hasMoreProjectsToLoad():
url = cast(PaginationLinks, cast(PaginationManager, self._projects_pagination_mgr).links).next_page
self._http.get(url,
scope = self._scope,
callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse, failed, pagination_manager = self._projects_pagination_mgr),
error_callback = failed,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
else:
Logger.log("d", "There are no more projects to load.")
def hasMoreProjectsToLoad(self) -> bool:
"""
Determines whether the client can get more pages of projects list from the API.
:return: Whether there are more pages in the projects list available to be retrieved from the API.
"""
return self._projects_pagination_mgr is not None and self._projects_pagination_mgr.links is not None and self._projects_pagination_mgr.links.next_page is not None
def getListOfFilesInProject(self, library_project_id: str, on_finished: Callable[[List[DigitalFactoryFileResponse]], Any], failed: Callable) -> None:
"""Retrieves the list of files contained in the project with library_project_id from the Digital Factory Library.
:param library_project_id: The id of the digital factory library project in which the files are included
:param on_finished: The function to be called after the result is parsed.
:param failed: The function to be called if the request fails.
"""
url = "{}/projects/{}/files".format(self.CURA_API_ROOT, library_project_id)
self._http.get(url,
scope = self._scope,
callback = self._parseCallback(on_finished, DigitalFactoryFileResponse, failed),
error_callback = failed,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def _parseCallback(self,
on_finished: Union[Callable[[CloudApiClientModel], Any],
Callable[[List[CloudApiClientModel]], Any]],
model: Type[CloudApiClientModel],
on_error: Optional[Callable] = None,
pagination_manager: Optional[PaginationManager] = None) -> Callable[[QNetworkReply], None]:
"""
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. If a paginated request was made and a pagination
manager is given, the pagination metadata will be held there.
: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.
:param on_error: The callback in case the response is ... less successful.
:param pagination_manager: Holds the pagination links and metadata contained in paginated responses.
If no pagination manager is provided, the pagination metadata is ignored.
"""
def parse(reply: QNetworkReply) -> None:
self._anti_gc_callbacks.remove(parse)
# Don't try to parse the reply if we didn't get one
if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None:
if on_error is not None:
on_error()
return
status_code, response = self._parseReply(reply)
if status_code >= 300 and on_error is not None:
on_error()
else:
self._parseModels(response, on_finished, model, pagination_manager = pagination_manager)
self._anti_gc_callbacks.append(parse)
return parse
@staticmethod
def _parseReply(reply: QNetworkReply) -> Tuple[int, Dict[str, Any]]:
"""Parses the given JSON network reply into a status code and a dictionary, handling unexpected errors as well.
:param reply: The reply from the server.
:return: A tuple with a status code and a dictionary.
"""
status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute)
try:
response = bytes(reply.readAll()).decode()
return status_code, json.loads(response)
except (UnicodeDecodeError, JSONDecodeError, ValueError) as err:
error = CloudError(code = type(err).__name__, title = str(err), http_code = str(status_code),
id = str(time()), http_status = "500")
Logger.logException("e", "Could not parse the stardust response: %s", error.toDict())
return status_code, {"errors": [error.toDict()]}
def _parseModels(self,
response: Dict[str, Any],
on_finished: Union[Callable[[CloudApiClientModel], Any],
Callable[[List[CloudApiClientModel]], Any]],
model_class: Type[CloudApiClientModel],
pagination_manager: Optional[PaginationManager] = None) -> None:
"""Parses the given models and calls the correct callback depending on the result.
:param response: The response from the server, after being converted to a dict.
:param on_finished: The callback in case the response is successful.
:param model_class: The type of the model to convert the response to. It may either be a single record or a list.
:param pagination_manager: Holds the pagination links and metadata contained in paginated responses.
If no pagination manager is provided, the pagination metadata is ignored.
"""
if "data" in response:
data = response["data"]
if "meta" in response and pagination_manager:
pagination_manager.setResponseMeta(response["meta"])
if "links" in response and pagination_manager:
pagination_manager.setLinks(response["links"])
if isinstance(data, list):
results = [model_class(**c) for c in data] # type: List[CloudApiClientModel]
on_finished_list = cast(Callable[[List[CloudApiClientModel]], Any], on_finished)
on_finished_list(results)
else:
result = model_class(**data) # type: CloudApiClientModel
on_finished_item = cast(Callable[[CloudApiClientModel], Any], on_finished)
on_finished_item(result)
elif "errors" in response:
self._on_error([CloudError(**error) for error in response["errors"]])
else:
Logger.log("e", "Cannot find data or errors in the cloud response: %s", response)
def requestUpload3MF(self, request: DFLibraryFileUploadRequest,
on_finished: Callable[[DFLibraryFileUploadResponse], Any],
on_error: Optional[Callable[["QNetworkReply", "QNetworkReply.NetworkError"], None]] = None) -> None:
"""Requests the Digital Factory to register the upload of a file in a library project.
:param request: The request object.
:param on_finished: The function to be called after the result is parsed.
:param on_error: The callback in case the request fails.
"""
url = "{}/files/upload".format(self.CURA_API_ROOT)
data = json.dumps({"data": request.toDict()}).encode()
self._http.put(url,
scope = self._scope,
data = data,
callback = self._parseCallback(on_finished, DFLibraryFileUploadResponse),
error_callback = on_error,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def requestUploadUFP(self, request: DFPrintJobUploadRequest,
on_finished: Callable[[DFPrintJobUploadResponse], Any],
on_error: Optional[Callable[["QNetworkReply", "QNetworkReply.NetworkError"], None]] = None) -> None:
"""Requests the Digital Factory to register the upload of a file in a library project.
:param request: The request object.
:param on_finished: The function to be called after the result is parsed.
:param on_error: The callback in case the request fails.
"""
url = "{}/jobs/upload".format(self.CURA_API_ROOT)
data = json.dumps({"data": request.toDict()}).encode()
self._http.put(url,
scope = self._scope,
data = data,
callback = self._parseCallback(on_finished, DFPrintJobUploadResponse),
error_callback = on_error,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def uploadExportedFileData(self,
df_file_upload_response: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse],
mesh: bytes,
on_finished: Callable[[str], Any],
on_success: Callable[[str], Any],
on_progress: Callable[[str, int], Any],
on_error: Callable[[str, "QNetworkReply", "QNetworkReply.NetworkError"], Any]) -> None:
"""Uploads an exported file (in bytes) to the Digital Factory Library.
:param df_file_upload_response: The response received after requesting an upload with `self.requestUpload`.
:param mesh: The mesh data (in bytes) to be uploaded.
:param on_finished: The function to be called after the upload has finished. Called both after on_success and on_error.
It receives the name of the file that has finished uploading.
:param on_success: The function to be called if the upload was successful.
It receives the name of the file that was uploaded successfully.
:param on_progress: A function to be called during upload progress. It receives a percentage (0-100).
It receives the name of the file for which the upload progress should be updated.
:param on_error: A function to be called if the upload fails.
It receives the name of the file that produced errors during the upload process.
"""
self._file_uploader = DFFileUploader(self._http, df_file_upload_response, mesh, on_finished, on_success, on_progress, on_error)
self._file_uploader.start()
def createNewProject(self, project_name: str, on_finished: Callable[[DigitalFactoryProjectResponse], Any], on_error: Callable) -> None:
""" Create a new project in the Digital Factory.
:param project_name: Name of the new to be created project.
:param on_finished: The function to be called after the result is parsed.
:param on_error: The function to be called if anything goes wrong.
"""
display_name = re.sub(r"[^a-zA-Z0-9- ./™®ö+']", " ", project_name)
Logger.log("i", "Attempt to create new DF project '{}'.".format(display_name))
url = "{}/projects".format(self.CURA_API_ROOT)
data = json.dumps({"data": {"display_name": display_name}}).encode()
self._http.put(url,
scope = self._scope,
data = data,
callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse),
error_callback = on_error,
timeout = self.DEFAULT_REQUEST_TIMEOUT)
def clear(self) -> None:
if self._projects_pagination_mgr is not None:
self._projects_pagination_mgr.reset()

View file

@ -0,0 +1,550 @@
# Copyright (c) 2021 Ultimaker B.V.
import json
import math
import os
import tempfile
import threading
from enum import IntEnum
from pathlib import Path
from typing import Optional, List, Dict, Any, cast
from PyQt5.QtCore import pyqtSignal, QObject, pyqtSlot, pyqtProperty, Q_ENUMS, QUrl
from PyQt5.QtNetwork import QNetworkReply
from PyQt5.QtQml import qmlRegisterType, qmlRegisterUncreatableType
from UM.FileHandler.FileHandler import FileHandler
from UM.Logger import Logger
from UM.Message import Message
from UM.Scene.SceneNode import SceneNode
from UM.Signal import Signal
from UM.TaskManagement.HttpRequestManager import HttpRequestManager
from cura.API import Account
from cura.CuraApplication import CuraApplication
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
from .DFFileExportAndUploadManager import DFFileExportAndUploadManager
from .DigitalFactoryApiClient import DigitalFactoryApiClient
from .DigitalFactoryFileModel import DigitalFactoryFileModel
from .DigitalFactoryFileResponse import DigitalFactoryFileResponse
from .DigitalFactoryProjectModel import DigitalFactoryProjectModel
from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse
class RetrievalStatus(IntEnum):
"""
The status of an http get request.
This is not an enum, because we want to use it in QML and QML doesn't recognize Python enums.
"""
Idle = 0
InProgress = 1
Success = 2
Failed = 3
class DFRetrievalStatus(QObject):
"""
Used as an intermediate QObject that registers the RetrievalStatus as a recognizable enum in QML, so that it can
be used within QML objects as DigitalFactory.RetrievalStatus.<status>
"""
Q_ENUMS(RetrievalStatus)
class DigitalFactoryController(QObject):
DISK_WRITE_BUFFER_SIZE = 256 * 1024 # 256 KB
selectedProjectIndexChanged = pyqtSignal(int, arguments = ["newProjectIndex"])
"""Signal emitted whenever the selected project is changed in the projects dropdown menu"""
selectedFileIndicesChanged = pyqtSignal("QList<int>", arguments = ["newFileIndices"])
"""Signal emitted whenever the selected file is changed in the files table"""
retrievingProjectsStatusChanged = pyqtSignal(int, arguments = ["status"])
"""Signal emitted whenever the status of the 'retrieving projects' http get request is changed"""
retrievingFilesStatusChanged = pyqtSignal(int, arguments = ["status"])
"""Signal emitted whenever the status of the 'retrieving files in project' http get request is changed"""
creatingNewProjectStatusChanged = pyqtSignal(int, arguments = ["status"])
"""Signal emitted whenever the status of the 'create new library project' http get request is changed"""
hasMoreProjectsToLoadChanged = pyqtSignal()
"""Signal emitted whenever the variable hasMoreProjectsToLoad is changed. This variable is used to determine if
the paginated list of projects has more pages to show"""
preselectedProjectChanged = pyqtSignal()
"""Signal emitted whenever a preselected project is set. Whenever there is a preselected project, it means that it is
the only project in the ProjectModel. When the preselected project is invalidated, the ProjectsModel needs to be
retrieved again."""
projectCreationErrorTextChanged = pyqtSignal()
"""Signal emitted whenever the creation of a new project fails and a specific error message is returned from the
server."""
"""Signals to inform about the process of the file upload"""
uploadStarted = Signal()
uploadFileProgress = Signal()
uploadFileSuccess = Signal()
uploadFileError = Signal()
uploadFileFinished = Signal()
def __init__(self, application: CuraApplication) -> None:
super().__init__(parent = None)
self._application = application
self._dialog = None # type: Optional["QObject"]
self.file_handlers = {} # type: Dict[str, FileHandler]
self.nodes = None # type: Optional[List[SceneNode]]
self.file_upload_manager = None # type: Optional[DFFileExportAndUploadManager]
self._has_preselected_project = False # type: bool
self._api = DigitalFactoryApiClient(self._application, on_error = lambda error: Logger.log("e", str(error)), projects_limit_per_page = 20)
# Indicates whether there are more pages of projects that can be loaded from the API
self._has_more_projects_to_load = False
self._account = self._application.getInstance().getCuraAPI().account # type: Account
self._current_workspace_information = CuraApplication.getInstance().getCurrentWorkspaceInformation()
# Initialize the project model
self._project_model = DigitalFactoryProjectModel()
self._selected_project_idx = -1
self._project_creation_error_text = "Something went wrong while creating a new project. Please try again."
# Initialize the file model
self._file_model = DigitalFactoryFileModel()
self._selected_file_indices = [] # type: List[int]
# Filled after the application has been initialized
self._supported_file_types = {} # type: Dict[str, str]
# For cleaning up the files afterwards:
self._erase_temp_files_lock = threading.Lock()
# The statuses which indicate whether Cura is waiting for a response from the DigitalFactory API
self.retrieving_files_status = RetrievalStatus.Idle
self.retrieving_projects_status = RetrievalStatus.Idle
self.creating_new_project_status = RetrievalStatus.Idle
self._application.engineCreatedSignal.connect(self._onEngineCreated)
self._application.initializationFinished.connect(self._applicationInitializationFinished)
def clear(self) -> None:
self._project_model.clearProjects()
self._api.clear()
self._has_preselected_project = False
self.preselectedProjectChanged.emit()
self.setRetrievingFilesStatus(RetrievalStatus.Idle)
self.setRetrievingProjectsStatus(RetrievalStatus.Idle)
self.setCreatingNewProjectStatus(RetrievalStatus.Idle)
self.setSelectedProjectIndex(-1)
def userAccountHasLibraryAccess(self) -> bool:
"""
Checks whether the currently logged in user account has access to the Digital Library
:return: True if the user account has Digital Library access, else False
"""
subscriptions = [] # type: List[Dict[str, Any]]
if self._account.userProfile:
subscriptions = self._account.userProfile.get("subscriptions", [])
return len(subscriptions) > 0
def initialize(self, preselected_project_id: Optional[str] = None) -> None:
self.clear()
if self._account.isLoggedIn and self.userAccountHasLibraryAccess():
self.setRetrievingProjectsStatus(RetrievalStatus.InProgress)
if preselected_project_id:
self._api.getProject(preselected_project_id, on_finished = self.setProjectAsPreselected, failed = self._onGetProjectFailed)
else:
self._api.getProjectsFirstPage(on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed)
def setProjectAsPreselected(self, df_project: DigitalFactoryProjectResponse) -> None:
"""
Sets the received df_project as the preselected one. When a project is preselected, it should be the only
project inside the model, so this function first makes sure to clear the projects model.
:param df_project: The library project intended to be set as preselected
"""
self._project_model.clearProjects()
self._project_model.setProjects([df_project])
self.setSelectedProjectIndex(0)
self.setHasPreselectedProject(True)
self.setRetrievingProjectsStatus(RetrievalStatus.Success)
self.setCreatingNewProjectStatus(RetrievalStatus.Success)
def _onGetProjectFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
reply_string = bytes(reply.readAll()).decode()
self.setHasPreselectedProject(False)
Logger.log("w", "Something went wrong while trying to retrieve a the preselected Digital Library project. Error: {}".format(reply_string))
def _onGetProjectsFirstPageFinished(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
"""
Set the first page of projects received from the digital factory library in the project model. Called whenever
the retrieval of the first page of projects is successful.
:param df_projects: A list of all the Digital Factory Library projects linked to the user's account
"""
self.setHasMoreProjectsToLoad(self._api.hasMoreProjectsToLoad())
self._project_model.setProjects(df_projects)
self.setRetrievingProjectsStatus(RetrievalStatus.Success)
@pyqtSlot()
def loadMoreProjects(self) -> None:
"""
Initiates the process of retrieving the next page of the projects list from the API.
"""
self._api.getMoreProjects(on_finished = self.loadMoreProjectsFinished, failed = self._onGetProjectsFailed)
self.setRetrievingProjectsStatus(RetrievalStatus.InProgress)
def loadMoreProjectsFinished(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
"""
Set the projects received from the digital factory library in the project model. Called whenever the retrieval
of the projects is successful.
:param df_projects: A list of all the Digital Factory Library projects linked to the user's account
"""
self.setHasMoreProjectsToLoad(self._api.hasMoreProjectsToLoad())
self._project_model.extendProjects(df_projects)
self.setRetrievingProjectsStatus(RetrievalStatus.Success)
def _onGetProjectsFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
"""
Error function, called whenever the retrieval of projects fails.
"""
self.setRetrievingProjectsStatus(RetrievalStatus.Failed)
Logger.log("w", "Failed to retrieve the list of projects from the Digital Library. Error encountered: {}".format(error))
def getProjectFilesFinished(self, df_files_in_project: List[DigitalFactoryFileResponse]) -> None:
"""
Set the files received from the digital factory library in the file model. The files are filtered to only
contain the files which can be opened by Cura.
Called whenever the retrieval of the files is successful.
:param df_files_in_project: A list of all the Digital Factory Library files that exist in a library project
"""
# Filter to show only the files that can be opened in Cura
self._file_model.setFilters({"file_name": lambda x: Path(x).suffix[1:].lower() in self._supported_file_types}) # the suffix is in format '.xyz', so omit the dot at the start
self._file_model.setFiles(df_files_in_project)
self.setRetrievingFilesStatus(RetrievalStatus.Success)
def getProjectFilesFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
"""
Error function, called whenever the retrieval of the files in a library project fails.
"""
Logger.log("w", "Failed to retrieve the list of files in project '{}' from the Digital Library".format(self._project_model._projects[self._selected_project_idx]))
self.setRetrievingFilesStatus(RetrievalStatus.Failed)
@pyqtSlot()
def clearProjectSelection(self) -> None:
"""
Clear the selected project.
"""
if self._has_preselected_project:
self.setHasPreselectedProject(False)
else:
self.setSelectedProjectIndex(-1)
@pyqtSlot(int)
def setSelectedProjectIndex(self, project_idx: int) -> None:
"""
Sets the index of the project which is currently selected in the dropdown menu. Then, it uses the project_id of
that project to retrieve the list of files included in that project and display it in the interface.
:param project_idx: The index of the currently selected project
"""
if project_idx < -1 or project_idx >= len(self._project_model.items):
Logger.log("w", "The selected project index is invalid.")
project_idx = -1 # -1 is a valid index for the combobox and it is handled as "nothing is selected"
self._selected_project_idx = project_idx
self.selectedProjectIndexChanged.emit(project_idx)
# Clear the files from the previously-selected project and refresh the files model with the newly-selected-
# project's files
self._file_model.clearFiles()
self.selectedFileIndicesChanged.emit([])
if 0 <= project_idx < len(self._project_model.items):
library_project_id = self._project_model.items[project_idx]["libraryProjectId"]
self.setRetrievingFilesStatus(RetrievalStatus.InProgress)
self._api.getListOfFilesInProject(library_project_id, on_finished = self.getProjectFilesFinished, failed = self.getProjectFilesFailed)
@pyqtProperty(int, fset = setSelectedProjectIndex, notify = selectedProjectIndexChanged)
def selectedProjectIndex(self) -> int:
return self._selected_project_idx
@pyqtSlot("QList<int>")
def setSelectedFileIndices(self, file_indices: List[int]) -> None:
"""
Sets the index of the file which is currently selected in the list of files.
:param file_indices: The index of the currently selected file
"""
if file_indices != self._selected_file_indices:
self._selected_file_indices = file_indices
self.selectedFileIndicesChanged.emit(file_indices)
@pyqtProperty(QObject, constant = True)
def digitalFactoryProjectModel(self) -> "DigitalFactoryProjectModel":
return self._project_model
@pyqtProperty(QObject, constant = True)
def digitalFactoryFileModel(self) -> "DigitalFactoryFileModel":
return self._file_model
def setHasMoreProjectsToLoad(self, has_more_projects_to_load: bool) -> None:
"""
Set the value that indicates whether there are more pages of projects that can be loaded from the API
:param has_more_projects_to_load: Whether there are more pages of projects
"""
if has_more_projects_to_load != self._has_more_projects_to_load:
self._has_more_projects_to_load = has_more_projects_to_load
self.hasMoreProjectsToLoadChanged.emit()
@pyqtProperty(bool, fset = setHasMoreProjectsToLoad, notify = hasMoreProjectsToLoadChanged)
def hasMoreProjectsToLoad(self) -> bool:
"""
:return: whether there are more pages for projects that can be loaded from the API
"""
return self._has_more_projects_to_load
@pyqtSlot(str)
def createLibraryProjectAndSetAsPreselected(self, project_name: Optional[str]) -> None:
"""
Creates a new project with the given name in the Digital Library.
:param project_name: The name that will be used for the new project
"""
if project_name:
self._api.createNewProject(project_name, self.setProjectAsPreselected, self._createNewLibraryProjectFailed)
self.setCreatingNewProjectStatus(RetrievalStatus.InProgress)
else:
Logger.log("w", "No project name provided while attempting to create a new project. Aborting the project creation.")
def _createNewLibraryProjectFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
reply_string = bytes(reply.readAll()).decode()
self._project_creation_error_text = "Something went wrong while creating the new project. Please try again."
if reply_string:
reply_dict = json.loads(reply_string)
if "errors" in reply_dict and len(reply_dict["errors"]) >= 1 and "title" in reply_dict["errors"][0]:
self._project_creation_error_text = "Error while creating the new project: {}".format(reply_dict["errors"][0]["title"])
self.projectCreationErrorTextChanged.emit()
self.setCreatingNewProjectStatus(RetrievalStatus.Failed)
Logger.log("e", "Something went wrong while trying to create a new a project. Error: {}".format(reply_string))
def setRetrievingProjectsStatus(self, new_status: RetrievalStatus) -> None:
"""
Sets the status of the "retrieving library projects" http call.
:param new_status: The new status
"""
self.retrieving_projects_status = new_status
self.retrievingProjectsStatusChanged.emit(int(new_status))
@pyqtProperty(int, fset = setRetrievingProjectsStatus, notify = retrievingProjectsStatusChanged)
def retrievingProjectsStatus(self) -> int:
return int(self.retrieving_projects_status)
def setRetrievingFilesStatus(self, new_status: RetrievalStatus) -> None:
"""
Sets the status of the "retrieving files list in the selected library project" http call.
:param new_status: The new status
"""
self.retrieving_files_status = new_status
self.retrievingFilesStatusChanged.emit(int(new_status))
@pyqtProperty(int, fset = setRetrievingFilesStatus, notify = retrievingFilesStatusChanged)
def retrievingFilesStatus(self) -> int:
return int(self.retrieving_files_status)
def setCreatingNewProjectStatus(self, new_status: RetrievalStatus) -> None:
"""
Sets the status of the "creating new library project" http call.
:param new_status: The new status
"""
self.creating_new_project_status = new_status
self.creatingNewProjectStatusChanged.emit(int(new_status))
@pyqtProperty(int, fset = setCreatingNewProjectStatus, notify = creatingNewProjectStatusChanged)
def creatingNewProjectStatus(self) -> int:
return int(self.creating_new_project_status)
@staticmethod
def _onEngineCreated() -> None:
qmlRegisterUncreatableType(DFRetrievalStatus, "DigitalFactory", 1, 0, "RetrievalStatus", "Could not create RetrievalStatus enum type")
def _applicationInitializationFinished(self) -> None:
self._supported_file_types = self._application.getInstance().getMeshFileHandler().getSupportedFileTypesRead()
@pyqtSlot()
def openSelectedFiles(self) -> None:
""" Downloads, then opens all files selected in the Qt frontend open dialog.
"""
temp_dir = tempfile.mkdtemp()
if temp_dir is None or temp_dir == "":
Logger.error("Digital Library: Couldn't create temporary directory to store to-be downloaded files.")
return
if self._selected_project_idx < 0 or len(self._selected_file_indices) < 1:
Logger.error("Digital Library: No project or no file selected on open action.")
return
to_erase_on_done_set = {
os.path.join(temp_dir, self._file_model.getItem(i)["fileName"]).replace('\\', '/')
for i in self._selected_file_indices}
def onLoadedCallback(filename_done: str) -> None:
filename_done = os.path.join(temp_dir, filename_done).replace('\\', '/')
with self._erase_temp_files_lock:
if filename_done in to_erase_on_done_set:
try:
os.remove(filename_done)
to_erase_on_done_set.remove(filename_done)
if len(to_erase_on_done_set) < 1 and os.path.exists(temp_dir):
os.rmdir(temp_dir)
except (IOError, OSError) as ex:
Logger.error("Can't erase temporary (in) {0} because {1}.", temp_dir, str(ex))
# Save the project id to make sure it will be preselected the next time the user opens the save dialog
CuraApplication.getInstance().getCurrentWorkspaceInformation().setEntryToStore("digital_factory", "library_project_id", library_project_id)
# Disconnect the signals so that they are not fired every time another (project) file is loaded
app.fileLoaded.disconnect(onLoadedCallback)
app.workspaceLoaded.disconnect(onLoadedCallback)
app = CuraApplication.getInstance()
app.fileLoaded.connect(onLoadedCallback) # fired when non-project files are loaded
app.workspaceLoaded.connect(onLoadedCallback) # fired when project files are loaded
project_name = self._project_model.getItem(self._selected_project_idx)["displayName"]
for file_index in self._selected_file_indices:
file_item = self._file_model.getItem(file_index)
file_name = file_item["fileName"]
download_url = file_item["downloadUrl"]
library_project_id = file_item["libraryProjectId"]
self._openSelectedFile(temp_dir, project_name, file_name, download_url)
def _openSelectedFile(self, temp_dir: str, project_name: str, file_name: str, download_url: str) -> None:
""" Downloads, then opens, the single specified file.
:param temp_dir: The already created temporary directory where the files will be stored.
:param project_name: Name of the project the file belongs to (used for error reporting).
:param file_name: Name of the file to be downloaded and opened (used for error reporting).
:param download_url: This url will be downloaded, then the downloaded file will be opened in Cura.
"""
if not download_url:
Logger.log("e", "No download url for file '{}'".format(file_name))
return
progress_message = Message(text = "{0}/{1}".format(project_name, file_name), dismissable = False, lifetime = 0,
progress = 0, title = "Downloading...")
progress_message.setProgress(0)
progress_message.show()
def progressCallback(rx: int, rt: int) -> None:
progress_message.setProgress(math.floor(rx * 100.0 / rt))
def finishedCallback(reply: QNetworkReply) -> None:
progress_message.hide()
try:
with open(os.path.join(temp_dir, file_name), "wb+") as temp_file:
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
while bytes_read:
temp_file.write(bytes_read)
bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
CuraApplication.getInstance().processEvents()
temp_file_name = temp_file.name
except IOError as ex:
Logger.logException("e", "Can't write Digital Library file {0}/{1} download to temp-directory {2}.",
ex, project_name, file_name, temp_dir)
Message(
text = "Failed to write to temporary file for '{}'.".format(file_name),
title = "File-system error",
lifetime = 10
).show()
return
CuraApplication.getInstance().readLocalFile(
QUrl.fromLocalFile(temp_file_name), add_to_recent_files = False)
def errorCallback(reply: QNetworkReply, error: QNetworkReply.NetworkError, p = project_name,
f = file_name) -> None:
progress_message.hide()
Logger.error("An error {0} {1} occurred while downloading {2}/{3}".format(str(error), str(reply), p, f))
Message(
text = "Failed Digital Library download for '{}'.".format(f),
title = "Network error {}".format(error),
lifetime = 10
).show()
download_manager = HttpRequestManager.getInstance()
download_manager.get(download_url, callback = finishedCallback, download_progress_callback = progressCallback,
error_callback = errorCallback, scope = UltimakerCloudScope(CuraApplication.getInstance()))
def setHasPreselectedProject(self, new_has_preselected_project: bool) -> None:
if not new_has_preselected_project:
# The preselected project was the only one in the model, at index 0, so when we set the has_preselected_project to
# false, we also need to clean it from the projects model
self._project_model.clearProjects()
self.setSelectedProjectIndex(-1)
self._api.getProjectsFirstPage(on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed)
self.setRetrievingProjectsStatus(RetrievalStatus.InProgress)
self._has_preselected_project = new_has_preselected_project
self.preselectedProjectChanged.emit()
@pyqtProperty(bool, fset = setHasPreselectedProject, notify = preselectedProjectChanged)
def hasPreselectedProject(self) -> bool:
return self._has_preselected_project
@pyqtSlot(str, "QStringList")
def saveFileToSelectedProject(self, filename: str, formats: List[str]) -> None:
"""
Function triggered whenever the Save button is pressed.
:param filename: The name (without the extension) that will be used for the files
:param formats: List of the formats the scene will be exported to. Can include 3mf, ufp, or both
"""
if self._selected_project_idx == -1:
Logger.log("e", "No DF Library project is selected.")
return
if filename == "":
Logger.log("w", "The file name cannot be empty.")
Message(text = "Cannot upload file with an empty name to the Digital Library", title = "Empty file name provided", lifetime = 0).show()
return
self._saveFileToSelectedProjectHelper(filename, formats)
def _saveFileToSelectedProjectHelper(self, filename: str, formats: List[str]) -> None:
# Indicate we have started sending a job.
self.uploadStarted.emit()
library_project_id = self._project_model.items[self._selected_project_idx]["libraryProjectId"]
library_project_name = self._project_model.items[self._selected_project_idx]["displayName"]
# Use the file upload manager to export and upload the 3mf and/or ufp files to the DF Library project
self.file_upload_manager = DFFileExportAndUploadManager(file_handlers = self.file_handlers, nodes = cast(List[SceneNode], self.nodes),
library_project_id = library_project_id,
library_project_name = library_project_name,
file_name = filename, formats = formats,
on_upload_error = self.uploadFileError.emit,
on_upload_success = self.uploadFileSuccess.emit,
on_upload_finished = self.uploadFileFinished.emit,
on_upload_progress = self.uploadFileProgress.emit)
# Save the project id to make sure it will be preselected the next time the user opens the save dialog
self._current_workspace_information.setEntryToStore("digital_factory", "library_project_id", library_project_id)
@pyqtProperty(str, notify = projectCreationErrorTextChanged)
def projectCreationErrorText(self) -> str:
return self._project_creation_error_text

View file

@ -0,0 +1,115 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import List, Dict, Callable
from PyQt5.QtCore import Qt, pyqtSignal
from UM.Logger import Logger
from UM.Qt.ListModel import ListModel
from .DigitalFactoryFileResponse import DigitalFactoryFileResponse
DIGITAL_FACTORY_DISPLAY_DATETIME_FORMAT = "%d-%m-%Y %H:%M"
class DigitalFactoryFileModel(ListModel):
FileNameRole = Qt.UserRole + 1
FileIdRole = Qt.UserRole + 2
FileSizeRole = Qt.UserRole + 3
LibraryProjectIdRole = Qt.UserRole + 4
DownloadUrlRole = Qt.UserRole + 5
UsernameRole = Qt.UserRole + 6
UploadedAtRole = Qt.UserRole + 7
dfFileModelChanged = pyqtSignal()
def __init__(self, parent = None):
super().__init__(parent)
self.addRoleName(self.FileNameRole, "fileName")
self.addRoleName(self.FileIdRole, "fileId")
self.addRoleName(self.FileSizeRole, "fileSize")
self.addRoleName(self.LibraryProjectIdRole, "libraryProjectId")
self.addRoleName(self.DownloadUrlRole, "downloadUrl")
self.addRoleName(self.UsernameRole, "username")
self.addRoleName(self.UploadedAtRole, "uploadedAt")
self._files = [] # type: List[DigitalFactoryFileResponse]
self._filters = {} # type: Dict[str, Callable]
def setFiles(self, df_files_in_project: List[DigitalFactoryFileResponse]) -> None:
if self._files == df_files_in_project:
return
self._files = df_files_in_project
self._update()
def clearFiles(self) -> None:
self.clear()
self._files.clear()
self.dfFileModelChanged.emit()
def _update(self) -> None:
filtered_files_list = self.getFilteredFilesList()
for file in filtered_files_list:
self.appendItem({
"fileName" : file.file_name,
"fileId" : file.file_id,
"fileSize": file.file_size,
"libraryProjectId": file.library_project_id,
"downloadUrl": file.download_url,
"username": file.username,
"uploadedAt": file.uploaded_at.strftime(DIGITAL_FACTORY_DISPLAY_DATETIME_FORMAT)
})
self.dfFileModelChanged.emit()
def setFilters(self, filters: Dict[str, Callable]) -> None:
"""
Sets the filters and updates the files model to contain only the files that meet all of the filters.
:param filters: The filters to be applied
example:
{
"attribute_name1": function_to_be_applied_on_DigitalFactoryFileResponse_attribute1,
"attribute_name2": function_to_be_applied_on_DigitalFactoryFileResponse_attribute2
}
"""
self.clear()
self._filters = filters
self._update()
def clearFilters(self) -> None:
"""
Clears all the model filters
"""
self.setFilters({})
def getFilteredFilesList(self) -> List[DigitalFactoryFileResponse]:
"""
Lists the files that meet all the filters specified in the self._filters. This is achieved by applying each
filter function on the corresponding attribute for all the filters in the self._filters. If all of them are
true, the file is added to the filtered files list.
In order for this to work, the self._filters should be in the format:
{
"attribute_name": function_to_be_applied_on_the_DigitalFactoryFileResponse_attribute
}
:return: The list of files that meet all the specified filters
"""
if not self._filters:
return self._files
filtered_files_list = []
for file in self._files:
filter_results = []
for attribute, filter_func in self._filters.items():
try:
filter_results.append(filter_func(getattr(file, attribute)))
except AttributeError:
Logger.log("w", "Attribute '{}' doesn't exist in objects of type '{}'".format(attribute, type(file)))
all_filters_met = all(filter_results)
if all_filters_met:
filtered_files_list.append(file)
return filtered_files_list

View file

@ -0,0 +1,62 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import os
from UM.FileProvider import FileProvider
from UM.Logger import Logger
from cura.API import Account
from cura.CuraApplication import CuraApplication
from .DigitalFactoryController import DigitalFactoryController
class DigitalFactoryFileProvider(FileProvider):
def __init__(self, df_controller: DigitalFactoryController) -> None:
super().__init__()
self._controller = df_controller
self.menu_item_display_text = "From Digital Library"
self.shortcut = "Ctrl+Shift+O"
plugin_path = os.path.dirname(os.path.dirname(__file__))
self._dialog_path = os.path.join(plugin_path, "resources", "qml", "DigitalFactoryOpenDialog.qml")
self._dialog = None
self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account
self._account.loginStateChanged.connect(self._onLoginStateChanged)
self.enabled = self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess()
self.priority = 10
def run(self) -> None:
"""
Function called every time the 'From Digital Factory' option of the 'Open File(s)' submenu is triggered
"""
self.loadWindow()
if self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess():
self._controller.initialize()
if not self._dialog:
Logger.log("e", "Unable to create the Digital Library Open dialog.")
return
self._dialog.show()
def loadWindow(self) -> None:
"""
Create the GUI window for the Digital Library Open dialog. If the window is already open, bring the focus on it.
"""
if self._dialog: # Dialogue is already open.
self._dialog.requestActivate() # Bring the focus on the dialogue.
return
self._dialog = CuraApplication.getInstance().createQmlComponent(self._dialog_path, {"manager": self._controller})
if not self._dialog:
Logger.log("e", "Unable to create the Digital Library Open dialog.")
def _onLoginStateChanged(self, logged_in: bool) -> None:
"""
Sets the enabled status of the DigitalFactoryFileProvider according to the account's login status
:param logged_in: The new login status
"""
self.enabled = logged_in and self._controller.userAccountHasLibraryAccess()
self.enabledChanged.emit()

View file

@ -0,0 +1,57 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from datetime import datetime
from typing import Optional
from .BaseModel import BaseModel
DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
class DigitalFactoryFileResponse(BaseModel):
"""Class representing a file in a digital factory project."""
def __init__(self, client_id: str, content_type: str, file_id: str, file_name: str, library_project_id: str,
status: str, user_id: str, username: str, uploaded_at: str, download_url: Optional[str] = "", status_description: Optional[str] = "",
file_size: Optional[int] = 0, upload_url: Optional[str] = "", **kwargs) -> None:
"""
Creates a new DF file response object
:param client_id:
:param content_type:
:param file_id:
:param file_name:
:param library_project_id:
:param status:
:param user_id:
:param username:
:param download_url:
:param status_description:
:param file_size:
:param upload_url:
:param kwargs:
"""
self.client_id = client_id
self.content_type = content_type
self.download_url = download_url
self.file_id = file_id
self.file_name = file_name
self.file_size = file_size
self.library_project_id = library_project_id
self.status = status
self.status_description = status_description
self.upload_url = upload_url
self.user_id = user_id
self.username = username
self.uploaded_at = datetime.strptime(uploaded_at, DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT)
super().__init__(**kwargs)
def __repr__(self) -> str:
return "File: {}, from: {}, File ID: {}, Project ID: {}, Download URL: {}".format(self.file_name, self.username, self.file_id, self.library_project_id, self.download_url)
# Validates the model, raising an exception if the model is invalid.
def validate(self) -> None:
super().validate()
if not self.file_id:
raise ValueError("file_id is required in Digital Library file")

View file

@ -0,0 +1,118 @@
# Copyright (c) 2021 Ultimaker B.V.
# Uranium is released under the terms of the LGPLv3 or higher.
import os
from typing import Optional, List
from UM.FileHandler.FileHandler import FileHandler
from UM.Logger import Logger
from UM.OutputDevice import OutputDeviceError
from UM.OutputDevice.ProjectOutputDevice import ProjectOutputDevice
from UM.Scene.SceneNode import SceneNode
from cura.API import Account
from cura.CuraApplication import CuraApplication
from .DigitalFactoryController import DigitalFactoryController
class DigitalFactoryOutputDevice(ProjectOutputDevice):
"""Implements an OutputDevice that supports saving to the digital factory library."""
def __init__(self, plugin_id, df_controller: DigitalFactoryController, add_to_output_devices: bool = False, parent = None) -> None:
super().__init__(device_id = "digital_factory", add_to_output_devices = add_to_output_devices, parent = parent)
self.setName("Digital Library") # Doesn't need to be translated
self.setShortDescription("Save to Library")
self.setDescription("Save to Library")
self.setIconName("save")
self.menu_entry_text = "To Digital Library"
self.shortcut = "Ctrl+Shift+S"
self._plugin_id = plugin_id
self._controller = df_controller
plugin_path = os.path.dirname(os.path.dirname(__file__))
self._dialog_path = os.path.join(plugin_path, "resources", "qml", "DigitalFactorySaveDialog.qml")
self._dialog = None
# Connect the write signals
self._controller.uploadStarted.connect(self._onWriteStarted)
self._controller.uploadFileProgress.connect(self.writeProgress.emit)
self._controller.uploadFileError.connect(self._onWriteError)
self._controller.uploadFileSuccess.connect(self.writeSuccess.emit)
self._controller.uploadFileFinished.connect(self._onWriteFinished)
self._priority = -1 # Negative value to ensure that it will have less priority than the LocalFileOutputDevice (which has 0)
self._application = CuraApplication.getInstance()
self._writing = False
self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account
self._account.loginStateChanged.connect(self._onLoginStateChanged)
self.enabled = self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess()
self._current_workspace_information = CuraApplication.getInstance().getCurrentWorkspaceInformation()
def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, file_handler: Optional[FileHandler] = None, **kwargs) -> None:
"""Request the specified nodes to be written.
Function called every time the 'To Digital Factory' option of the 'Save Project' submenu is triggered or when the
"Save to Library" action button is pressed (upon slicing).
:param nodes: A collection of scene nodes that should be written to the file.
:param file_name: A suggestion for the file name to write to.
:param limit_mimetypes: Limit the possible mimetypes to use for writing to these types.
:param file_handler: The handler responsible for reading and writing mesh files.
:param kwargs: Keyword arguments.
"""
if self._writing:
raise OutputDeviceError.DeviceBusyError()
self.loadWindow()
if self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess():
self._controller.nodes = nodes
df_workspace_information = self._current_workspace_information.getPluginMetadata("digital_factory")
self._controller.initialize(preselected_project_id = df_workspace_information.get("library_project_id"))
if not self._dialog:
Logger.log("e", "Unable to create the Digital Library Save dialog.")
return
self._dialog.show()
def loadWindow(self) -> None:
"""
Create the GUI window for the Digital Library Save dialog. If the window is already open, bring the focus on it.
"""
if self._dialog: # Dialogue is already open.
self._dialog.requestActivate() # Bring the focus on the dialogue.
return
if not self._controller.file_handlers:
self._controller.file_handlers = {
"3mf": CuraApplication.getInstance().getWorkspaceFileHandler(),
"ufp": CuraApplication.getInstance().getMeshFileHandler()
}
self._dialog = CuraApplication.getInstance().createQmlComponent(self._dialog_path, {"manager": self._controller})
if not self._dialog:
Logger.log("e", "Unable to create the Digital Library Save dialog.")
def _onLoginStateChanged(self, logged_in: bool) -> None:
"""
Sets the enabled status of the DigitalFactoryOutputDevice according to the account's login status
:param logged_in: The new login status
"""
self.enabled = logged_in and self._controller.userAccountHasLibraryAccess()
self.enabledChanged.emit()
def _onWriteStarted(self) -> None:
self._writing = True
self.writeStarted.emit(self)
def _onWriteFinished(self) -> None:
self._writing = False
self.writeFinished.emit(self)
def _onWriteError(self) -> None:
self._writing = False
self.writeError.emit(self)

View file

@ -0,0 +1,18 @@
# Copyright (c) 2021 Ultimaker B.V.
# Uranium is released under the terms of the LGPLv3 or higher.
from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin
from .DigitalFactoryOutputDevice import DigitalFactoryOutputDevice
from .DigitalFactoryController import DigitalFactoryController
class DigitalFactoryOutputDevicePlugin(OutputDevicePlugin):
def __init__(self, df_controller: DigitalFactoryController) -> None:
super().__init__()
self.df_controller = df_controller
def start(self) -> None:
self.getOutputDeviceManager().addProjectOutputDevice(DigitalFactoryOutputDevice(plugin_id = self.getPluginId(), df_controller = self.df_controller, add_to_output_devices = True))
def stop(self) -> None:
self.getOutputDeviceManager().removeProjectOutputDevice("digital_factory")

View file

@ -0,0 +1,64 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from typing import List, Optional
from PyQt5.QtCore import Qt, pyqtSignal
from UM.Logger import Logger
from UM.Qt.ListModel import ListModel
from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse
PROJECT_UPDATED_AT_DATETIME_FORMAT = "%d-%m-%Y"
class DigitalFactoryProjectModel(ListModel):
DisplayNameRole = Qt.UserRole + 1
LibraryProjectIdRole = Qt.UserRole + 2
DescriptionRole = Qt.UserRole + 3
ThumbnailUrlRole = Qt.UserRole + 5
UsernameRole = Qt.UserRole + 6
LastUpdatedRole = Qt.UserRole + 7
dfProjectModelChanged = pyqtSignal()
def __init__(self, parent = None):
super().__init__(parent)
self.addRoleName(self.DisplayNameRole, "displayName")
self.addRoleName(self.LibraryProjectIdRole, "libraryProjectId")
self.addRoleName(self.DescriptionRole, "description")
self.addRoleName(self.ThumbnailUrlRole, "thumbnailUrl")
self.addRoleName(self.UsernameRole, "username")
self.addRoleName(self.LastUpdatedRole, "lastUpdated")
self._projects = [] # type: List[DigitalFactoryProjectResponse]
def setProjects(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
if self._projects == df_projects:
return
self._items.clear()
self._projects = df_projects
# self.sortProjectsBy("display_name")
self._update(df_projects)
def extendProjects(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
if not df_projects:
return
self._projects.extend(df_projects)
# self.sortProjectsBy("display_name")
self._update(df_projects)
def clearProjects(self) -> None:
self.clear()
self._projects.clear()
self.dfProjectModelChanged.emit()
def _update(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
for project in df_projects:
self.appendItem({
"displayName" : project.display_name,
"libraryProjectId" : project.library_project_id,
"description": project.description,
"thumbnailUrl": project.thumbnail_url,
"username": project.username,
"lastUpdated": project.last_updated.strftime(PROJECT_UPDATED_AT_DATETIME_FORMAT) if project.last_updated else "",
})
self.dfProjectModelChanged.emit()

View file

@ -0,0 +1,65 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
from datetime import datetime
from typing import Optional, List, Dict, Any
from .BaseModel import BaseModel
from .DigitalFactoryFileResponse import DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT
class DigitalFactoryProjectResponse(BaseModel):
"""Class representing a cloud project."""
def __init__(self,
library_project_id: str,
display_name: str,
username: str,
organization_shared: bool,
last_updated: Optional[str] = None,
created_at: Optional[str] = None,
thumbnail_url: Optional[str] = None,
organization_id: Optional[str] = None,
created_by_user_id: Optional[str] = None,
description: Optional[str] = "",
tags: Optional[List[str]] = None,
team_ids: Optional[List[str]] = None,
status: Optional[str] = None,
technical_requirements: Optional[Dict[str, Any]] = None,
**kwargs) -> None:
"""
Creates a new digital factory project response object
:param library_project_id:
:param display_name:
:param username:
:param organization_shared:
:param thumbnail_url:
:param created_by_user_id:
:param description:
:param tags:
:param kwargs:
"""
self.library_project_id = library_project_id
self.display_name = display_name
self.description = description
self.username = username
self.organization_shared = organization_shared
self.organization_id = organization_id
self.created_by_user_id = created_by_user_id
self.thumbnail_url = thumbnail_url
self.tags = tags
self.team_ids = team_ids
self.created_at = datetime.strptime(created_at, DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT) if created_at else None
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
super().__init__(**kwargs)
def __str__(self) -> str:
return "Project: {}, Id: {}, from: {}".format(self.display_name, self.library_project_id, self.username)
# Validates the model, raising an exception if the model is invalid.
def validate(self) -> None:
super().validate()
if not self.library_project_id:
raise ValueError("library_project_id is required on cloud project")

View file

@ -0,0 +1,55 @@
# Copyright (c) 2021 Ultimaker B.V.
# Cura is released under the terms of the LGPLv3 or higher.
import io
from typing import List, Optional, Union
from UM.FileHandler.FileHandler import FileHandler
from UM.FileHandler.FileWriter import FileWriter
from UM.FileHandler.WriteFileJob import WriteFileJob
from UM.Logger import Logger
from UM.MimeTypeDatabase import MimeTypeDatabase
from UM.OutputDevice import OutputDeviceError
from UM.Scene.SceneNode import SceneNode
class ExportFileJob(WriteFileJob):
"""Job that exports the build plate to the correct file format for the Digital Factory Library project."""
def __init__(self, file_handler: FileHandler, nodes: List[SceneNode], job_name: str, extension: str) -> None:
file_types = file_handler.getSupportedFileTypesWrite()
if len(file_types) == 0:
Logger.log("e", "There are no file types available to write with!")
raise OutputDeviceError.WriteRequestFailedError("There are no file types available to write with!")
mode = None
file_writer = None
for file_type in file_types:
if file_type["extension"] == extension:
file_writer = file_handler.getWriter(file_type["id"])
mode = file_type.get("mode")
super().__init__(file_writer, self.createStream(mode = mode), nodes, mode)
# Determine the filename.
self.setFileName("{}.{}".format(job_name, extension))
def getOutput(self) -> bytes:
"""Get the job result as bytes as that is what we need to upload to the Digital Factory Library."""
output = self.getStream().getvalue()
if isinstance(output, str):
output = output.encode("utf-8")
return output
def getMimeType(self) -> str:
"""Get the mime type of the selected export file type."""
return MimeTypeDatabase.getMimeTypeForFile(self.getFileName()).name
@staticmethod
def createStream(mode) -> Union[io.BytesIO, io.StringIO]:
"""Creates the right kind of stream based on the preferred format."""
if mode == FileWriter.OutputMode.TextMode:
return io.StringIO()
else:
return io.BytesIO()

View file

@ -0,0 +1,30 @@
# Copyright (c) 2021 Ultimaker B.V.
from typing import Optional
class PaginationLinks:
"""Model containing pagination links."""
def __init__(self,
first: Optional[str] = None,
last: Optional[str] = None,
next: Optional[str] = None,
prev: Optional[str] = None,
**kwargs) -> None:
"""
Creates a new digital factory project response object
:param first: The URL for the first page.
:param last: The URL for the last page.
:param next: The URL for the next page.
:param prev: The URL for the prev page.
:param kwargs:
"""
self.first_page = first
self.last_page = last
self.next_page = next
self.prev_page = prev
def __str__(self) -> str:
return "Pagination Links | First: {}, Last: {}, Next: {}, Prev: {}".format(self.first_page, self.last_page, self.next_page, self.prev_page)

View file

@ -0,0 +1,43 @@
# Copyright (c) 2021 Ultimaker B.V.
from typing import Optional, Dict, Any
from .PaginationLinks import PaginationLinks
from .PaginationMetadata import PaginationMetadata
from .ResponseMeta import ResponseMeta
class PaginationManager:
def __init__(self, limit: int) -> None:
self.limit = limit # The limit of items per page
self.meta = None # type: Optional[ResponseMeta] # The metadata of the paginated response
self.links = None # type: Optional[PaginationLinks] # The pagination-related links
def setResponseMeta(self, meta: Optional[Dict[str, Any]]) -> None:
self.meta = None
if meta:
page = None
if "page" in meta:
page = PaginationMetadata(**meta["page"])
self.meta = ResponseMeta(page)
def setLinks(self, links: Optional[Dict[str, str]]) -> None:
self.links = PaginationLinks(**links) if links else None
def setLimit(self, new_limit: int) -> None:
"""
Sets the limit of items per page.
:param new_limit: The new limit of items per page
"""
self.limit = new_limit
self.reset()
def reset(self) -> None:
"""
Sets the metadata and links to None.
"""
self.meta = None
self.links = None

View file

@ -0,0 +1,25 @@
# Copyright (c) 2021 Ultimaker B.V.
from typing import Optional
class PaginationMetadata:
"""Class representing the metadata related to pagination."""
def __init__(self,
total_count: Optional[int] = None,
total_pages: Optional[int] = None,
**kwargs) -> None:
"""
Creates a new digital factory project response object
:param total_count: The total count of items.
:param total_pages: The total number of pages when pagination is applied.
:param kwargs:
"""
self.total_count = total_count
self.total_pages = total_pages
self.__dict__.update(kwargs)
def __str__(self) -> str:
return "PaginationMetadata | Total Count: {}, Total Pages: {}".format(self.total_count, self.total_pages)

View file

@ -0,0 +1,24 @@
# Copyright (c) 2021 Ultimaker B.V.
from typing import Optional
from .PaginationMetadata import PaginationMetadata
class ResponseMeta:
"""Class representing the metadata included in a Digital Library response (if any)"""
def __init__(self,
page: Optional[PaginationMetadata] = None,
**kwargs) -> None:
"""
Creates a new digital factory project response object
:param page: Metadata related to pagination
:param kwargs:
"""
self.page = page
self.__dict__.update(kwargs)
def __str__(self) -> str:
return "Response Meta | {}".format(self.page)

View file

View file

@ -118,6 +118,23 @@
}
}
},
"DigitalLibrary": {
"package_info": {
"package_id": "DigitalLibrary",
"package_type": "plugin",
"display_name": "Ultimaker Digital Library",
"description": "Connects to the Digital Library, allowing Cura to open files from and save files to the Digital Library.",
"package_version": "1.0.0",
"sdk_version": "7.5.0",
"website": "https://ultimaker.com",
"author": {
"author_id": "UltimakerPackages",
"display_name": "Ultimaker B.V.",
"email": "plugins@ultimaker.com",
"website": "https://ultimaker.com"
}
}
},
"FirmwareUpdateChecker": {
"package_info": {
"package_id": "FirmwareUpdateChecker",

View file

@ -61,7 +61,7 @@
"machine_max_feedrate_y": { "default_value": 300 },
"machine_max_feedrate_z": { "default_value": 40 },
"machine_acceleration": { "default_value": 3000 },
"gantry_height": { "value": "60" },
"gantry_height": { "value": "55" },
"machine_extruder_count": { "default_value": 2 },
"extruder_prime_pos_abs": { "default_value": true },
"machine_start_gcode": { "default_value": "" },

View file

@ -63,7 +63,7 @@
"machine_max_feedrate_y": { "default_value": 300 },
"machine_max_feedrate_z": { "default_value": 40 },
"machine_acceleration": { "default_value": 3000 },
"gantry_height": { "value": "60" },
"gantry_height": { "value": "55" },
"machine_extruder_count": { "default_value": 2 },
"extruder_prime_pos_abs": { "default_value": true },
"machine_start_gcode": { "default_value": "" },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 999 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 502 KiB

After

Width:  |  Height:  |  Size: 880 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 MiB

View file

@ -1,4 +1,8 @@
[4.9.0 Beta]
[4.9.0]
<i>For an overview of the new features in Cura 4.9, please watch <a href="https://www.youtube.com/watch?app=desktop&v=q7IJ0I9Mi1I&feature=youtu.be">our video</a>.</i>
* Digital factory integration.
Now you can open files directly from Digital Library projects. Then, after preparation, quickly and easily export them back. This feature is available for all users with an Ultimaker Essentials, Professional, or Excellence subscription. <a href="https://ultimaker.com/software/enterprise-software"> Learn more</a>
* "Line type" is now the default color scheme.
When entering the Preview mode, you don't have to switch manually to line type.
@ -20,19 +24,11 @@ The shell category was a mix of settings about walls and settings about top/bott
The ability to have thumbnails embedded. Contributed by Gravedigger7789.
* Add checkbox for Extruder Offsets.
Ability to enable or disable the extruder offsets to gcode. This will be enabled by default, unless it is in the printer's def.json file. Contributed by RFBomb.
Ability to enable or disable the extruder offsets to gcode. This will be enabled by default, unless it is in the printer's def.json file. Contributed by RFBomb.
* Cura should work properly on MacOS 'Big Sur' now, afforded by upgrades to Python (to 3.8) and Qt (to 5.15).
If you had (UX, visual, graphics card) problems, specifically on (newer) MacOS versions, like Big Sur, you should be able to use this new version.
* Known UX issues that will be fixed before final in our current plan
- Custom menu Materials and Nozzle menu now open at cursor position instead of under the menu button.
- Visuals of Preference screen are large.
- Drop downs in Preference screen don't react to mouse-scroll.
- Default language not selected in Preference screen.
- Changelog takes long too load.
- Setting Visibility submenu items in the Preference screen are greyed-out and can't be selected on Mac OSX.
* Bug Fixes
- Fixed a security vulnerability on windows permitting the openssl library used to launch other programs. Thanks to Xavier Danest for raising this bug.
- Fixed Connect Top/Bottom Polygons.
@ -63,7 +59,8 @@ If you had (UX, visual, graphics card) problems, specifically on (newer) MacOS v
- Fixed message for non manifold models.
- Fixed setting category arrows. Contributed by fieldOfView.
- Fixed metadate tags for 3MF files.
- Fixed engine crash when using low-density Cross Infill
- Fixed engine crash when using low-density Cross Infill.
- Improved performance of loading .def.json files.
* Printer definitions, profiles and materials
- 3DFuel Pro PLA and SnapSupport materials, contributed by grk3010.
@ -87,6 +84,7 @@ If you had (UX, visual, graphics card) problems, specifically on (newer) MacOS v
- TwoTrees Bluer, contributed by WashingtonJunior.
- Update Hellbot Magna 1 and Hellbot Magna dual, contributed by DevelopmentHellbot.
- Update Rigid3D and added Rigid3D Mucit2, contributed by mehmetsutas.
- Update TPU profiles for 0.6mm nozzle of UM2+C.
- ZAV series, contributed by kimer2002.
[4.8.0]

View file

@ -1,4 +1,5 @@
<h4>“Line type” is now the default color scheme.</h4>
<h4>Seamless workflow with the Digital Library in Ultimaker Digital Factory</h4>
<p>
This improves your user experience as you will no longer have to manually switch to “line type” each time you enter Preview mode.
Now you can open files directly from Digital Library projects. Then, after preparation, quickly and easily export them back. This feature is available for all users with an Ultimaker Essentials, Professional, or Excellence subscription.
<a href="https://ultimaker.com/software/enterprise-software"> Learn more</a>
</p>

View file

@ -1,3 +1,4 @@
<h4>There is a lot more..</h4>
<p>Want more information on new features, bug fixes, and more for Ultimaker Cura 4.9 beta? </br>
Read the full blog post <a href="https://ultimaker.com/learn/increased-first-time-right-with-ultimaker-cura-4-9-beta"> here</a>. And don't forget to give us your feedback on <a href="https://github.com/Ultimaker/Cura/issues"> Github!</a></p>
<h4>“Line type” is now the default color scheme.</h4>
<p>
This improves your user experience as you will no longer have to manually switch to “line type” each time you enter Preview mode.
</p>

View file

@ -0,0 +1,3 @@
<h4>Learn more</h4>
<p>Want more information for Ultimaker Cura 4.9?</br>
Read the <a href="https://ultimaker.com/learn/ultimaker-cura-4-9-seamless-and-efficient-with-digital-library-integration"> blog post</a> or watch the <a href="https://www.youtube.com/watch?app=desktop&v=q7IJ0I9Mi1I&feature=youtu.be"> video</a>. And don't forget to give us your <a href="https://github.com/Ultimaker/Cura/issues"> feedback</a>!</p>