Browse Source

Merge branch 'master' into python_type_hinting

Simon Edwards 8 years ago
parent
commit
98a6568313
10 changed files with 826 additions and 361 deletions
  1. 9 0
      .gitignore
  2. 5 39
      CMakeLists.txt
  3. 5 3
      README.md
  4. 31 0
      cura.appdata.xml
  5. 1 0
      cura.desktop.in
  6. 468 136
      cura/BuildVolume.py
  7. 87 44
      cura/ConvexHullDecorator.py
  8. 204 101
      cura/CuraApplication.py
  9. 16 27
      cura/LayerPolygon.py
  10. 0 11
      cura/MultiMaterialDecorator.py

+ 9 - 0
.gitignore

@@ -8,6 +8,8 @@ docs/html
 resources/i18n/en
 resources/i18n/x-test
 resources/firmware
+resources/materials
+LC_MESSAGES
 
 # Editors and IDEs.
 *kdev*
@@ -24,3 +26,10 @@ resources/firmware
 
 # Debian packaging
 debian*
+
+#Externally located plug-ins.
+plugins/Doodle3D-cura-plugin
+plugins/GodMode
+plugins/PostProcessingPlugin
+plugins/UM3NetworkPrinting
+plugins/X3GWriter

+ 5 - 39
CMakeLists.txt

@@ -1,5 +1,5 @@
 
-project(cura)
+project(cura NONE)
 cmake_minimum_required(VERSION 2.8.12)
 
 include(GNUInstallDirs)
@@ -17,48 +17,12 @@ set(CURA_BUILDTYPE "" CACHE STRING "Build type of Cura, eg. 'PPA'")
 configure_file(${CMAKE_SOURCE_DIR}/cura.desktop.in ${CMAKE_BINARY_DIR}/cura.desktop @ONLY)
 configure_file(cura/CuraVersion.py.in CuraVersion.py @ONLY)
 
-# Macro needed to list all sub-directory of a directory.
-# There is no function in cmake as far as I know.
-# Found at: http://stackoverflow.com/a/7788165
-MACRO(SUBDIRLIST result curdir)
-  FILE(GLOB children RELATIVE ${curdir} ${curdir}/*)
-  SET(dirlist "")
-  FOREACH(child ${children})
-    IF(IS_DIRECTORY ${curdir}/${child})
-        STRING(REPLACE "/" "" child ${child})
-        LIST(APPEND dirlist ${child})
-    ENDIF()
-  ENDFOREACH()
-  SET(${result} ${dirlist})
-ENDMACRO()
-
 if(NOT ${URANIUM_SCRIPTS_DIR} STREQUAL "")
+    include(UraniumTranslationTools)
     # Extract Strings
     add_custom_target(extract-messages ${URANIUM_SCRIPTS_DIR}/extract-messages ${CMAKE_SOURCE_DIR} cura)
-
     # Build Translations
-    find_package(Gettext)
-    if(GETTEXT_FOUND)
-        # translations target will convert .po files into .mo and .qm as needed.
-        # The files are checked for a _qt suffix and if it is found, converted to
-        # qm, otherwise they are converted to .po.
-        add_custom_target(translations ALL)
-        # copy-translations can be used to copy the built translation files from the
-        # build directory to the source resources directory. This is mostly a convenience
-        # during development, normally you want to simply use the install target to install
-        # the files along side the rest of the application.
-
-        SUBDIRLIST(languages ${CMAKE_SOURCE_DIR}/resources/i18n/)
-        foreach(lang ${languages})
-            file(GLOB po_files ${CMAKE_SOURCE_DIR}/resources/i18n/${lang}/*.po)
-            foreach(po_file ${po_files})
-                string(REGEX REPLACE ".*/(.*).po" "${CMAKE_BINARY_DIR}/resources/i18n/${lang}/LC_MESSAGES/\\1.mo" mo_file ${po_file})
-                add_custom_command(TARGET translations POST_BUILD COMMAND mkdir ARGS -p ${CMAKE_BINARY_DIR}/resources/i18n/${lang}/LC_MESSAGES/ COMMAND ${GETTEXT_MSGFMT_EXECUTABLE} ARGS ${po_file} -o ${mo_file} -f)
-            endforeach()
-        endforeach()
-        install(DIRECTORY ${CMAKE_BINARY_DIR}/resources
-                DESTINATION ${CMAKE_INSTALL_DATADIR}/cura)
-    endif()
+    CREATE_TRANSLATION_TARGETS()
 endif()
 
 find_package(PythonInterp 3.5.0 REQUIRED)
@@ -79,6 +43,8 @@ if(NOT APPLE AND NOT WIN32)
             DESTINATION lib/python${PYTHON_VERSION_MAJOR}/dist-packages/cura)
     install(FILES ${CMAKE_BINARY_DIR}/cura.desktop
             DESTINATION ${CMAKE_INSTALL_DATADIR}/applications)
+    install(FILES cura.appdata.xml
+            DESTINATION ${CMAKE_INSTALL_DATADIR}/appdata)
     install(FILES cura.sharedmimeinfo
             DESTINATION ${CMAKE_INSTALL_DATADIR}/mime/packages/
             RENAME cura.xml )

+ 5 - 3
README.md

@@ -42,9 +42,11 @@ Please checkout [cura-build](https://github.com/Ultimaker/cura-build)
 
 Third party plugins
 -------------
-* [Print time calculator](https://github.com/nallath/PrintCostCalculator)
-* [Post processing plugin](https://github.com/nallath/PostProcessingPlugin)
-* [Barbarian Plugin](https://github.com/nallath/BarbarianPlugin) Simple scale tool for imperial to metric.
+* [Print Cost Calculator](https://github.com/nallath/PrintCostCalculator): Calculates weight and monetary cost of your print.
+* [Post Processing Plugin](https://github.com/nallath/PostProcessingPlugin): Allows for post-processing scripts to run on g-code.
+* [Barbarian Plugin](https://github.com/nallath/BarbarianPlugin): Simple scale tool for imperial to metric.
+* [X3G Writer](https://github.com/Ghostkeeper/X3GWriter): Adds support for exporting X3G files.
+* [Auto orientation](https://github.com/nallath/CuraOrientationPlugin): Calculate the optimal orientation for a model.
 
 Making profiles for other printers
 ----------------------------------

+ 31 - 0
cura.appdata.xml

@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Copyright 2016 Richard Hughes <richard@hughsie.com> -->
+<component type="desktop">
+  <id>cura.desktop</id>
+  <metadata_license>CC0-1.0</metadata_license>
+  <project_license>AGPL-3.0 and CC-BY-SA-4.0</project_license>
+  <name>Cura</name>
+  <summary>The world's most advanced 3d printer software</summary>
+  <description>
+    <p>
+      Cura creates a seamless integration between hardware, software and
+      materials for the best 3D printing experience around.
+      Cura supports the 3MF, OBJ and STL file formats and is available on
+      Windows, Mac and Linux.
+    </p>
+    <ul>
+      <li>Novices can start printing right away</li>
+      <li>Experts are able to customize 200 settings to achieve the best results</li>
+      <li>Optimized profiles for Ultimaker materials</li>
+      <li>Supported by a global network of Ultimaker certified service partners</li>
+      <li>Print multiple objects at once with different settings for each object</li>
+      <li>Cura supports STL, 3MF and OBJ file formats</li>
+      <li>Open source and completely free</li>
+    </ul>
+  </description>
+  <screenshots>
+    <screenshot type="default" width="1280" height="720">http://software.ultimaker.com/Cura.png</screenshot>
+  </screenshots>
+  <url type="homepage">https://ultimaker.com/en/products/cura-software</url>
+  <translation type="gettext">Cura</translation>
+</component>

+ 1 - 0
cura.desktop.in

@@ -5,6 +5,7 @@ Name[de]=Cura
 GenericName=3D Printing Software
 GenericName[de]=3D-Druck-Software
 Comment=Cura converts 3D models into paths for a 3D printer. It prepares your print for maximum accuracy, minimum printing time and good reliability with many extra features that make your print come out great.
+Comment[de]=Cura wandelt 3D-Modelle in Pfade für einen 3D-Drucker um. Es bereitet Ihren Druck für maximale Genauigkeit, minimale Druckzeit und guter Zuverlässigkeit mit vielen zusätzlichen Funktionen vor, damit Ihr Druck großartig wird.
 Exec=@CMAKE_INSTALL_FULL_BINDIR@/cura %F
 TryExec=@CMAKE_INSTALL_FULL_BINDIR@/cura
 Icon=@CMAKE_INSTALL_FULL_DATADIR@/cura/resources/images/cura-icon.png

+ 468 - 136
cura/BuildVolume.py

@@ -1,9 +1,10 @@
-# Copyright (c) 2015 Ultimaker B.V.
+# Copyright (c) 2016 Ultimaker B.V.
 # Cura is released under the terms of the AGPLv3 or higher.
 
 from cura.Settings.ExtruderManager import ExtruderManager
 from UM.i18n import i18nCatalog
 from UM.Scene.Platform import Platform
+from UM.Scene.Iterator.BreadthFirstIterator import BreadthFirstIterator
 from UM.Scene.SceneNode import SceneNode
 from UM.Application import Application
 from UM.Resources import Resources
@@ -14,7 +15,7 @@ from UM.Math.AxisAlignedBox import AxisAlignedBox
 from UM.Math.Polygon import Polygon
 from UM.Message import Message
 from UM.Signal import Signal
-
+from PyQt5.QtCore import QTimer
 from UM.View.RenderBatch import RenderBatch
 from UM.View.GL.OpenGL import OpenGL
 catalog = i18nCatalog("cura")
@@ -22,33 +23,19 @@ catalog = i18nCatalog("cura")
 import numpy
 import copy
 
+import UM.Settings.ContainerRegistry
 
-# Setting for clearance around the prime
-PRIME_CLEARANCE = 10
-
-
-def approximatedCircleVertices(r):
-    """
-    Return vertices from an approximated circle.
-    :param r: radius
-    :return: numpy 2-array with the vertices
-    """
 
-    return numpy.array([
-        [-r, 0],
-        [-r * 0.707, r * 0.707],
-        [0, r],
-        [r * 0.707, r * 0.707],
-        [r, 0],
-        [r * 0.707, -r * 0.707],
-        [0, -r],
-        [-r * 0.707, -r * 0.707]
-    ], numpy.float32)
+# Setting for clearance around the prime
+PRIME_CLEARANCE = 6.5
 
 
 ##  Build volume is a special kind of node that is responsible for rendering the printable area & disallowed areas.
 class BuildVolume(SceneNode):
     VolumeOutlineColor = Color(12, 169, 227, 255)
+    XAxisColor = Color(255, 0, 0, 255)
+    YAxisColor = Color(0, 0, 255, 255)
+    ZAxisColor = Color(0, 255, 0, 255)
 
     raftThicknessChanged = Signal()
 
@@ -61,12 +48,19 @@ class BuildVolume(SceneNode):
 
         self._shader = None
 
+        self._origin_mesh = None
+        self._origin_line_length = 20
+        self._origin_line_width = 0.5
+
         self._grid_mesh = None
         self._grid_shader = None
 
         self._disallowed_areas = []
         self._disallowed_area_mesh = None
 
+        self._error_areas = []
+        self._error_mesh = None
+
         self.setCalculateBoundingBox(False)
         self._volume_aabb = None
 
@@ -75,12 +69,66 @@ class BuildVolume(SceneNode):
         self._platform = Platform(self)
 
         self._global_container_stack = None
-        Application.getInstance().globalContainerStackChanged.connect(self._onGlobalContainerStackChanged)
-        self._onGlobalContainerStackChanged()
+        Application.getInstance().globalContainerStackChanged.connect(self._onStackChanged)
+        self._onStackChanged()
+
+        self._has_errors = False
+        Application.getInstance().getController().getScene().sceneChanged.connect(self._onSceneChanged)
 
-        self._active_extruder_stack = None
-        ExtruderManager.getInstance().activeExtruderChanged.connect(self._onActiveExtruderStackChanged)
-        self._onActiveExtruderStackChanged()
+        #Objects loaded at the moment. We are connected to the property changed events of these objects.
+        self._scene_objects = set()
+
+        self._change_timer = QTimer()
+        self._change_timer.setInterval(100)
+        self._change_timer.setSingleShot(True)
+        self._change_timer.timeout.connect(self._onChangeTimerFinished)
+
+        self._build_volume_message = Message(catalog.i18nc("@info:status",
+            "The build volume height has been reduced due to the value of the"
+            " \"Print Sequence\" setting to prevent the gantry from colliding"
+            " with printed models."))
+
+        # Must be after setting _build_volume_message, apparently that is used in getMachineManager.
+        # activeQualityChanged is always emitted after setActiveVariant, setActiveMaterial and setActiveQuality.
+        # Therefore this works.
+        Application.getInstance().getMachineManager().activeQualityChanged.connect(self._onStackChanged)
+        # This should also ways work, and it is semantically more correct,
+        # but it does not update the disallowed areas after material change
+        Application.getInstance().getMachineManager().activeStackChanged.connect(self._onStackChanged)
+
+    def _onSceneChanged(self, source):
+        if self._global_container_stack:
+            self._change_timer.start()
+
+    def _onChangeTimerFinished(self):
+        root = Application.getInstance().getController().getScene().getRoot()
+        new_scene_objects = set(node for node in BreadthFirstIterator(root) if node.getMeshData() and type(node) is SceneNode)
+        if new_scene_objects != self._scene_objects:
+            for node in new_scene_objects - self._scene_objects: #Nodes that were added to the scene.
+                node.decoratorsChanged.connect(self._onNodeDecoratorChanged)
+            for node in self._scene_objects - new_scene_objects: #Nodes that were removed from the scene.
+                per_mesh_stack = node.callDecoration("getStack")
+                if per_mesh_stack:
+                    per_mesh_stack.propertyChanged.disconnect(self._onSettingPropertyChanged)
+                active_extruder_changed = node.callDecoration("getActiveExtruderChangedSignal")
+                if active_extruder_changed is not None:
+                    node.callDecoration("getActiveExtruderChangedSignal").disconnect(self._updateDisallowedAreasAndRebuild)
+                node.decoratorsChanged.disconnect(self._onNodeDecoratorChanged)
+
+            self._scene_objects = new_scene_objects
+            self._onSettingPropertyChanged("print_sequence", "value")  # Create fake event, so right settings are triggered.
+
+    ##  Updates the listeners that listen for changes in per-mesh stacks.
+    #
+    #   \param node The node for which the decorators changed.
+    def _onNodeDecoratorChanged(self, node):
+        per_mesh_stack = node.callDecoration("getStack")
+        if per_mesh_stack:
+            per_mesh_stack.propertyChanged.connect(self._onSettingPropertyChanged)
+        active_extruder_changed = node.callDecoration("getActiveExtruderChangedSignal")
+        if active_extruder_changed is not None:
+            active_extruder_changed.connect(self._updateDisallowedAreasAndRebuild)
+            self._updateDisallowedAreasAndRebuild()
 
     def setWidth(self, width):
         if width: self._width = width
@@ -106,10 +154,15 @@ class BuildVolume(SceneNode):
             self._grid_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "grid.shader"))
 
         renderer.queueNode(self, mode = RenderBatch.RenderMode.Lines)
+        renderer.queueNode(self, mesh = self._origin_mesh)
         renderer.queueNode(self, mesh = self._grid_mesh, shader = self._grid_shader, backface_cull = True)
         if self._disallowed_area_mesh:
             renderer.queueNode(self, mesh = self._disallowed_area_mesh, shader = self._shader, transparent = True, backface_cull = True, sort = -9)
 
+        if self._error_mesh:
+            renderer.queueNode(self, mesh=self._error_mesh, shader=self._shader, transparent=True,
+                               backface_cull=True, sort=-8)
+
         return True
 
     ##  Recalculates the build volume & disallowed areas.
@@ -144,6 +197,37 @@ class BuildVolume(SceneNode):
 
         self.setMeshData(mb.build())
 
+        mb = MeshBuilder()
+
+        # Indication of the machine origin
+        if self._global_container_stack.getProperty("machine_center_is_zero", "value"):
+            origin = (Vector(min_w, min_h, min_d) + Vector(max_w, min_h, max_d)) / 2
+        else:
+            origin = Vector(min_w, min_h, max_d)
+
+        mb.addCube(
+            width = self._origin_line_length,
+            height = self._origin_line_width,
+            depth = self._origin_line_width,
+            center = origin + Vector(self._origin_line_length / 2, 0, 0),
+            color = self.XAxisColor
+        )
+        mb.addCube(
+            width = self._origin_line_width,
+            height = self._origin_line_length,
+            depth = self._origin_line_width,
+            center = origin + Vector(0, self._origin_line_length / 2, 0),
+            color = self.YAxisColor
+        )
+        mb.addCube(
+            width = self._origin_line_width,
+            height = self._origin_line_width,
+            depth = self._origin_line_length,
+            center = origin - Vector(0, 0, self._origin_line_length / 2),
+            color = self.ZAxisColor
+        )
+        self._origin_mesh = mb.build()
+
         mb = MeshBuilder()
         mb.addQuad(
             Vector(min_w, min_h - 0.2, min_d),
@@ -184,15 +268,29 @@ class BuildVolume(SceneNode):
         else:
             self._disallowed_area_mesh = None
 
+        if self._error_areas:
+            mb = MeshBuilder()
+            for error_area in self._error_areas:
+                color = Color(1.0, 0.0, 0.0, 0.5)
+                points = error_area.getPoints()
+                first = Vector(self._clamp(points[0][0], min_w, max_w), disallowed_area_height,
+                               self._clamp(points[0][1], min_d, max_d))
+                previous_point = Vector(self._clamp(points[0][0], min_w, max_w), disallowed_area_height,
+                                        self._clamp(points[0][1], min_d, max_d))
+                for point in points:
+                    new_point = Vector(self._clamp(point[0], min_w, max_w), disallowed_area_height,
+                                       self._clamp(point[1], min_d, max_d))
+                    mb.addFace(first, previous_point, new_point, color=color)
+                    previous_point = new_point
+            self._error_mesh = mb.build()
+        else:
+            self._error_mesh = None
+
         self._volume_aabb = AxisAlignedBox(
             minimum = Vector(min_w, min_h - 1.0, min_d),
             maximum = Vector(max_w, max_h - self._raft_thickness, max_d))
 
-        bed_adhesion_size = 0.0
-
-        container_stack = Application.getInstance().getGlobalContainerStack()
-        if container_stack:
-            bed_adhesion_size = self._getBedAdhesionSize(container_stack)
+        bed_adhesion_size = self._getEdgeDisallowedSize()
 
         # As this works better for UM machines, we only add the disallowed_area_size for the z direction.
         # This is probably wrong in all other cases. TODO!
@@ -207,13 +305,6 @@ class BuildVolume(SceneNode):
     def getBoundingBox(self):
         return self._volume_aabb
 
-    def _buildVolumeMessage(self):
-        Message(catalog.i18nc(
-            "@info:status",
-            "The build volume height has been reduced due to the value of the"
-            " \"Print Sequence\" setting to prevent the gantry from colliding"
-            " with printed models.")).show()
-
     def getRaftThickness(self):
         return self._raft_thickness
 
@@ -234,23 +325,33 @@ class BuildVolume(SceneNode):
             self.setPosition(Vector(0, -self._raft_thickness, 0), SceneNode.TransformSpace.World)
             self.raftThicknessChanged.emit()
 
-    def _onGlobalContainerStackChanged(self):
+    ##  Update the build volume visualization
+    def _onStackChanged(self):
         if self._global_container_stack:
             self._global_container_stack.propertyChanged.disconnect(self._onSettingPropertyChanged)
+            extruders = ExtruderManager.getInstance().getMachineExtruders(self._global_container_stack.getId())
+            for extruder in extruders:
+                extruder.propertyChanged.disconnect(self._onSettingPropertyChanged)
 
         self._global_container_stack = Application.getInstance().getGlobalContainerStack()
 
         if self._global_container_stack:
             self._global_container_stack.propertyChanged.connect(self._onSettingPropertyChanged)
+            extruders = ExtruderManager.getInstance().getMachineExtruders(self._global_container_stack.getId())
+            for extruder in extruders:
+                extruder.propertyChanged.connect(self._onSettingPropertyChanged)
 
             self._width = self._global_container_stack.getProperty("machine_width", "value")
             machine_height = self._global_container_stack.getProperty("machine_height", "value")
-            if self._global_container_stack.getProperty("print_sequence", "value") == "one_at_a_time":
+            if self._global_container_stack.getProperty("print_sequence", "value") == "one_at_a_time" and len(self._scene_objects) > 1:
                 self._height = min(self._global_container_stack.getProperty("gantry_height", "value"), machine_height)
                 if self._height < machine_height:
-                    self._buildVolumeMessage()
+                    self._build_volume_message.show()
+                else:
+                    self._build_volume_message.hide()
             else:
                 self._height = self._global_container_stack.getProperty("machine_height", "value")
+                self._build_volume_message.hide()
             self._depth = self._global_container_stack.getProperty("machine_depth", "value")
 
             self._updateDisallowedAreas()
@@ -258,13 +359,6 @@ class BuildVolume(SceneNode):
 
             self.rebuild()
 
-    def _onActiveExtruderStackChanged(self):
-        if self._active_extruder_stack:
-            self._active_extruder_stack.propertyChanged.disconnect(self._onSettingPropertyChanged)
-        self._active_extruder_stack = ExtruderManager.getInstance().getActiveExtruderStack()
-        if self._active_extruder_stack:
-            self._active_extruder_stack.propertyChanged.connect(self._onSettingPropertyChanged)
-
     def _onSettingPropertyChanged(self, setting_key, property_name):
         if property_name != "value":
             return
@@ -272,15 +366,18 @@ class BuildVolume(SceneNode):
         rebuild_me = False
         if setting_key == "print_sequence":
             machine_height = self._global_container_stack.getProperty("machine_height", "value")
-            if Application.getInstance().getGlobalContainerStack().getProperty("print_sequence", "value") == "one_at_a_time":
+            if Application.getInstance().getGlobalContainerStack().getProperty("print_sequence", "value") == "one_at_a_time" and len(self._scene_objects) > 1:
                 self._height = min(self._global_container_stack.getProperty("gantry_height", "value"), machine_height)
                 if self._height < machine_height:
-                    self._buildVolumeMessage()
+                    self._build_volume_message.show()
+                else:
+                    self._build_volume_message.hide()
             else:
                 self._height = self._global_container_stack.getProperty("machine_height", "value")
+                self._build_volume_message.hide()
             rebuild_me = True
 
-        if setting_key in self._skirt_settings or setting_key in self._prime_settings or setting_key in self._tower_settings:
+        if setting_key in self._skirt_settings or setting_key in self._prime_settings or setting_key in self._tower_settings or setting_key == "print_sequence" or setting_key in self._ooze_shield_settings or setting_key in self._distance_settings or setting_key in self._extruder_settings:
             self._updateDisallowedAreas()
             rebuild_me = True
 
@@ -291,120 +388,355 @@ class BuildVolume(SceneNode):
         if rebuild_me:
             self.rebuild()
 
+    def hasErrors(self):
+        return self._has_errors
+
+    ##  Calls _updateDisallowedAreas and makes sure the changes appear in the
+    #   scene.
+    #
+    #   This is required for a signal to trigger the update in one go. The
+    #   ``_updateDisallowedAreas`` method itself shouldn't call ``rebuild``,
+    #   since there may be other changes before it needs to be rebuilt, which
+    #   would hit performance.
+    def _updateDisallowedAreasAndRebuild(self):
+        self._updateDisallowedAreas()
+        self.rebuild()
+
     def _updateDisallowedAreas(self):
         if not self._global_container_stack:
             return
 
-        disallowed_areas = copy.deepcopy(
-            self._global_container_stack.getProperty("machine_disallowed_areas", "value"))
-        areas = []
+        self._error_areas = []
 
-        machine_width = self._global_container_stack.getProperty("machine_width", "value")
-        machine_depth = self._global_container_stack.getProperty("machine_depth", "value")
+        extruder_manager = ExtruderManager.getInstance()
+        used_extruders = extruder_manager.getUsedExtruderStacks()
+        disallowed_border_size = self._getEdgeDisallowedSize()
+
+        result_areas = self._computeDisallowedAreasStatic(disallowed_border_size, used_extruders) #Normal machine disallowed areas can always be added.
+        prime_areas = self._computeDisallowedAreasPrime(disallowed_border_size, used_extruders)
+        prime_disallowed_areas = self._computeDisallowedAreasStatic(0, used_extruders) #Where the priming is not allowed to happen. This is not added to the result, just for collision checking.
+
+        #Check if prime positions intersect with disallowed areas.
+        for extruder in used_extruders:
+            extruder_id = extruder.getId()
+
+            collision = False
+            for prime_polygon in prime_areas[extruder_id]:
+                for disallowed_polygon in prime_disallowed_areas[extruder_id]:
+                    if prime_polygon.intersectsPolygon(disallowed_polygon) is not None:
+                        collision = True
+                        break
+                if collision:
+                    break
+
+                #Also check other prime positions (without additional offset).
+                for other_extruder_id in prime_areas:
+                    if extruder_id == other_extruder_id: #It is allowed to collide with itself.
+                        continue
+                    for other_prime_polygon in prime_areas[other_extruder_id]:
+                        if prime_polygon.intersectsPolygon(other_prime_polygon):
+                            collision = True
+                            break
+                    if collision:
+                        break
+                if collision:
+                    break
+
+
+            if not collision:
+                #Prime areas are valid. Add as normal.
+                result_areas[extruder_id].extend(prime_areas[extruder_id])
+
+            nozzle_disallowed_areas = extruder.getProperty("nozzle_disallowed_areas", "value")
+            for area in nozzle_disallowed_areas:
+                polygon = Polygon(numpy.array(area, numpy.float32))
+                polygon = polygon.getMinkowskiHull(Polygon.approximatedCircle(disallowed_border_size))
+                result_areas[extruder_id].append(polygon) #Don't perform the offset on these.
 
         # Add prime tower location as disallowed area.
-        if self._global_container_stack.getProperty("prime_tower_enable", "value"):
+        prime_tower_collision = False
+        prime_tower_areas = self._computeDisallowedAreasPrinted(used_extruders)
+        for extruder_id in prime_tower_areas:
+            for prime_tower_area in prime_tower_areas[extruder_id]:
+                for area in result_areas[extruder_id]:
+                    if prime_tower_area.intersectsPolygon(area) is not None:
+                        prime_tower_collision = True
+                        break
+                if prime_tower_collision: #Already found a collision.
+                    break
+            if not prime_tower_collision:
+                result_areas[extruder_id].extend(prime_tower_areas[extruder_id])
+            else:
+                self._error_areas.extend(prime_tower_areas[extruder_id])
+
+        self._has_errors = len(self._error_areas) > 0
+
+        self._disallowed_areas = []
+        for extruder_id in result_areas:
+            self._disallowed_areas.extend(result_areas[extruder_id])
+
+    ##  Computes the disallowed areas for objects that are printed with print
+    #   features.
+    #
+    #   This means that the brim, travel avoidance and such will be applied to
+    #   these features.
+    #
+    #   \return A dictionary with for each used extruder ID the disallowed areas
+    #   where that extruder may not print.
+    def _computeDisallowedAreasPrinted(self, used_extruders):
+        result = {}
+        for extruder in used_extruders:
+            result[extruder.getId()] = []
+
+        #Currently, the only normally printed object is the prime tower.
+        if ExtruderManager.getInstance().getResolveOrValue("prime_tower_enable") == True:
             prime_tower_size = self._global_container_stack.getProperty("prime_tower_size", "value")
-            prime_tower_x = self._global_container_stack.getProperty("prime_tower_position_x", "value") - machine_width / 2
+            machine_width = self._global_container_stack.getProperty("machine_width", "value")
+            machine_depth = self._global_container_stack.getProperty("machine_depth", "value")
+            prime_tower_x = self._global_container_stack.getProperty("prime_tower_position_x", "value") - machine_width / 2 #Offset by half machine_width and _depth to put the origin in the front-left.
             prime_tower_y = - self._global_container_stack.getProperty("prime_tower_position_y", "value") + machine_depth / 2
 
-            disallowed_areas.append([
+            prime_tower_area = Polygon([
                 [prime_tower_x - prime_tower_size, prime_tower_y - prime_tower_size],
                 [prime_tower_x, prime_tower_y - prime_tower_size],
                 [prime_tower_x, prime_tower_y],
                 [prime_tower_x - prime_tower_size, prime_tower_y],
             ])
+            prime_tower_area = prime_tower_area.getMinkowskiHull(Polygon.approximatedCircle(0))
+            for extruder in used_extruders:
+                result[extruder.getId()].append(prime_tower_area) #The prime tower location is the same for each extruder, regardless of offset.
+
+        return result
+
+    ##  Computes the disallowed areas for the prime locations.
+    #
+    #   These are special because they are not subject to things like brim or
+    #   travel avoidance. They do get a dilute with the border size though
+    #   because they may not intersect with brims and such of other objects.
+    #
+    #   \param border_size The size with which to offset the disallowed areas
+    #   due to skirt, brim, travel avoid distance, etc.
+    #   \param used_extruders The extruder stacks to generate disallowed areas
+    #   for.
+    #   \return A dictionary with for each used extruder ID the prime areas.
+    def _computeDisallowedAreasPrime(self, border_size, used_extruders):
+        result = {}
 
-        # Add extruder prime locations as disallowed areas.
-        # Probably needs some rework after coordinate system change.
-        extruder_manager = ExtruderManager.getInstance()
-        extruders = extruder_manager.getMachineExtruders(self._global_container_stack.getId())
-        for single_extruder in extruders:
-            extruder_prime_pos_x = single_extruder.getProperty("extruder_prime_pos_x", "value")
-            extruder_prime_pos_y = single_extruder.getProperty("extruder_prime_pos_y", "value")
-            # TODO: calculate everything in CuraEngine/Firmware/lower left as origin coordinates.
-            # Here we transform the extruder prime pos (lower left as origin) to Cura coordinates
-            # (center as origin, y from back to front)
-            prime_x = extruder_prime_pos_x - machine_width / 2
-            prime_y = machine_depth / 2 - extruder_prime_pos_y
-            disallowed_areas.append([
-                [prime_x - PRIME_CLEARANCE, prime_y - PRIME_CLEARANCE],
-                [prime_x + PRIME_CLEARANCE, prime_y - PRIME_CLEARANCE],
-                [prime_x + PRIME_CLEARANCE, prime_y + PRIME_CLEARANCE],
-                [prime_x - PRIME_CLEARANCE, prime_y + PRIME_CLEARANCE],
-            ])
-
-        bed_adhesion_size = self._getBedAdhesionSize(self._global_container_stack)
-
-        if disallowed_areas:
-            # Extend every area already in the disallowed_areas with the skirt size.
-            for area in disallowed_areas:
-                poly = Polygon(numpy.array(area, numpy.float32))
-                poly = poly.getMinkowskiHull(Polygon(approximatedCircleVertices(bed_adhesion_size)))
-
-                areas.append(poly)
-
-        # Add the skirt areas around the borders of the build plate.
-        if bed_adhesion_size > 0:
+        machine_width = self._global_container_stack.getProperty("machine_width", "value")
+        machine_depth = self._global_container_stack.getProperty("machine_depth", "value")
+        for extruder in used_extruders:
+            prime_x = extruder.getProperty("extruder_prime_pos_x", "value") - machine_width / 2 #Offset by half machine_width and _depth to put the origin in the front-left.
+            prime_y = machine_depth / 2 - extruder.getProperty("extruder_prime_pos_y", "value")
+
+            prime_polygon = Polygon.approximatedCircle(PRIME_CLEARANCE)
+            prime_polygon = prime_polygon.translate(prime_x, prime_y)
+            prime_polygon = prime_polygon.getMinkowskiHull(Polygon.approximatedCircle(border_size))
+            result[extruder.getId()] = [prime_polygon]
+
+        return result
+
+    ##  Computes the disallowed areas that are statically placed in the machine.
+    #
+    #   It computes different disallowed areas depending on the offset of the
+    #   extruder. The resulting dictionary will therefore have an entry for each
+    #   extruder that is used.
+    #
+    #   \param border_size The size with which to offset the disallowed areas
+    #   due to skirt, brim, travel avoid distance, etc.
+    #   \param used_extruders The extruder stacks to generate disallowed areas
+    #   for.
+    #   \return A dictionary with for each used extruder ID the disallowed areas
+    #   where that extruder may not print.
+    def _computeDisallowedAreasStatic(self, border_size, used_extruders):
+        #Convert disallowed areas to polygons and dilate them.
+        machine_disallowed_polygons = []
+        for area in self._global_container_stack.getProperty("machine_disallowed_areas", "value"):
+            polygon = Polygon(numpy.array(area, numpy.float32))
+            polygon = polygon.getMinkowskiHull(Polygon.approximatedCircle(border_size))
+            machine_disallowed_polygons.append(polygon)
+
+        result = {}
+        for extruder in used_extruders:
+            extruder_id = extruder.getId()
+            offset_x = extruder.getProperty("machine_nozzle_offset_x", "value")
+            if offset_x is None:
+                offset_x = 0
+            offset_y = extruder.getProperty("machine_nozzle_offset_y", "value")
+            if offset_y is None:
+                offset_y = 0
+            result[extruder_id] = []
+
+            for polygon in machine_disallowed_polygons:
+                result[extruder_id].append(polygon.translate(offset_x, offset_y)) #Compensate for the nozzle offset of this extruder.
+
+            #Add the border around the edge of the build volume.
+            left_unreachable_border = 0
+            right_unreachable_border = 0
+            top_unreachable_border = 0
+            bottom_unreachable_border = 0
+            #The build volume is defined as the union of the area that all extruders can reach, so we need to know the relative offset to all extruders.
+            for other_extruder in ExtruderManager.getInstance().getActiveExtruderStacks():
+                other_offset_x = other_extruder.getProperty("machine_nozzle_offset_x", "value")
+                other_offset_y = other_extruder.getProperty("machine_nozzle_offset_y", "value")
+                left_unreachable_border = min(left_unreachable_border, other_offset_x - offset_x)
+                right_unreachable_border = max(right_unreachable_border, other_offset_x - offset_x)
+                top_unreachable_border = min(top_unreachable_border, other_offset_y - offset_y)
+                bottom_unreachable_border = max(bottom_unreachable_border, other_offset_y - offset_y)
             half_machine_width = self._global_container_stack.getProperty("machine_width", "value") / 2
             half_machine_depth = self._global_container_stack.getProperty("machine_depth", "value") / 2
+            if border_size - left_unreachable_border > 0:
+                result[extruder_id].append(Polygon(numpy.array([
+                    [-half_machine_width, -half_machine_depth],
+                    [-half_machine_width, half_machine_depth],
+                    [-half_machine_width + border_size - left_unreachable_border, half_machine_depth - border_size - bottom_unreachable_border],
+                    [-half_machine_width + border_size - left_unreachable_border, -half_machine_depth + border_size - top_unreachable_border]
+                ], numpy.float32)))
+            if border_size + right_unreachable_border > 0:
+                result[extruder_id].append(Polygon(numpy.array([
+                    [half_machine_width, half_machine_depth],
+                    [half_machine_width, -half_machine_depth],
+                    [half_machine_width - border_size - right_unreachable_border, -half_machine_depth + border_size - top_unreachable_border],
+                    [half_machine_width - border_size - right_unreachable_border, half_machine_depth - border_size - bottom_unreachable_border]
+                ], numpy.float32)))
+            if border_size + bottom_unreachable_border > 0:
+                result[extruder_id].append(Polygon(numpy.array([
+                    [-half_machine_width, half_machine_depth],
+                    [half_machine_width, half_machine_depth],
+                    [half_machine_width - border_size - right_unreachable_border, half_machine_depth - border_size - bottom_unreachable_border],
+                    [-half_machine_width + border_size - left_unreachable_border, half_machine_depth - border_size - bottom_unreachable_border]
+                ], numpy.float32)))
+            if border_size - top_unreachable_border > 0:
+                result[extruder_id].append(Polygon(numpy.array([
+                    [half_machine_width, -half_machine_depth],
+                    [-half_machine_width, -half_machine_depth],
+                    [-half_machine_width + border_size - left_unreachable_border, -half_machine_depth + border_size - top_unreachable_border],
+                    [half_machine_width - border_size - right_unreachable_border, -half_machine_depth + border_size - top_unreachable_border]
+                ], numpy.float32)))
+
+        return result
+
+    ##  Private convenience function to get a setting from the adhesion
+    #   extruder.
+    #
+    #   \param setting_key The key of the setting to get.
+    #   \param property The property to get from the setting.
+    #   \return The property of the specified setting in the adhesion extruder.
+    def _getSettingFromAdhesionExtruder(self, setting_key, property = "value"):
+        return self._getSettingFromExtruder(setting_key, "adhesion_extruder_nr", property)
+
+    ##  Private convenience function to get a setting from every extruder.
+    #
+    #   For single extrusion machines, this gets the setting from the global
+    #   stack.
+    #
+    #   \return A sequence of setting values, one for each extruder.
+    def _getSettingFromAllExtruders(self, setting_key, property = "value"):
+        return ExtruderManager.getInstance().getAllExtruderSettings(setting_key, property)
+
+    ##  Private convenience function to get a setting from the support infill
+    #   extruder.
+    #
+    #   \param setting_key The key of the setting to get.
+    #   \param property The property to get from the setting.
+    #   \return The property of the specified setting in the support infill
+    #   extruder.
+    def _getSettingFromSupportInfillExtruder(self, setting_key, property = "value"):
+        return self._getSettingFromExtruder(setting_key, "support_infill_extruder_nr", property)
+
+    ##  Helper function to get a setting from an extruder specified in another
+    #   setting.
+    #
+    #   \param setting_key The key of the setting to get.
+    #   \param extruder_setting_key The key of the setting that specifies from
+    #   which extruder to get the setting, if there are multiple extruders.
+    #   \param property The property to get from the setting.
+    #   \return The property of the specified setting in the specified extruder.
+    def _getSettingFromExtruder(self, setting_key, extruder_setting_key, property = "value"):
+        multi_extrusion = self._global_container_stack.getProperty("machine_extruder_count", "value") > 1
+
+        if not multi_extrusion:
+            return self._global_container_stack.getProperty(setting_key, property)
+
+        extruder_index = self._global_container_stack.getProperty(extruder_setting_key, "value")
+
+        if extruder_index == "-1":  # If extruder index is -1 use global instead
+            return self._global_container_stack.getProperty(setting_key, property)
+
+        extruder_stack_id = ExtruderManager.getInstance().extruderIds[str(extruder_index)]
+        stack = UM.Settings.ContainerRegistry.getInstance().findContainerStacks(id = extruder_stack_id)[0]
+        return stack.getProperty(setting_key, property)
+
+    ##  Convenience function to calculate the disallowed radius around the edge.
+    #
+    #   This disallowed radius is to allow for space around the models that is
+    #   not part of the collision radius, such as bed adhesion (skirt/brim/raft)
+    #   and travel avoid distance.
+    def _getEdgeDisallowedSize(self):
+        if not self._global_container_stack:
+            return 0
+        container_stack = self._global_container_stack
 
-            areas.append(Polygon(numpy.array([
-                [-half_machine_width, -half_machine_depth],
-                [-half_machine_width, half_machine_depth],
-                [-half_machine_width + bed_adhesion_size, half_machine_depth - bed_adhesion_size],
-                [-half_machine_width + bed_adhesion_size, -half_machine_depth + bed_adhesion_size]
-            ], numpy.float32)))
-
-            areas.append(Polygon(numpy.array([
-                [half_machine_width, half_machine_depth],
-                [half_machine_width, -half_machine_depth],
-                [half_machine_width - bed_adhesion_size, -half_machine_depth + bed_adhesion_size],
-                [half_machine_width - bed_adhesion_size, half_machine_depth - bed_adhesion_size]
-            ], numpy.float32)))
-
-            areas.append(Polygon(numpy.array([
-                [-half_machine_width, half_machine_depth],
-                [half_machine_width, half_machine_depth],
-                [half_machine_width - bed_adhesion_size, half_machine_depth - bed_adhesion_size],
-                [-half_machine_width + bed_adhesion_size, half_machine_depth - bed_adhesion_size]
-            ], numpy.float32)))
-
-            areas.append(Polygon(numpy.array([
-                [half_machine_width, -half_machine_depth],
-                [-half_machine_width, -half_machine_depth],
-                [-half_machine_width + bed_adhesion_size, -half_machine_depth + bed_adhesion_size],
-                [half_machine_width - bed_adhesion_size, -half_machine_depth + bed_adhesion_size]
-            ], numpy.float32)))
-
-        self._disallowed_areas = areas
-
-    ##  Convenience function to calculate the size of the bed adhesion in directions x, y.
-    def _getBedAdhesionSize(self, container_stack):
-        skirt_size = 0.0
+        # If we are printing one at a time, we need to add the bed adhesion size to the disallowed areas of the objects
+        if container_stack.getProperty("print_sequence", "value") == "one_at_a_time":
+            return 0.1  # Return a very small value, so we do draw disallowed area's near the edges.
 
         adhesion_type = container_stack.getProperty("adhesion_type", "value")
         if adhesion_type == "skirt":
-            skirt_distance = container_stack.getProperty("skirt_gap", "value")
-            skirt_line_count = container_stack.getProperty("skirt_line_count", "value")
-            skirt_size = skirt_distance + (skirt_line_count * container_stack.getProperty("skirt_brim_line_width", "value"))
+            skirt_distance = self._getSettingFromAdhesionExtruder("skirt_gap")
+            skirt_line_count = self._getSettingFromAdhesionExtruder("skirt_line_count")
+            bed_adhesion_size = skirt_distance + (skirt_line_count * self._getSettingFromAdhesionExtruder("skirt_brim_line_width"))
+            if self._global_container_stack.getProperty("machine_extruder_count", "value") > 1:
+                adhesion_extruder_nr = int(self._global_container_stack.getProperty("adhesion_extruder_nr", "value"))
+                extruder_values = ExtruderManager.getInstance().getAllExtruderValues("skirt_brim_line_width")
+                del extruder_values[adhesion_extruder_nr]  # Remove the value of the adhesion extruder nr.
+                for value in extruder_values:
+                    bed_adhesion_size += value
         elif adhesion_type == "brim":
-            skirt_size = container_stack.getProperty("brim_line_count", "value") * container_stack.getProperty("skirt_brim_line_width", "value")
+            bed_adhesion_size = self._getSettingFromAdhesionExtruder("brim_line_count") * self._getSettingFromAdhesionExtruder("skirt_brim_line_width")
+            if self._global_container_stack.getProperty("machine_extruder_count", "value") > 1:
+                adhesion_extruder_nr = int(self._global_container_stack.getProperty("adhesion_extruder_nr", "value"))
+                extruder_values = ExtruderManager.getInstance().getAllExtruderValues("skirt_brim_line_width")
+                del extruder_values[adhesion_extruder_nr]  # Remove the value of the adhesion extruder nr.
+                for value in extruder_values:
+                    bed_adhesion_size += value
         elif adhesion_type == "raft":
-            skirt_size = container_stack.getProperty("raft_margin", "value")
-
-        if container_stack.getProperty("draft_shield_enabled", "value"):
-            skirt_size += container_stack.getProperty("draft_shield_dist", "value")
+            bed_adhesion_size = self._getSettingFromAdhesionExtruder("raft_margin")
+        elif adhesion_type == "none":
+            bed_adhesion_size = 0
+        else:
+            raise Exception("Unknown bed adhesion type. Did you forget to update the build volume calculations for your new bed adhesion type?")
 
-        if container_stack.getProperty("xy_offset", "value"):
-            skirt_size += container_stack.getProperty("xy_offset", "value")
+        support_expansion = 0
+        if self._getSettingFromSupportInfillExtruder("support_offset") and self._global_container_stack.getProperty("support_enable", "value"):
+            support_expansion += self._getSettingFromSupportInfillExtruder("support_offset")
 
-        return skirt_size
+        farthest_shield_distance = 0
+        if container_stack.getProperty("draft_shield_enabled", "value"):
+            farthest_shield_distance = max(farthest_shield_distance, container_stack.getProperty("draft_shield_dist", "value"))
+        if container_stack.getProperty("ooze_shield_enabled", "value"):
+            farthest_shield_distance = max(farthest_shield_distance, container_stack.getProperty("ooze_shield_dist", "value"))
+
+        move_from_wall_radius = 0  # Moves that start from outer wall.
+        move_from_wall_radius = max(move_from_wall_radius, max(self._getSettingFromAllExtruders("infill_wipe_dist")))
+        avoid_enabled_per_extruder = self._getSettingFromAllExtruders(("travel_avoid_other_parts"))
+        avoid_distance_per_extruder = self._getSettingFromAllExtruders("travel_avoid_distance")
+        for index, avoid_other_parts_enabled in enumerate(avoid_enabled_per_extruder): #For each extruder (or just global).
+            if avoid_other_parts_enabled:
+                move_from_wall_radius = max(move_from_wall_radius, avoid_distance_per_extruder[index]) #Index of the same extruder.
+
+        #Now combine our different pieces of data to get the final border size.
+        #Support expansion is added to the bed adhesion, since the bed adhesion goes around support.
+        #Support expansion is added to farthest shield distance, since the shields go around support.
+        border_size = max(move_from_wall_radius, support_expansion + farthest_shield_distance, support_expansion + bed_adhesion_size)
+        return border_size
 
     def _clamp(self, value, min_value, max_value):
         return max(min(value, max_value), min_value)
 
-    _skirt_settings = ["adhesion_type", "skirt_gap", "skirt_line_count", "skirt_brim_line_width", "brim_width", "brim_line_count", "raft_margin", "draft_shield_enabled", "draft_shield_dist", "xy_offset"]
+    _skirt_settings = ["adhesion_type", "skirt_gap", "skirt_line_count", "skirt_brim_line_width", "brim_width", "brim_line_count", "raft_margin", "draft_shield_enabled", "draft_shield_dist"]
     _raft_settings = ["adhesion_type", "raft_base_thickness", "raft_interface_thickness", "raft_surface_layers", "raft_surface_thickness", "raft_airgap"]
     _prime_settings = ["extruder_prime_pos_x", "extruder_prime_pos_y", "extruder_prime_pos_z"]
     _tower_settings = ["prime_tower_enable", "prime_tower_size", "prime_tower_position_x", "prime_tower_position_y"]
+    _ooze_shield_settings = ["ooze_shield_enabled", "ooze_shield_dist"]
+    _distance_settings = ["infill_wipe_dist", "travel_avoid_distance", "support_offset", "support_enable", "travel_avoid_other_parts"]
+    _extruder_settings = ["support_enable", "support_interface_enable", "support_infill_extruder_nr", "support_extruder_nr_layer_0", "support_interface_extruder_nr", "brim_line_count", "adhesion_extruder_nr", "adhesion_type"] #Settings that can affect which extruders are used.

+ 87 - 44
cura/ConvexHullDecorator.py

@@ -1,9 +1,14 @@
+# Copyright (c) 2016 Ultimaker B.V.
+# Cura is released under the terms of the AGPLv3 or higher.
+
 from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
 from UM.Application import Application
-
+from cura.Settings.ExtruderManager import ExtruderManager
 from UM.Math.Polygon import Polygon
 from . import ConvexHullNode
 
+import UM.Settings.ContainerRegistry
+
 import numpy
 
 ##  The convex hull decorator is a scene node decorator that adds the convex hull functionality to a scene node.
@@ -56,6 +61,7 @@ class ConvexHullDecorator(SceneNodeDecorator):
         if self._global_stack and self._node:
             if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and not self._node.getParent().callDecoration("isGroup"):
                 hull = hull.getMinkowskiHull(Polygon(numpy.array(self._global_stack.getProperty("machine_head_polygon", "value"), numpy.float32)))
+                hull = self._add2DAdhesionMargin(hull)
         return hull
 
     ##  Get the convex hull of the node with the full head size
@@ -109,7 +115,13 @@ class ConvexHullDecorator(SceneNodeDecorator):
         self._convex_hull_node = hull_node
 
     def _onSettingValueChanged(self, key, property_name):
-        if key in self._affected_settings and property_name == "value":
+        if property_name != "value": #Not the value that was changed.
+            return
+
+        if key in self._affected_settings:
+            self._onChanged()
+        if key in self._influencing_settings:
+            self._init2DConvexHullCache() #Invalidate the cache.
             self._onChanged()
 
     def _init2DConvexHullCache(self):
@@ -138,21 +150,17 @@ class ConvexHullDecorator(SceneNodeDecorator):
             if child_polygon == self._2d_convex_hull_group_child_polygon:
                 return self._2d_convex_hull_group_result
 
-            # First, calculate the normal convex hull around the points
-            convex_hull = child_polygon.getConvexHull()
-
-            # Then, do a Minkowski hull with a simple 1x1 quad to outset and round the normal convex hull.
-            # This is done because of rounding errors.
-            rounded_hull = self._roundHull(convex_hull)
+            convex_hull = child_polygon.getConvexHull() #First calculate the normal convex hull around the points.
+            offset_hull = self._offsetHull(convex_hull) #Then apply the offset from the settings.
 
             # Store the result in the cache
             self._2d_convex_hull_group_child_polygon = child_polygon
-            self._2d_convex_hull_group_result = rounded_hull
+            self._2d_convex_hull_group_result = offset_hull
 
-            return rounded_hull
+            return offset_hull
 
         else:
-            rounded_hull = None
+            offset_hull = None
             mesh = None
             world_transform = None
             if self._node.getMeshData():
@@ -190,19 +198,17 @@ class ConvexHullDecorator(SceneNodeDecorator):
                     hull = Polygon(vertex_data)
 
                     if len(vertex_data) >= 4:
-                        # First, calculate the normal convex hull around the points
                         convex_hull = hull.getConvexHull()
-
-                        # Then, do a Minkowski hull with a simple 1x1 quad to outset and round the normal convex hull.
-                        # This is done because of rounding errors.
-                        rounded_hull = convex_hull.getMinkowskiHull(Polygon(numpy.array([[-0.5, -0.5], [-0.5, 0.5], [0.5, 0.5], [0.5, -0.5]], numpy.float32)))
+                        offset_hull = self._offsetHull(convex_hull)
+            else:
+                return Polygon([])  # Node has no mesh data, so just return an empty Polygon.
 
             # Store the result in the cache
             self._2d_convex_hull_mesh = mesh
             self._2d_convex_hull_mesh_world_transform = world_transform
-            self._2d_convex_hull_mesh_result = rounded_hull
+            self._2d_convex_hull_mesh_result = offset_hull
 
-            return rounded_hull
+            return offset_hull
 
     def _getHeadAndFans(self):
         return Polygon(numpy.array(self._global_stack.getProperty("machine_head_with_fans_polygon", "value"), numpy.float32))
@@ -225,40 +231,43 @@ class ConvexHullDecorator(SceneNodeDecorator):
         # Compensate for raft/skirt/brim
         # Add extra margin depending on adhesion type
         adhesion_type = self._global_stack.getProperty("adhesion_type", "value")
-        extra_margin = 0
-        machine_head_coords = numpy.array(
-            self._global_stack.getProperty("machine_head_with_fans_polygon", "value"),
-            numpy.float32)
-        head_y_size = abs(machine_head_coords).min()  # safe margin to take off in all directions
 
         if adhesion_type == "raft":
-            extra_margin = max(0, self._global_stack.getProperty("raft_margin", "value") - head_y_size)
+            extra_margin = max(0, self._getSettingProperty("raft_margin", "value"))
         elif adhesion_type == "brim":
-            extra_margin = max(0, self._global_stack.getProperty("brim_width", "value") - head_y_size)
+            extra_margin = max(0, self._getSettingProperty("brim_line_count", "value") * self._getSettingProperty("skirt_brim_line_width", "value"))
+        elif adhesion_type == "none":
+            extra_margin = 0
         elif adhesion_type == "skirt":
             extra_margin = max(
-                0, self._global_stack.getProperty("skirt_gap", "value") +
-                   self._global_stack.getProperty("skirt_line_count", "value") * self._global_stack.getProperty("skirt_brim_line_width", "value") -
-                   head_y_size)
+                0, self._getSettingProperty("skirt_gap", "value") +
+                   self._getSettingProperty("skirt_line_count", "value") * self._getSettingProperty("skirt_brim_line_width", "value"))
+        else:
+            raise Exception("Unknown bed adhesion type. Did you forget to update the convex hull calculations for your new bed adhesion type?")
+
         # adjust head_and_fans with extra margin
         if extra_margin > 0:
-            # In Cura 2.2+, there is a function to create this circle-like polygon.
-            extra_margin_polygon = Polygon(numpy.array([
-                [-extra_margin, 0],
-                [-extra_margin * 0.707, extra_margin * 0.707],
-                [0, extra_margin],
-                [extra_margin * 0.707, extra_margin * 0.707],
-                [extra_margin, 0],
-                [extra_margin * 0.707, -extra_margin * 0.707],
-                [0, -extra_margin],
-                [-extra_margin * 0.707, -extra_margin * 0.707]
-            ], numpy.float32))
-
+            extra_margin_polygon = Polygon.approximatedCircle(extra_margin)
             poly = poly.getMinkowskiHull(extra_margin_polygon)
         return poly
 
-    def _roundHull(self, convex_hull):
-        return convex_hull.getMinkowskiHull(Polygon(numpy.array([[-0.5, -0.5], [-0.5, 0.5], [0.5, 0.5], [0.5, -0.5]], numpy.float32)))
+    ##  Offset the convex hull with settings that influence the collision area.
+    #
+    #   This also applies a minimum offset of 0.5mm, because of edge cases due
+    #   to the rounding we apply.
+    #
+    #   \param convex_hull Polygon of the original convex hull.
+    #   \return New Polygon instance that is offset with everything that
+    #   influences the collision area.
+    def _offsetHull(self, convex_hull):
+        horizontal_expansion = max(0.5, self._getSettingProperty("xy_offset", "value"))
+        expansion_polygon = Polygon(numpy.array([
+            [-horizontal_expansion, -horizontal_expansion],
+            [-horizontal_expansion, horizontal_expansion],
+            [horizontal_expansion, horizontal_expansion],
+            [horizontal_expansion, -horizontal_expansion]
+        ], numpy.float32))
+        return convex_hull.getMinkowskiHull(expansion_polygon)
 
     def _onChanged(self, *args):
         self._raft_thickness = self._build_volume.getRaftThickness()
@@ -268,6 +277,9 @@ class ConvexHullDecorator(SceneNodeDecorator):
         if self._global_stack:
             self._global_stack.propertyChanged.disconnect(self._onSettingValueChanged)
             self._global_stack.containersChanged.disconnect(self._onChanged)
+            extruders = ExtruderManager.getInstance().getMachineExtruders(self._global_stack.getId())
+            for extruder in extruders:
+                extruder.propertyChanged.disconnect(self._onSettingValueChanged)
 
         self._global_stack = Application.getInstance().getGlobalContainerStack()
 
@@ -275,9 +287,35 @@ class ConvexHullDecorator(SceneNodeDecorator):
             self._global_stack.propertyChanged.connect(self._onSettingValueChanged)
             self._global_stack.containersChanged.connect(self._onChanged)
 
+            extruders = ExtruderManager.getInstance().getMachineExtruders(self._global_stack.getId())
+            for extruder in extruders:
+                extruder.propertyChanged.connect(self._onSettingValueChanged)
+
             self._onChanged()
 
-    ## Returns true if node is a descendent or the same as the root node.
+    ##   Private convenience function to get a setting from the correct extruder (as defined by limit_to_extruder property).
+    def _getSettingProperty(self, setting_key, property="value"):
+        per_mesh_stack = self._node.callDecoration("getStack")
+        if per_mesh_stack:
+            return per_mesh_stack.getProperty(setting_key, property)
+
+        multi_extrusion = self._global_stack.getProperty("machine_extruder_count", "value") > 1
+        if not multi_extrusion:
+            return self._global_stack.getProperty(setting_key, property)
+
+        extruder_index = self._global_stack.getProperty(setting_key, "limit_to_extruder")
+        if extruder_index == "-1": #No limit_to_extruder.
+            extruder_stack_id = self._node.callDecoration("getActiveExtruder")
+            if not extruder_stack_id: #Decoration doesn't exist.
+                extruder_stack_id = ExtruderManager.getInstance().extruderIds["0"]
+            extruder_stack = UM.Settings.ContainerRegistry.getInstance().findContainerStacks(id = extruder_stack_id)[0]
+            return extruder_stack.getProperty(setting_key, property)
+        else: #Limit_to_extruder is set. Use that one.
+            extruder_stack_id = ExtruderManager.getInstance().extruderIds[str(extruder_index)]
+            stack = UM.Settings.ContainerRegistry.getInstance().findContainerStacks(id = extruder_stack_id)[0]
+            return stack.getProperty(setting_key, property)
+
+    ## Returns true if node is a descendant or the same as the root node.
     def __isDescendant(self, root, node):
         if node is None:
             return False
@@ -288,4 +326,9 @@ class ConvexHullDecorator(SceneNodeDecorator):
     _affected_settings = [
         "adhesion_type", "raft_base_thickness", "raft_interface_thickness", "raft_surface_layers",
         "raft_surface_thickness", "raft_airgap", "raft_margin", "print_sequence",
-        "skirt_gap", "skirt_line_count", "skirt_brim_line_width", "skirt_distance"]
+        "skirt_gap", "skirt_line_count", "skirt_brim_line_width", "skirt_distance", "brim_line_count"]
+
+    ##  Settings that change the convex hull.
+    #
+    #   If these settings change, the convex hull should be recalculated.
+    _influencing_settings = {"xy_offset"}

+ 204 - 101
cura/CuraApplication.py

@@ -23,20 +23,25 @@ from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation
 from UM.Operations.RemoveSceneNodeOperation import RemoveSceneNodeOperation
 from UM.Operations.GroupedOperation import GroupedOperation
 from UM.Operations.SetTransformOperation import SetTransformOperation
+from UM.Operations.TranslateOperation import TranslateOperation
 from cura.SetParentOperation import SetParentOperation
 
 from UM.Settings.SettingDefinition import SettingDefinition, DefinitionPropertyType
 from UM.Settings.ContainerRegistry import ContainerRegistry
 from UM.Settings.SettingFunction import SettingFunction
+from cura.Settings.MachineNameValidator import MachineNameValidator
+from cura.Settings.ProfilesModel import ProfilesModel
+from cura.Settings.QualityAndUserProfilesModel import QualityAndUserProfilesModel
+from cura.Settings.SettingInheritanceManager import SettingInheritanceManager
 
 from UM.i18n import i18nCatalog
+from cura.Settings.UserProfilesModel import UserProfilesModel
 
 from . import PlatformPhysics
 from . import BuildVolume
 from . import CameraAnimation
 from . import PrintInformation
 from . import CuraActions
-from . import MultiMaterialDecorator
 from . import ZOffsetDecorator
 from . import CuraSplashScreen
 from . import CameraImageProvider
@@ -56,11 +61,44 @@ from PyQt5.QtGui import QColor, QIcon
 from PyQt5.QtWidgets import QMessageBox
 from PyQt5.QtQml import qmlRegisterUncreatableType, qmlRegisterSingletonType, qmlRegisterType
 
+from contextlib import contextmanager
+
 import sys
 import os.path
 import numpy
 import copy
 import urllib
+import os
+import time
+
+CONFIG_LOCK_FILENAME = "cura.lock"
+
+##  Contextmanager to create a lock file and remove it afterwards.
+@contextmanager
+def lockFile(filename):
+    try:
+        with open(filename, 'w') as lock_file:
+            lock_file.write("Lock file - Cura is currently writing")
+    except:
+        Logger.log("e", "Could not create lock file [%s]" % filename)
+    yield
+    try:
+        if os.path.exists(filename):
+            os.remove(filename)
+    except:
+        Logger.log("e", "Could not delete lock file [%s]" % filename)
+
+
+##  Wait for a lock file to disappear
+#   the maximum allowable age is settable; if the file is too old, it will be ignored too
+def waitFileDisappear(filename, max_age_seconds=10, msg=""):
+    now = time.time()
+    while os.path.exists(filename) and now < os.path.getmtime(filename) + max_age_seconds and now > os.path.getmtime(filename):
+        if msg:
+            Logger.log("d", msg)
+        time.sleep(1)
+        now = time.time()
+
 
 numpy.seterr(all="ignore")
 
@@ -97,14 +135,24 @@ class CuraApplication(QtApplication):
         # Need to do this before ContainerRegistry tries to load the machines
         SettingDefinition.addSupportedProperty("settable_per_mesh", DefinitionPropertyType.Any, default = True, read_only = True)
         SettingDefinition.addSupportedProperty("settable_per_extruder", DefinitionPropertyType.Any, default = True, read_only = True)
+        # this setting can be changed for each group in one-at-a-time mode
         SettingDefinition.addSupportedProperty("settable_per_meshgroup", DefinitionPropertyType.Any, default = True, read_only = True)
         SettingDefinition.addSupportedProperty("settable_globally", DefinitionPropertyType.Any, default = True, read_only = True)
-        SettingDefinition.addSupportedProperty("global_inherits_stack", DefinitionPropertyType.Function, default = "-1")
+
+        # From which stack the setting would inherit if not defined per object (handled in the engine)
+        # AND for settings which are not settable_per_mesh:
+        # which extruder is the only extruder this setting is obtained from
+        SettingDefinition.addSupportedProperty("limit_to_extruder", DefinitionPropertyType.Function, default = "-1")
+
+        # For settings which are not settable_per_mesh and not settable_per_extruder:
+        # A function which determines the glabel/meshgroup value by looking at the values of the setting in all (used) extruders
         SettingDefinition.addSupportedProperty("resolve", DefinitionPropertyType.Function, default = None)
+
         SettingDefinition.addSettingType("extruder", None, str, Validator)
 
         SettingFunction.registerOperator("extruderValues", ExtruderManager.getExtruderValues)
         SettingFunction.registerOperator("extruderValue", ExtruderManager.getExtruderValue)
+        SettingFunction.registerOperator("resolveOrValue", ExtruderManager.getResolveOrValue)
 
         ## Add the 4 types of profiles to storage.
         Resources.addStorageType(self.ResourceTypes.QualityInstanceContainer, "quality")
@@ -128,12 +176,14 @@ class CuraApplication(QtApplication):
             {
                 ("quality", UM.Settings.InstanceContainer.InstanceContainer.Version):    (self.ResourceTypes.QualityInstanceContainer, "application/x-uranium-instancecontainer"),
                 ("machine_stack", UM.Settings.ContainerStack.ContainerStack.Version): (self.ResourceTypes.MachineStack, "application/x-uranium-containerstack"),
-                ("preferences", Preferences.Version):               (Resources.Preferences, "application/x-uranium-preferences")
+                ("preferences", Preferences.Version):               (Resources.Preferences, "application/x-uranium-preferences"),
+                ("user", UM.Settings.InstanceContainer.InstanceContainer.Version):       (self.ResourceTypes.UserInstanceContainer, "application/x-uranium-instancecontainer")
             }
         )
 
         self._machine_action_manager = MachineActionManager.MachineActionManager()
         self._machine_manager = None    # This is initialized on demand.
+        self._setting_inheritance_manager = None
 
         self._additional_components = {} # Components to add to certain areas in the interface
 
@@ -191,6 +241,8 @@ class CuraApplication(QtApplication):
         ContainerRegistry.getInstance().addContainer(empty_material_container)
         empty_quality_container = copy.deepcopy(empty_container)
         empty_quality_container._id = "empty_quality"
+        empty_quality_container.setName("Not supported")
+        empty_quality_container.addMetaDataEntry("quality_type", "normal")
         empty_quality_container.addMetaDataEntry("type", "quality")
         ContainerRegistry.getInstance().addContainer(empty_quality_container)
         empty_quality_changes_container = copy.deepcopy(empty_container)
@@ -198,6 +250,9 @@ class CuraApplication(QtApplication):
         empty_quality_changes_container.addMetaDataEntry("type", "quality_changes")
         ContainerRegistry.getInstance().addContainer(empty_quality_changes_container)
 
+        # Set the filename to create if cura is writing in the config dir.
+        self._config_lock_filename = os.path.join(Resources.getConfigStoragePath(), CONFIG_LOCK_FILENAME)
+        self.waitConfigLockFile()
         ContainerRegistry.getInstance().load()
 
         Preferences.getInstance().addPreference("cura/active_mode", "simple")
@@ -219,7 +274,7 @@ class CuraApplication(QtApplication):
 
         Preferences.getInstance().setDefault("general/visible_settings", """
             machine_settings
-                resolution
+            resolution
                 layer_height
             shell
                 wall_thickness
@@ -244,17 +299,17 @@ class CuraApplication(QtApplication):
                 cool_fan_enabled
             support
                 support_enable
+                support_extruder_nr
                 support_type
                 support_interface_density
             platform_adhesion
                 adhesion_type
+                adhesion_extruder_nr
                 brim_width
                 raft_airgap
                 layer_0_z_overlap
                 raft_surface_layers
             dual
-                adhesion_extruder_nr
-                support_extruder_nr
                 prime_tower_enable
                 prime_tower_size
                 prime_tower_position_x
@@ -280,6 +335,11 @@ class CuraApplication(QtApplication):
 
     def getContainerRegistry(self):
         return CuraContainerRegistry.getInstance()
+    ## Lock file check: if (another) Cura is writing in the Config dir.
+    #  one may not be able to read a valid set of files while writing. Not entirely fool-proof,
+    #  but works when you start Cura shortly after shutting down.
+    def waitConfigLockFile(self):
+        waitFileDisappear(self._config_lock_filename, max_age_seconds=10, msg="Waiting for Cura to finish writing in the config dir...")
 
     def _onEngineCreated(self):
         self._engine.addImageProvider("camera", CameraImageProvider.CameraImageProvider())
@@ -308,58 +368,67 @@ class CuraApplication(QtApplication):
         if not self._started: # Do not do saving during application start
             return
 
-        for instance in ContainerRegistry.getInstance().findInstanceContainers():
-            if not instance.isDirty():
-                continue
-
-            try:
-                data = instance.serialize()
-            except NotImplementedError:
-                continue
-            except Exception:
-                Logger.logException("e", "An exception occurred when serializing container %s", instance.getId())
-                continue
-
-            mime_type = ContainerRegistry.getMimeTypeForContainer(type(instance))
-            file_name = urllib.parse.quote_plus(instance.getId()) + "." + mime_type.preferredSuffix
-            instance_type = instance.getMetaDataEntry("type")
-            path = None
-            if instance_type == "material":
-                path = Resources.getStoragePath(self.ResourceTypes.MaterialInstanceContainer, file_name)
-            elif instance_type == "quality" or instance_type == "quality_changes":
-                path = Resources.getStoragePath(self.ResourceTypes.QualityInstanceContainer, file_name)
-            elif instance_type == "user":
-                path = Resources.getStoragePath(self.ResourceTypes.UserInstanceContainer, file_name)
-            elif instance_type == "variant":
-                path = Resources.getStoragePath(self.ResourceTypes.VariantInstanceContainer, file_name)
-
-            if path:
-                with SaveFile(path, "wt", -1, "utf-8") as f:
-                    f.write(data)
-
-        for stack in ContainerRegistry.getInstance().findContainerStacks():
-            if not stack.isDirty():
-                continue
-
-            try:
-                data = stack.serialize()
-            except NotImplementedError:
-                continue
-            except Exception:
-                Logger.logException("e", "An exception occurred when serializing container %s", instance.getId())
-                continue
+        self.waitConfigLockFile()
+
+        # When starting Cura, we check for the lockFile which is created and deleted here
+        with lockFile(self._config_lock_filename):
+
+            for instance in ContainerRegistry.getInstance().findInstanceContainers():
+                if not instance.isDirty():
+                    continue
+
+                try:
+                    data = instance.serialize()
+                except NotImplementedError:
+                    continue
+                except Exception:
+                    Logger.logException("e", "An exception occurred when serializing container %s", instance.getId())
+                    continue
+
+                mime_type = ContainerRegistry.getMimeTypeForContainer(type(instance))
+                file_name = urllib.parse.quote_plus(instance.getId()) + "." + mime_type.preferredSuffix
+                instance_type = instance.getMetaDataEntry("type")
+                path = None
+                if instance_type == "material":
+                    path = Resources.getStoragePath(self.ResourceTypes.MaterialInstanceContainer, file_name)
+                elif instance_type == "quality" or instance_type == "quality_changes":
+                    path = Resources.getStoragePath(self.ResourceTypes.QualityInstanceContainer, file_name)
+                elif instance_type == "user":
+                    path = Resources.getStoragePath(self.ResourceTypes.UserInstanceContainer, file_name)
+                elif instance_type == "variant":
+                    path = Resources.getStoragePath(self.ResourceTypes.VariantInstanceContainer, file_name)
+
+                if path:
+                    instance.setPath(path)
+                    with SaveFile(path, "wt") as f:
+                        f.write(data)
+
+            for stack in ContainerRegistry.getInstance().findContainerStacks():
+                self.saveStack(stack)
+
+    def saveStack(self, stack):
+        if not stack.isDirty():
+            return
+        try:
+            data = stack.serialize()
+        except NotImplementedError:
+            return
+        except Exception:
+            Logger.logException("e", "An exception occurred when serializing container %s", stack.getId())
+            return
 
-            mime_type = ContainerRegistry.getMimeTypeForContainer(type(stack))
-            file_name = urllib.parse.quote_plus(stack.getId()) + "." + mime_type.preferredSuffix
-            stack_type = stack.getMetaDataEntry("type", None)
-            path = None
-            if not stack_type or stack_type == "machine":
-                path = Resources.getStoragePath(self.ResourceTypes.MachineStack, file_name)
-            elif stack_type == "extruder_train":
-                path = Resources.getStoragePath(self.ResourceTypes.ExtruderStack, file_name)
-            if path:
-                with SaveFile(path, "wt", -1, "utf-8") as f:
-                    f.write(data)
+        mime_type = ContainerRegistry.getMimeTypeForContainer(type(stack))
+        file_name = urllib.parse.quote_plus(stack.getId()) + "." + mime_type.preferredSuffix
+        stack_type = stack.getMetaDataEntry("type", None)
+        path = None
+        if not stack_type or stack_type == "machine":
+            path = Resources.getStoragePath(self.ResourceTypes.MachineStack, file_name)
+        elif stack_type == "extruder_train":
+            path = Resources.getStoragePath(self.ResourceTypes.ExtruderStack, file_name)
+        if path:
+            stack.setPath(path)
+            with SaveFile(path, "wt") as f:
+                f.write(data)
 
 
     @pyqtSlot(str, result = QUrl)
@@ -434,6 +503,8 @@ class CuraApplication(QtApplication):
         # Initialise extruder so as to listen to global container stack changes before the first global container stack is set.
         ExtruderManager.getInstance()
         qmlRegisterSingletonType(MachineManager, "Cura", 1, 0, "MachineManager", self.getMachineManager)
+        qmlRegisterSingletonType(SettingInheritanceManager, "Cura", 1, 0, "SettingInheritanceManager",
+                         self.getSettingInheritanceManager)
 
         qmlRegisterSingletonType(MachineActionManager.MachineActionManager, "Cura", 1, 0, "MachineActionManager", self.getMachineActionManager)
         self.setMainQml(Resources.getPath(self.ResourceTypes.QmlFiles, "Cura.qml"))
@@ -457,6 +528,11 @@ class CuraApplication(QtApplication):
             self._machine_manager = MachineManager.createMachineManager()
         return self._machine_manager
 
+    def getSettingInheritanceManager(self, *args):
+        if self._setting_inheritance_manager is None:
+            self._setting_inheritance_manager = SettingInheritanceManager.createSettingInheritanceManager()
+        return self._setting_inheritance_manager
+
     ##  Get the machine action manager
     #   We ignore any *args given to this, as we also register the machine manager as qml singleton.
     #   It wants to give this function an engine and script engine, but we don't care about that.
@@ -493,12 +569,18 @@ class CuraApplication(QtApplication):
         qmlRegisterType(ExtrudersModel, "Cura", 1, 0, "ExtrudersModel")
 
         qmlRegisterType(ContainerSettingsModel, "Cura", 1, 0, "ContainerSettingsModel")
+        qmlRegisterSingletonType(ProfilesModel, "Cura", 1, 0, "ProfilesModel", ProfilesModel.createProfilesModel)
+        qmlRegisterType(QualityAndUserProfilesModel, "Cura", 1, 0, "QualityAndUserProfilesModel")
+        qmlRegisterType(UserProfilesModel, "Cura", 1, 0, "UserProfilesModel")
         qmlRegisterType(MaterialSettingsVisibilityHandler, "Cura", 1, 0, "MaterialSettingsVisibilityHandler")
         qmlRegisterType(QualitySettingsModel, "Cura", 1, 0, "QualitySettingsModel")
+        qmlRegisterType(MachineNameValidator, "Cura", 1, 0, "MachineNameValidator")
 
         qmlRegisterSingletonType(ContainerManager, "Cura", 1, 0, "ContainerManager", ContainerManager.createContainerManager)
 
-        qmlRegisterSingletonType(QUrl.fromLocalFile(Resources.getPath(CuraApplication.ResourceTypes.QmlFiles, "Actions.qml")), "Cura", 1, 0, "Actions")
+        # As of Qt5.7, it is necessary to get rid of any ".." in the path for the singleton to work.
+        actions_url = QUrl.fromLocalFile(os.path.abspath(Resources.getPath(CuraApplication.ResourceTypes.QmlFiles, "Actions.qml")))
+        qmlRegisterSingletonType(actions_url, "Cura", 1, 0, "Actions")
 
         engine.rootContext().setContextProperty("ExtruderManager", ExtruderManager.getInstance())
 
@@ -511,11 +593,19 @@ class CuraApplication(QtApplication):
 
     def onSelectionChanged(self):
         if Selection.hasSelection():
-            if not self.getController().getActiveTool():
+            if self.getController().getActiveTool():
+                # If the tool has been disabled by the new selection
+                if not self.getController().getActiveTool().getEnabled():
+                    # Default
+                    self.getController().setActiveTool("TranslateTool")
+            else:
                 if self._previous_active_tool:
                     self.getController().setActiveTool(self._previous_active_tool)
+                    if not self.getController().getActiveTool().getEnabled():
+                        self.getController().setActiveTool("TranslateTool")
                     self._previous_active_tool = None
                 else:
+                    # Default
                     self.getController().setActiveTool("TranslateTool")
             if Preferences.getInstance().getValue("view/center_on_select"):
                 self._center_after_select = True
@@ -523,8 +613,6 @@ class CuraApplication(QtApplication):
             if self.getController().getActiveTool():
                 self._previous_active_tool = self.getController().getActiveTool().getPluginId()
                 self.getController().setActiveTool(None)
-            else:
-                self._previous_active_tool = None
 
     def _onToolOperationStopped(self, event):
         if self._center_after_select:
@@ -589,8 +677,6 @@ class CuraApplication(QtApplication):
                     op.addOperation(RemoveSceneNodeOperation(group_node))
         op.push()
 
-        pass
-
     ##  Remove an object from the scene.
     #   Note that this only removes an object if it is selected.
     @pyqtSlot("quint64")
@@ -604,17 +690,17 @@ class CuraApplication(QtApplication):
             node = Selection.getSelectedObject(0)
 
         if node:
-            group_node = None
-            if node.getParent():
-                group_node = node.getParent()
-                op = RemoveSceneNodeOperation(node)
+            op = GroupedOperation()
+            op.addOperation(RemoveSceneNodeOperation(node))
 
-            op.push()
+            group_node = node.getParent()
             if group_node:
-                if len(group_node.getChildren()) == 1 and group_node.callDecoration("isGroup"):
+                # Note that at this point the node has not yet been deleted
+                if len(group_node.getChildren()) <= 2 and group_node.callDecoration("isGroup"):
                     op.addOperation(SetParentOperation(group_node.getChildren()[0], group_node.getParent()))
-                    op = RemoveSceneNodeOperation(group_node)
-                    op.push()
+                    op.addOperation(RemoveSceneNodeOperation(group_node))
+
+            op.push()
 
     ##  Create a number of copies of existing object.
     @pyqtSlot("quint64", int)
@@ -630,10 +716,9 @@ class CuraApplication(QtApplication):
             while current_node.getParent() and current_node.getParent().callDecoration("isGroup"):
                 current_node = current_node.getParent()
 
-            new_node = copy.deepcopy(current_node)
-
             op = GroupedOperation()
             for _ in range(count):
+                new_node = copy.deepcopy(current_node)
                 op.addOperation(AddSceneNodeOperation(new_node, current_node.getParent()))
             op.push()
 
@@ -707,18 +792,21 @@ class CuraApplication(QtApplication):
                 continue  # Node that doesnt have a mesh and is not a group.
             if node.getParent() and node.getParent().callDecoration("isGroup"):
                 continue  # Grouped nodes don't need resetting as their parent (the group) is resetted)
-
             nodes.append(node)
 
         if nodes:
             op = GroupedOperation()
             for node in nodes:
+                # Ensure that the object is above the build platform
                 node.removeDecorator(ZOffsetDecorator.ZOffsetDecorator)
-                op.addOperation(SetTransformOperation(node, Vector(0,0,0)))
-
+                if node.getBoundingBox():
+                    center_y = node.getWorldPosition().y - node.getBoundingBox().bottom
+                else:
+                    center_y = 0
+                op.addOperation(SetTransformOperation(node, Vector(0, center_y, 0)))
             op.push()
-    
-    ## Reset all transformations on nodes with mesh data. 
+
+    ## Reset all transformations on nodes with mesh data.
     @pyqtSlot()
     def resetAll(self):
         Logger.log("i", "Resetting all scene transformations")
@@ -734,14 +822,16 @@ class CuraApplication(QtApplication):
 
         if nodes:
             op = GroupedOperation()
-
             for node in nodes:
                 # Ensure that the object is above the build platform
                 node.removeDecorator(ZOffsetDecorator.ZOffsetDecorator)
-                op.addOperation(SetTransformOperation(node, Vector(0,0,0), Quaternion(), Vector(1, 1, 1)))
-
+                if node.getBoundingBox():
+                    center_y = node.getWorldPosition().y - node.getBoundingBox().bottom
+                else:
+                    center_y = 0
+                op.addOperation(SetTransformOperation(node, Vector(0, center_y, 0), Quaternion(), Vector(1, 1, 1)))
             op.push()
-            
+
     ##  Reload all mesh data on the screen from file.
     @pyqtSlot()
     def reloadAll(self):
@@ -806,20 +896,32 @@ class CuraApplication(QtApplication):
         except Exception as e:
             Logger.log("d", "mergeSelected: Exception:", e)
             return
-        multi_material_decorator = MultiMaterialDecorator.MultiMaterialDecorator()
-        group_node.addDecorator(multi_material_decorator)
 
-        # Compute the center of the objects when their origins are aligned.
-        object_centers = [node.getMeshData().getCenterPosition().scale(node.getScale()) for node in group_node.getChildren()]
-        middle_x = sum([v.x for v in object_centers]) / len(object_centers)
-        middle_y = sum([v.y for v in object_centers]) / len(object_centers)
-        middle_z = sum([v.z for v in object_centers]) / len(object_centers)
-        offset = Vector(middle_x, middle_y, middle_z)
+        meshes = [node.getMeshData() for node in group_node.getAllChildren() if node.getMeshData()]
+
+        # Compute the center of the objects
+        object_centers = []
+        for mesh, node in zip(meshes, group_node.getChildren()):
+            orientation = node.getOrientation().toMatrix()
+            rotated_mesh = mesh.getTransformed(orientation)
+            center = rotated_mesh.getCenterPosition().scale(node.getScale())
+            object_centers.append(center)
+        if object_centers and len(object_centers) > 0:
+            middle_x = sum([v.x for v in object_centers]) / len(object_centers)
+            middle_y = sum([v.y for v in object_centers]) / len(object_centers)
+            middle_z = sum([v.z for v in object_centers]) / len(object_centers)
+            offset = Vector(middle_x, middle_y, middle_z)
+        else:
+            offset = Vector(0, 0, 0)
 
         # Move each node to the same position.
-        for center, node in zip(object_centers, group_node.getChildren()):
-            # Align the object and also apply the offset to center it inside the group.
-            node.setPosition(center - offset)
+        for mesh, node in zip(meshes, group_node.getChildren()):
+            orientation = node.getOrientation().toMatrix()
+            rotated_mesh = mesh.getTransformed(orientation)
+
+            # Align the object around its zero position
+            # and also apply the offset to center it inside the group.
+            node.setPosition(-rotated_mesh.getZeroPosition().scale(node.getScale()) - offset)
 
         # Use the previously found center of the group bounding box as the new location of the group
         group_node.setPosition(group_node.getBoundingBox().center)
@@ -873,15 +975,16 @@ class CuraApplication(QtApplication):
     fileLoaded = pyqtSignal(str)
 
     def _onFileLoaded(self, job):
-        node = job.getResult()
-        if node != None:
-            self.fileLoaded.emit(job.getFileName())
-            node.setSelectable(True)
-            node.setName(os.path.basename(job.getFileName()))
-            op = AddSceneNodeOperation(node, self.getController().getScene().getRoot())
-            op.push()
+        nodes = job.getResult()
+        for node in nodes:
+            if node is not None:
+                self.fileLoaded.emit(job.getFileName())
+                node.setSelectable(True)
+                node.setName(os.path.basename(job.getFileName()))
+                op = AddSceneNodeOperation(node, self.getController().getScene().getRoot())
+                op.push()
 
-            self.getController().getScene().sceneChanged.emit(node) #Force scene change.
+                self.getController().getScene().sceneChanged.emit(node) #Force scene change.
 
     def _onJobFinished(self, job):
         if type(job) is not ReadMeshJob or not job.getResult():

+ 16 - 27
cura/LayerPolygon.py

@@ -14,8 +14,9 @@ class LayerPolygon:
     SupportInfillType = 7
     MoveCombingType = 8
     MoveRetractionType = 9
+    SupportInterfaceType = 10
     
-    __jump_map = numpy.logical_or( numpy.arange(10) == NoneType, numpy.arange(10) >= MoveCombingType )
+    __jump_map = numpy.logical_or(numpy.logical_or(numpy.arange(11) == NoneType, numpy.arange(11) == MoveCombingType), numpy.arange(11) == MoveRetractionType)
     
     def __init__(self, mesh, extruder, line_types, data, line_widths):
         self._mesh = mesh
@@ -36,12 +37,12 @@ class LayerPolygon:
 
         # Buffering the colors shouldn't be necessary as it is not 
         # re-used and can save alot of memory usage.
-        self._colors = self.__color_map[self._types]
-        self._color_map = self.__color_map
+        self._color_map = self.__color_map * [1, 1, 1, self._extruder] # The alpha component is used to store the extruder nr
+        self._colors = self._color_map[self._types]
         
         # When type is used as index returns true if type == LayerPolygon.InfillType or type == LayerPolygon.SkinType or type == LayerPolygon.SupportInfillType
         # Should be generated in better way, not hardcoded.
-        self._isInfillOrSkinTypeMap = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0], dtype=numpy.bool)
+        self._isInfillOrSkinTypeMap = numpy.array([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1], dtype=numpy.bool)
         
         self._build_cache_line_mesh_mask = None
         self._build_cache_needed_points = None
@@ -171,29 +172,17 @@ class LayerPolygon:
 
         return normals
 
-    __color_mapping = {
-        NoneType: Color(1.0, 1.0, 1.0, 1.0),
-        Inset0Type: Color(1.0, 0.0, 0.0, 1.0),
-        InsetXType: Color(0.0, 1.0, 0.0, 1.0),
-        SkinType: Color(1.0, 1.0, 0.0, 1.0),
-        SupportType: Color(0.0, 1.0, 1.0, 1.0),
-        SkirtType: Color(0.0, 1.0, 1.0, 1.0),
-        InfillType: Color(1.0, 0.74, 0.0, 1.0),
-        SupportInfillType: Color(0.0, 1.0, 1.0, 1.0),
-        MoveCombingType: Color(0.0, 0.0, 1.0, 1.0),
-        MoveRetractionType: Color(0.5, 0.5, 1.0, 1.0),
-    }
-
     # Should be generated in better way, not hardcoded.
     __color_map = numpy.array([
-        [1.0, 1.0, 1.0, 1.0],
-        [1.0, 0.0, 0.0, 1.0],
-        [0.0, 1.0, 0.0, 1.0],
-        [1.0, 1.0, 0.0, 1.0],
-        [0.0, 1.0, 1.0, 1.0],
-        [0.0, 1.0, 1.0, 1.0],
-        [1.0, 0.74, 0.0, 1.0],
-        [0.0, 1.0, 1.0, 1.0],
-        [0.0, 0.0, 1.0, 1.0],
-        [0.5, 0.5, 1.0, 1.0]
+        [1.0,  1.0,  1.0, 1.0], # NoneType
+        [1.0,  0.0,  0.0, 1.0], # Inset0Type
+        [0.0,  1.0,  0.0, 1.0], # InsetXType
+        [1.0,  1.0,  0.0, 1.0], # SkinType
+        [0.0,  1.0,  1.0, 1.0], # SupportType
+        [0.0,  1.0,  1.0, 1.0], # SkirtType
+        [1.0,  0.75, 0.0, 1.0], # InfillType
+        [0.0,  1.0,  1.0, 1.0], # SupportInfillType
+        [0.0,  0.0,  1.0, 1.0], # MoveCombingType
+        [0.5,  0.5,  1.0, 1.0], # MoveRetractionType
+        [0.25, 0.75, 1.0, 1.0]  # SupportInterfaceType
     ])

+ 0 - 11
cura/MultiMaterialDecorator.py

@@ -1,11 +0,0 @@
-from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
-
-class MultiMaterialDecorator(SceneNodeDecorator):
-    def __init__(self):
-        super().__init__()
-        
-    def isMultiMaterial(self):
-        return True
-
-    def __deepcopy__(self, memo):
-        return MultiMaterialDecorator()

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