Adding the DryWise postprocessing script to the Cura repo.

PP-649
This commit is contained in:
Paul Kuiper 2025-06-19 08:34:15 +02:00
parent e627d09b1d
commit e7472a83e4
5 changed files with 2381 additions and 0 deletions

View file

@ -0,0 +1,344 @@
try:
from plugins.CuraEngineBackend.StartSliceJob import *
from plugins.CuraEngineBackend.CuraEngineBackend import *
except:
from share.cura.plugins.CuraEngineBackend.StartSliceJob import *
from share.cura.plugins.CuraEngineBackend.CuraEngineBackend import *
class HackedStartSliceJob(StartSliceJob):
def run(self) -> None:
"""Runs the job that initiates the slicing."""
if self._build_plate_number is None:
self.setResult(StartJobResult.Error)
return
stack = CuraApplication.getInstance().getGlobalContainerStack()
if not stack:
self.setResult(StartJobResult.Error)
return
# Don't slice if there is a setting with an error value.
if CuraApplication.getInstance().getMachineManager().stacksHaveErrors:
self.setResult(StartJobResult.SettingError)
return
if CuraApplication.getInstance().getBuildVolume().hasErrors():
self.setResult(StartJobResult.BuildPlateError)
return
# Wait for error checker to be done.
while CuraApplication.getInstance().getMachineErrorChecker().needToWaitForResult:
time.sleep(0.1)
if CuraApplication.getInstance().getMachineErrorChecker().hasError:
self.setResult(StartJobResult.SettingError)
return
# Don't slice if the buildplate or the nozzle type is incompatible with the materials
if not CuraApplication.getInstance().getMachineManager().variantBuildplateCompatible and \
not CuraApplication.getInstance().getMachineManager().variantBuildplateUsable:
self.setResult(StartJobResult.MaterialIncompatible)
return
for extruder_stack in stack.extruderList:
material = extruder_stack.findContainer({"type": "material"})
if not extruder_stack.isEnabled:
continue
if material:
if material.getMetaDataEntry("compatible") == False:
self.setResult(StartJobResult.MaterialIncompatible)
return
# Don't slice if there is a per object setting with an error value.
for node in DepthFirstIterator(self._scene.getRoot()):
if not isinstance(node, CuraSceneNode) or not node.isSelectable():
continue
if self._checkStackForErrors(node.callDecoration("getStack")):
self.setResult(StartJobResult.ObjectSettingError)
return
# Remove old layer data.
for node in DepthFirstIterator(self._scene.getRoot()):
if node.callDecoration("getLayerData") and node.callDecoration(
"getBuildPlateNumber") == self._build_plate_number:
# Since we walk through all nodes in the scene, they always have a parent.
cast(SceneNode, node.getParent()).removeChild(node)
break
# Get the objects in their groups to print.
object_groups = []
if stack.getProperty("print_sequence", "value") == "one_at_a_time":
modifier_mesh_nodes = []
for node in DepthFirstIterator(self._scene.getRoot()):
build_plate_number = node.callDecoration("getBuildPlateNumber")
if node.callDecoration("isNonPrintingMesh") and build_plate_number == self._build_plate_number:
modifier_mesh_nodes.append(node)
for node in OneAtATimeIterator(self._scene.getRoot()):
temp_list = []
# Filter on current build plate
build_plate_number = node.callDecoration("getBuildPlateNumber")
if build_plate_number is not None and build_plate_number != self._build_plate_number:
continue
children = node.getAllChildren()
children.append(node)
for child_node in children:
mesh_data = child_node.getMeshData()
if mesh_data and mesh_data.getVertices() is not None:
temp_list.append(child_node)
if temp_list:
object_groups.append(temp_list + modifier_mesh_nodes)
Job.yieldThread()
if len(object_groups) == 0:
Logger.log("w", "No objects suitable for one at a time found, or no correct order found")
else:
temp_list = []
has_printing_mesh = False
for node in DepthFirstIterator(self._scene.getRoot()):
mesh_data = node.getMeshData()
if node.callDecoration("isSliceable") and mesh_data and mesh_data.getVertices() is not None:
is_non_printing_mesh = bool(node.callDecoration("isNonPrintingMesh"))
# Find a reason not to add the node
if node.callDecoration("getBuildPlateNumber") != self._build_plate_number:
continue
if getattr(node, "_outside_buildarea", False) and not is_non_printing_mesh:
continue
temp_list.append(node)
if not is_non_printing_mesh:
has_printing_mesh = True
Job.yieldThread()
# If the list doesn't have any model with suitable settings then clean the list
# otherwise CuraEngine will crash
if not has_printing_mesh:
temp_list.clear()
if temp_list:
object_groups.append(temp_list)
if stack.getProperty("print_sequence", "value") == "one_at_a_time":
idx = 0
while 'drywise_purge_box' not in object_groups[idx][0].getName():
idx += 1
if len(object_groups) <= idx:
break
else:
object_groups[0], object_groups[idx] = object_groups[idx], object_groups[0]
else:
group = object_groups[0]
idx = 0
while 'drywise_purge_box' not in group[idx].getName():
idx += 1
if len(group) <= idx:
break
else:
object_groups = [[group.pop(idx)], group]
global_stack = CuraApplication.getInstance().getGlobalContainerStack()
if not global_stack:
return
extruders_enabled = [stack.isEnabled for stack in global_stack.extruderList]
filtered_object_groups = []
has_model_with_disabled_extruders = False
associated_disabled_extruders = set()
for group in object_groups:
stack = global_stack
skip_group = False
for node in group:
# Only check if the printing extruder is enabled for printing meshes
is_non_printing_mesh = node.callDecoration("evaluateIsNonPrintingMesh")
extruder_position = int(node.callDecoration("getActiveExtruderPosition"))
if not is_non_printing_mesh and not extruders_enabled[extruder_position]:
skip_group = True
has_model_with_disabled_extruders = True
associated_disabled_extruders.add(extruder_position)
if not skip_group:
filtered_object_groups.append(group)
if has_model_with_disabled_extruders:
self.setResult(StartJobResult.ObjectsWithDisabledExtruder)
associated_disabled_extruders = {p + 1 for p in associated_disabled_extruders}
self._associated_disabled_extruders = ", ".join(map(str, sorted(associated_disabled_extruders)))
return
# There are cases when there is nothing to slice. This can happen due to one at a time slicing not being
# able to find a possible sequence or because there are no objects on the build plate (or they are outside
# the build volume)
if not filtered_object_groups:
self.setResult(StartJobResult.NothingToSlice)
return
self._buildGlobalSettingsMessage(stack)
self._buildGlobalInheritsStackMessage(stack)
user_id = uuid.getnode() # On all of Cura's supported platforms, this returns the MAC address which is pseudonymical information (!= anonymous).
user_id %= 2 ** 16 # So to make it anonymous, apply a bitmask selecting only the last 16 bits. This prevents it from being traceable to a specific user but still gives somewhat of an idea of whether it's just the same user hitting the same crash over and over again, or if it's widespread.
self._slice_message.sentry_id = f"{user_id}"
self._slice_message.cura_version = CuraVersion
# Add the project name to the message if the user allows for non-anonymous crash data collection.
account = CuraApplication.getInstance().getCuraAPI().account
if account and account.isLoggedIn and not CuraApplication.getInstance().getPreferences().getValue(
"info/anonymous_engine_crash_report"):
self._slice_message.project_name = CuraApplication.getInstance().getPrintInformation().baseName
self._slice_message.user_name = account.userName
# Build messages for extruder stacks
for extruder_stack in global_stack.extruderList:
self._buildExtruderMessage(extruder_stack)
backend_plugins = CuraApplication.getInstance().getBackendPlugins()
# Sort backend plugins by name. Not a very good strategy, but at least it is repeatable. This will be improved later.
backend_plugins = sorted(backend_plugins, key=lambda backend_plugin: backend_plugin.getId())
for plugin in backend_plugins:
if not plugin.usePlugin():
continue
for slot in plugin.getSupportedSlots():
# Right now we just send the message for every slot that we support. A single plugin can support
# multiple slots
# In the future the frontend will need to decide what slots that a plugin actually supports should
# also be used. For instance, if you have two plugins and each of them support a_generate and b_generate
# only one of each can actually be used (eg; plugin 1 does both, plugin 1 does a_generate and 2 does
# b_generate, etc).
plugin_message = self._slice_message.addRepeatedMessage("engine_plugins")
plugin_message.id = slot
plugin_message.address = plugin.getAddress()
plugin_message.port = plugin.getPort()
plugin_message.plugin_name = plugin.getPluginId()
plugin_message.plugin_version = plugin.getVersion()
for group in filtered_object_groups:
group_message = self._slice_message.addRepeatedMessage("object_lists")
parent = group[0].getParent()
if parent is not None and parent.callDecoration("isGroup"):
self._handlePerObjectSettings(cast(CuraSceneNode, parent), group_message)
for object in group:
mesh_data = object.getMeshData()
if mesh_data is None:
continue
rot_scale = object.getWorldTransformation().getTransposed().getData()[0:3, 0:3]
translate = object.getWorldTransformation().getData()[:3, 3]
# This effectively performs a limited form of MeshData.getTransformed that ignores normals.
verts = mesh_data.getVertices()
verts = verts.dot(rot_scale)
verts += translate
# Convert from Y up axes to Z up axes. Equals a 90 degree rotation.
verts[:, [1, 2]] = verts[:, [2, 1]]
verts[:, 1] *= -1
obj = group_message.addRepeatedMessage("objects")
obj.id = id(object)
obj.name = object.getName()
indices = mesh_data.getIndices()
if indices is not None:
flat_verts = numpy.take(verts, indices.flatten(), axis=0)
else:
flat_verts = numpy.array(verts)
obj.vertices = flat_verts
self._handlePerObjectSettings(cast(CuraSceneNode, object), obj)
Job.yieldThread()
self.setResult(StartJobResult.Finished)
def hacked_slice(self: CuraEngineBackend) -> None:
"""Perform a slice of the scene."""
self._createSnapshot()
self.startPlugins()
Logger.log("i", "Starting to slice...")
self._time_start_process = time()
if not self._build_plates_to_be_sliced:
self.processingProgress.emit(1.0)
Logger.log("w", "Slice unnecessary, nothing has changed that needs reslicing.")
self.setState(BackendState.Done)
return
if self._process_layers_job:
Logger.log("d", "Process layers job still busy, trying later.")
return
if not hasattr(self._scene, "gcode_dict"):
self._scene.gcode_dict = {} # type: ignore
# We need to ignore type because we are creating the missing attribute here.
# see if we really have to slice
application = CuraApplication.getInstance()
active_build_plate = application.getMultiBuildPlateModel().activeBuildPlate
build_plate_to_be_sliced = self._build_plates_to_be_sliced.pop(0)
Logger.log("d", "Going to slice build plate [%s]!" % build_plate_to_be_sliced)
num_objects = self._numObjectsPerBuildPlate()
self._stored_layer_data = []
if build_plate_to_be_sliced not in num_objects or num_objects[build_plate_to_be_sliced] == 0:
self._scene.gcode_dict[build_plate_to_be_sliced] = [] # type: ignore
# We need to ignore the type because we created this attribute above.
Logger.log("d", "Build plate %s has no objects to be sliced, skipping", build_plate_to_be_sliced)
if self._build_plates_to_be_sliced:
self.slice()
return
self._stored_optimized_layer_data[build_plate_to_be_sliced] = []
if application.getPrintInformation() and build_plate_to_be_sliced == active_build_plate:
application.getPrintInformation().setToZeroPrintInformation(build_plate_to_be_sliced)
if self._process is None: # type: ignore
self._createSocket()
self.stopSlicing()
self._engine_is_fresh = False # Yes we're going to use the engine
self.processingProgress.emit(0.0)
self.backendStateChange.emit(BackendState.NotStarted)
self._scene.gcode_dict[build_plate_to_be_sliced] = [] # type: ignore #[] indexed by build plate number
self._slicing = True
self.slicingStarted.emit()
self.determineAutoSlicing() # Switch timer on or off if appropriate
slice_message = self._socket.createMessage("cura.proto.Slice")
self._start_slice_job = HackedStartSliceJob(slice_message)
self._start_slice_job_build_plate = build_plate_to_be_sliced
self._start_slice_job.setBuildPlate(self._start_slice_job_build_plate)
self._start_slice_job.start()
self._start_slice_job.finished.connect(self._onStartSliceCompleted)
def apply_hack():
from UM.PluginRegistry import PluginRegistry
registry = PluginRegistry.getInstance()
backend: CuraEngineBackend = registry.getPluginObject('CuraEngineBackend')
backend.slice = lambda b=backend: hacked_slice(backend)
# def apply_hack():
# def decor(func):
# def wrapper(*args, **kwargs):
# print('wrapped')
# func(*args, **kwargs)
# print('wrapped')
# return wrapper
#
# StartSliceJob.run = decor(hacked_run)
# print('hacked')

View file

@ -0,0 +1,512 @@
import math
import time
from pathlib import Path
import typing
import numpy as np
from UM.Math.Vector import Vector
from UM.Math.Quaternion import Quaternion
from UM.Math.Polygon import Polygon
from UM.Scene.SceneNodeDecorator import SceneNodeDecorator
from UM.FileHandler.ReadFileJob import ReadFileJob
from UM.Scene.SceneNode import SceneNode
from UM.Logger import Logger
from UM.i18n import i18nCatalog
catalog = i18nCatalog("cura")
from cura.CuraApplication import CuraApplication
from cura.Scene.CuraSceneNode import CuraSceneNode
from cura.Scene.ConvexHullDecorator import ConvexHullDecorator
from cura.Settings.SettingOverrideDecorator import SettingOverrideDecorator
from cura.Settings.PerObjectContainerStack import PerObjectContainerStack
from UM.Settings.InstanceContainer import InstanceContainer
from UM.Settings.SettingInstance import SettingInstance
from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
from UM.Math.AxisAlignedBox import AxisAlignedBox
from PyQt6.QtCore import QUrl
from typing import List, Dict, Optional
from UM.Mesh.ReadMeshJob import ReadMeshJob
if typing.TYPE_CHECKING:
from UseDrywiseDryer import UseDrywiseDryer
class PurgeCubeSceneNode(CuraSceneNode):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.drywise_script: UseDrywiseDryer | None = None
self.prev_size: np.ndarray | None = None
self.prev_orient: Quaternion | None = None
self._current_volume: float = 1.0
self._initial_size: np.ndarray | None = None
self._size_bounds: np.ndarray | None = None
self.transformationChanged.connect(self._on_transform)
# Signals on adding an object
# Signals when selecting an object
# Signals when deselecting an object
# Signals when selecting which extruder should print the object
# extruder_manager.selectedObjectExtrudersChanged.connect(self.on_extruder_change)
if (app := CuraApplication.getInstance()) is None:
return
root_node = app.getController().getScene().getRoot()
root_node.childrenChanged.connect(self.verify_if_still_exists)
def set_setting(self, key: str, value: typing.Any, property_name: str = 'value'):
""" Sets per object slicing setting (or adds it if it is not there) """
if not self.hasDecoration('getStack'):
raise RuntimeError(f'{self} has no decoration `getStack`!')
# A container stack that just contains `userChanges` which is a single InstanceContainer
node_stack = typing.cast(PerObjectContainerStack, self.callDecoration('getStack'))
user_changes = typing.cast(InstanceContainer, node_stack.userChanges)
if not user_changes.hasProperty(key, property_name):
app = CuraApplication.getInstance()
stack = app.getGlobalContainerStack()
if (definition := stack.getSettingDefinition(key)) is None:
Logger.warning(f'No definition for the {key=}.')
return
# Adding a setting like this will make it visible to the user in the per-object settings.
user_changes.addInstance(SettingInstance(definition, container=user_changes))
user_changes.setProperty(key, property_name, value)
def verify_if_still_exists(self, *args):
""" Check if the user deleted this node. If so, then signal to the script to delete itself and this node """
if (app := CuraApplication.getInstance()) is None:
return
root_node = app.getController().getScene().getRoot()
for child in root_node.getChildren():
if child is self:
return
root_node.childrenChanged.disconnect(self.verify_if_still_exists)
self.drywise_script.unload()
@property
def size_bounds(self) -> np.ndarray:
return self._size_bounds
@size_bounds.setter
def size_bounds(self, value: np.ndarray):
if value.shape != (2, 3):
raise ValueError('Incorrect shape of the size bounds of the purge cube')
if self._size_bounds is not None and np.allclose(self._size_bounds, value):
return
self._size_bounds = value
self._on_transform()
@property
def volume(self) -> float:
""" Required volume of the purge model
Can change when the user changes some script parameters, such as distance from drywise output to extruder.
As a result, the script should be able to update the scale of the model.
See the script paramters for more info on which parameters affect the final volume of the purge model.
"""
return self._current_volume
@volume.setter
def volume(self, value: float):
if np.isclose(value, self.volume):
return
self._current_volume = value
xy_size_change = math.sqrt(value / self.volume)
self.scale(Vector(xy_size_change, 1.0, xy_size_change), transform_space=SceneNode.TransformSpace.Local)
@property
def size(self) -> np.ndarray:
if self._initial_size is None:
bbox = self.getBoundingBox()
self._initial_size = np.array([bbox.width, bbox.height, bbox.depth])
return self._initial_size * self.getScale().getData()
@size.setter
def size(self, new_size: np.ndarray):
if np.allclose(new_size, (current_size := self.size)):
return
# Scaling is done with respect to local coordinates and not the world coords, unlike with position and
# orientation. The reason for this is that when scaling is
self.scale(Vector(data=new_size/current_size), transform_space=SceneNode.TransformSpace.Local)
@property
def z_height(self) -> float:
return self.getBoundingBox().height
@z_height.setter
def z_height(self, value: float):
if np.isclose(value, self.z_height):
return
self.scale(Vector(1.0, value/self.z_height, 1.0), transform_space=SceneNode.TransformSpace.Local)
def place_and_resize_at(self,
pos: typing.Literal['left', 'right', 'top', 'bottom'], bed_offset: float = 5.0, min_width: float = 10.0
):
global_stack = CuraApplication.getInstance().getGlobalContainerStack()
build_volume = (
float(global_stack.getProperty('machine_width', 'value')) - 2*bed_offset,
float(global_stack.getProperty('machine_depth', 'value')) - 2*bed_offset,
)
vol = self.volume
# The height of the cube does not change
height = self.z_height
# As a rule, if the position is on left/right, then size of the cube extends along the Y axis as much as
# possible before extending along the X axis
is_along_Y = pos in ['left', 'right']
area = vol / height
length = min(area / min_width, build_volume[int(is_along_Y)])
width = max(min_width, area / length)
self.size = np.array([length, height, width])
if is_along_Y:
# Rotate 90 degrees cw if right and acw if left
q = Quaternion()
q.setByAngleAxis(angle=math.radians(90), axis=Vector(0, 1, 0))
self.rotate(q, transform_space=SceneNode.TransformSpace.World)
# By default, the positioning of the cube is in the center
self.setPosition(Vector(
x=0.0 if not is_along_Y else (build_volume[0]/2 - width) * (-1 if pos == 'left' else 1),
z=0.0,
y=0.0 if is_along_Y else (build_volume[0]/2 - width) * (-1 if pos == 'bottom' else 1),
), transform_space=SceneNode.TransformSpace.World)
def _on_transform(self, *args):
# Disconnect to prevent repeated firing if this method modifies transformations
self.transformationChanged.disconnect(self._on_transform)
self.prev_size = self.prev_size if self.prev_size is not None else self.size
self.prev_orient = self.prev_orient if self.prev_orient is not None else self.getWorldOrientation()
self._on_transform_forbid_xz_rotations()
self._on_transform_maintain_size_bounds()
self._on_transform_maintain_volume()
self.prev_size = self.size
self.prev_orient = self.getWorldOrientation()
# Reconnect after corrections are applied
self.transformationChanged.connect(self._on_transform)
def _on_transform_maintain_volume(self, *args):
""" Maintains required volume for the purge cube """
size = self.size
is_transformed = ~np.isclose(self.prev_size, size)
if not np.any(is_transformed):
return
volume_change = np.prod(size) / self.volume
if is_transformed[1] or (is_transformed[0] and is_transformed[2]):
scale_change = np.sqrt(np.array([volume_change, 1.0, volume_change]))
elif is_transformed[0]:
scale_change = np.array([1.0, 1.0, volume_change])
else:
scale_change = np.array([volume_change, 1.0, 1.0])
self.size = size / scale_change
def _on_transform_maintain_size_bounds(self, *args):
"""
Ensure that the object does not extend outside of the buildplate
Notice that this does not protect against extending the object s.t. the adhesion area are either outside
of the buildplate or placed on the non-printable area.
"""
if self.size_bounds is None:
return
# `self.size` returns the size of the object in the local coordinate system, which is not
# suitable for us, since the model can be rotated. As a result, it is best to compute the bbox
# of the object on the buildplate and work with that.
bbox = self.getBoundingBox()
global_size = [bbox.width, bbox.height, bbox.depth]
global_size_bounded = np.max(np.vstack((global_size, self.size_bounds[0, :])), axis=0)
global_size_bounded = np.min(np.vstack((global_size_bounded, self.size_bounds[1, :])), axis=0)
out_of_bounds = ~np.isclose(global_size_bounded, global_size)
if not np.any(out_of_bounds):
return
# Since we are working with the bbox, we have to transform on the global coordinate system.
Logger.debug(f'The purge cube was scaled to avoid being larger than the buildplate.')
scaling_factor = np.where(out_of_bounds, global_size_bounded / global_size, 1.0)
self.scale(Vector(data=scaling_factor), transform_space=SceneNode.TransformSpace.World)
def _on_transform_forbid_xz_rotations(self, *args):
orient = np.abs(self.getWorldOrientation().getData())
if np.allclose(orient[[0, 2]], 0.0):
return
Logger.debug(f'Rotations of the purge cube not around the vertical Y-axis are forbidden. Reverting to previous state.')
self.setOrientation(self.prev_orient, transform_space=SceneNode.TransformSpace.World)
def addDecorator(self, decorator: SceneNodeDecorator) -> None:
""" Modified in order to circumvent the type check that does not allow subclasses """
if any(isinstance(dec, type(decorator)) for dec in self._decorators):
Logger.log("w", "Unable to add the same decorator type (%s) to a SceneNode twice.", type(decorator))
return
try:
decorator.setNode(self)
except AttributeError:
Logger.logException("e", "Unable to add decorator.")
return
self._decorators.append(decorator)
self.decoratorsChanged.emit(self)
def getDecorator(self, dec_type: type[SceneNodeDecorator]) -> Optional[SceneNodeDecorator]:
""" Modified in order to circumvent the type check that does not allow subclasses """
for decorator in self._decorators:
if isinstance(decorator, dec_type):
return decorator
return None
def removeDecorator(self, dec_type: type[SceneNodeDecorator]) -> None:
""" Modified in order to circumvent the type check that does not allow subclasses """
for decorator in self._decorators:
if isinstance(decorator, dec_type):
decorator.clear()
self._decorators.remove(decorator)
self.decoratorsChanged.emit(self)
break
# def _on_node_addition(self, *args):
# app = CuraApplication.getInstance()
# if app is None:
# return
#
# root_node = app.getController().getScene().getRoot()
# children = root_node.getChildren()
#
# idx_first_node = 0
# while not isinstance(children[idx_first_node], CuraSceneNode):
# idx_first_node += 1
# if idx_first_node >= len(children):
# return
# if children[idx_first_node] is self.node:
# return
#
# idx_purge_cube = idx_first_node
# while children[idx_purge_cube] is not self.node:
# idx_purge_cube += 1
# if idx_purge_cube >= len(children):
# return
#
# # Move the purge cube node to the last position
# root_node._children = [
# *children[:idx_first_node],
# self.node,
# *children[idx_first_node:idx_purge_cube],
# *children[idx_purge_cube + 1:],
# ]
# # root_node.childrenChanged.emit(root_node)
#
# def _enforce_print_order(self):
# app = CuraApplication.getInstance()
# if app is None or self.node is None:
# return
# if self.node.printOrder == 1:
# return
#
# root_nodes = app.getController().getScene().getRoot()
# printing_nodes = [child for child in root_nodes.getChildren() if isinstance(child, CuraSceneNode)]
# for node in printing_nodes:
# if node.printOrder < self.node.printOrder:
# node.printOrder += 1
# self.node.printOrder = 1
class PurgeCubeConvexHullDecorator(ConvexHullDecorator):
def __init__(self, nozzle_height: float = 2.0):
super().__init__()
self.nozzle_height = nozzle_height
def getNode(self) -> PurgeCubeSceneNode | None:
return self._node
def _isSingularOneAtATimeNode(self) -> bool:
""" Always printed as a whole, therefore a one-at-a-time node """
return True
def _getHeadAndFans(self):
""" If the z_height of the node is small, allow placing other models much much closer to the `PurgeCube` """
if self.getNode().z_height > self.nozzle_height:
return super()._getHeadAndFans()
# To simulate the nozzle. Even at that height the nozzle can hit the purge cube
return Polygon.approximatedCircle(8.0 / 2)
def _compute2DConvexHeadFull(self) -> Optional[Polygon]:
convex_hull = self._compute2DConvexHull()
convex_hull = self._add2DAdhesionMargin(convex_hull)
if not convex_hull:
return None
head_hull = convex_hull.getMinkowskiHull(self._getHeadAndFans())
if self._global_stack is not None \
and self._global_stack.getProperty("print_sequence", "value") == "all_at_once":
brim_hull = self._add2DAdhesionMargin(convex_hull)
return head_hull.unionConvexHulls(brim_hull)
return head_hull
def load_purge_cube(path: str, node_name: str = None) -> PurgeCubeSceneNode | None:
""" Copy pasted from CuraApplication.readLocalFile
Removed unnecessary lines since we know that we are loading an stl
"""
file = QUrl(Path(__file__).parent.joinpath(path).as_uri())
Logger.log("i", "Attempting to read file %s", file.toString())
if not file.isValid():
raise FileNotFoundError
if (app := CuraApplication.getInstance()) is None:
return None
# No idea what isBlockSlicing decorator is, so I decided to just leave it as it is.
scene = app.getController().getScene()
for node in DepthFirstIterator(scene.getRoot()):
if node.callDecoration("isBlockSlicing"):
app.deleteAll()
break
f = file.toLocalFile()
app._currently_loading_files.append(f)
# Using UM.FileHandler.ReadFileJob instead of UM.Mesh.ReadMeshJob, because the latter tries to be smart and
# auto-scales the model if it is too small. This is annoying, so I am not using it.
job = ReadFileJob(filename=f, handler=app.getMeshFileHandler(), add_to_recent_files=False)
job.start()
# I do not really like callbacks that much. I wish cura used AsyncIO or at least Future or Promise, but no,
# it uses callbacks. The way this is fine, since the model is just a cube with 8 vertices, so it is not very
# computationally intensive (so it would not block the UI if this is the thread on which the UI runs).
while not job.isFinished():
if job.hasError():
raise job.getError()
time.sleep(0.1)
result = job.getResult()
if not isinstance(result, typing.Sequence) or len(result) <= 0:
raise RuntimeError('Did not load any meshes for some reason...')
# Code taken from CuraApplication._readMeshFinished where this code converts a SceneNode to
# CuraSceneNode
original_node = result[0]
node = PurgeCubeSceneNode(name=node_name)
node.setMeshData(original_node.getMeshData())
node.source_mime_type = original_node.source_mime_type
# Setting meshdata does not apply scaling.
if original_node.getScale() != Vector(1.0, 1.0, 1.0):
node.scale(original_node.getScale())
# Ensure that the correct decorator is attached to this new node.
if node.getDecorator(ConvexHullDecorator):
node.removeDecorator(ConvexHullDecorator)
node.addDecorator(PurgeCubeConvexHullDecorator())
# Finish node creation using the default function
job._result = [node]
app._readMeshFinished(job)
return node
# def _on_model_finish_loading(self, filename: str):
# """ Find the `CuraSceneNode` that contains the purge cube that was loaded by cura
#
# `CuraApplication.readLocalFile` loads the stl files in a separate worker thread. As a result, it does not
# actually return the resulting node. So, after it finishes loading, we have to unironially find it. So,
# all this method does is it finds to loaded node and saves its reference
#
# """
#
# if self.path != Path(filename):
# return
# app = CuraApplication.getInstance()
# root_node = app.getController().getScene().getRoot()
#
# candidate_nodes = [n for n in root_node.getChildren() if n.getName() == self.path.name]
# if len(candidate_nodes) == 0:
# raise RuntimeError('No purge cube node was found!')
# elif len(candidate_nodes) == 1:
# self.node = candidate_nodes[0]
# assert isinstance(self.node, CuraSceneNode)
# else:
# raise NotImplementedError('Likely multiple purge cubes created by multiple scripts. We are not dealing with that for now')
#
#
# def _getNozzle(chd: ConvexHullDecorator) -> Polygon:
# if not chd._global_stack:
# return Polygon()
#
# polygon = Polygon.approximatedCircle(8.0/4)
# return polygon
#
# def decorator(chd: ConvexHullDecorator, func):
# def wrapper(*args, **kwargs):
# return chd._compute2DConvexHeadFull()
# return wrapper
#
# def decoratorMin(chd: ConvexHullDecorator, func):
# def wrapper(*args, **kwargs):
# if self.z_height > 2.0:
# return func()
#
# return chd._compute2DConvexHeadFull()
# return wrapper
#
# def decoratorHeadAndFans(chd: ConvexHullDecorator, func):
# def wrapper(*args, **kwargs):
# if self.z_height > 2.0:
# return func()
# polygon = Polygon.approximatedCircle(8.0/4)
# offset_x = 0.0 # chd._getSettingProperty("machine_nozzle_offset_x", "value")
# offset_y = 0.0 # chd._getSettingProperty("machine_nozzle_offset_y", "value")
# return polygon.translate(-offset_x, -offset_y)
# return wrapper
#
#
# convex_hull_decor: ConvexHullDecorator | None = self.node.getDecorator(ConvexHullDecorator)
# if convex_hull_decor is not None:
# convex_hull_decor._isSingularOneAtATimeNode = lambda *args: True
# convex_hull_decor._getHeadAndFans = decoratorHeadAndFans(convex_hull_decor, convex_hull_decor._getHeadAndFans)
# convex_hull_decor.getConvexHullHeadFull = convex_hull_decor._compute2DConvexHeadFull
# #convex_hull_decor._compute2DConvexHeadFull = decorator(convex_hull_decor, convex_hull_decor._compute2DConvexHeadFull)
# #convex_hull_decor._compute2DConvexHeadMin = decoratorMin(convex_hull_decor, convex_hull_decor._compute2DConvexHeadMin)
#
# app.fileCompleted.disconnect(self._on_model_finish_loading) # No need to continue watching
# self.node.transformationChanged.connect(self._on_transform)
# root_node = app.getController().getScene().getRoot()
# root_node.childrenChanged.connect(self._on_node_addition)
# self._current_scale = self.node.getScale()
#
# if self.on_load is not None:
# self.on_load()

View file

@ -0,0 +1,143 @@
# Motivation
This script is a highly requested feature from our clients. Normally, the dryer requires manual loading of the
filament in the printer 40 mins after starting drying and manually starting the print 20 mins after loading.
This means that the user must be nearby the dryer for up to 1 hour after starting drying. This is inconvenient because:
1. It is distracting, especially when using multiple dryers.
2. You cannot start the print last minute before you leave work.
3. One may forget about the dryer, and as a result the model will not get printed at all.
This script attempts to resolve these issues by providing a more automated method of drying and printing, at
the cost of ~2 g of purged wet filament (depending of the printer and dryer setup)
## How the script works
1. The wet filament is passed through the dryer and is attached to the extruder. Notice that the filament is **not** loaded in the printer, but the end of the filament is gripped by the extruder gear.
2. The dryer is switched on and put in the printing mode, the print file is selected and the printer starts printing immediately.
3. The script loads the filament in the printer, meaning that the printer moves the filament until it reaches the
nozzle. The loading is done roughly at the printing speed defined by the slicer to ensure that the filament is
dried uniformly.
4. The loaded filament is wet, because it was not processed by the dryer. This filament now needs to be purged, so
a purge cube is printed at the edge of the buildplate. The purge cube is printed until all unprocessed material is
removed.
5. The loaded filament is now dry. The printer begins printing the sliced model using the dried material. At the
same time, the dryer is continuing drying the material.
## How to install the script
It is expected that in the future versions no installation will be necessary as cura will integrate this script
with the `PostProcessingPlugin` and make it available for all clients. Until then, the script has to be installed
according to the following procedure:
1. Download `UseDrywiseDryer.py`. Any other files in the repository are not required for proper functioning of the
script.
2. Locate your cura installation folder. Normally, it is installed in
`C:\Program Files\UltiMaker Cura {version_number}`.
3. In the cura directory find the folder `~\share\cura\plugins\PostProcessingPlugin\scripts` and put the script file(s) there.
4. Restart cura (if already running). You are now ready to use the script.
## How to use the script
### Slicing
1. Launch cura.
2. Add your models and choose your printing profile
2. Go to `Extensions/Post Processing/Modify G-code`.
3. Click `Add a script` button and select `Use Drywise Dryer`. It is usually located at the bottom of all scripts.
4. Enter the required parameters. For the printer that we have tested, the "Distance from extruder to nozzle"
parameter is already pre-defined. However, the "Distance from dryer output to extruder" depends on your set-up,
you will need to work it out yourself. See [Explanation of script parameters] section on the explanation of
individual parameters as well as how to choose them.
5. Close the script window and slice the model.
6. Unforturnately, for now the purging cube is not shown in the "Preview". This is one of the current limitations
on the script that will be addressed soon. However, you will be able to see the purging cube when viewing the gcode
directly. Save the gcode and drag and drop it in the cura application.
7. Ensure that the purge cube does not overlap with your model. If it does, then you will need to move your models
away from the location where the purging cube will be printed and reslice the file again.
8. You are now ready to print your model.
### Drying and printing
1. Start the dryer and select the material to be dried.
2. Press "Start" to move to the filament loading screen. Sharpen the filament and pass it through the dryer,
like during normal operation. When you have done so, press "Next" on the dryer to finish loading procedure.
2. Pull the filament out of the dryer and place it inside the extruder s.t. the extruder gear grips it firmly.
Ensure that the extruder managed to grip the filament. You might want to test this by trying to pull
the filament out of the extruder gear. **Note: do not load the filament all the way to the nozzle.
If you do so, you may damage your printer. The printer will load the filament automatically during operation.**
3. Position the dryer close to the printer, like during normal operation.
4. Due to the way the script works, you will need to enter the continuous cycle immediately. At the moment, you
should be at the "Pre-Drying Cycle" screen with the following buttons available: "Start", "Abort", and "Skip".
To enter the continuous cycle immediately press the following buttons in the following sequence:
> "Skip" > "Skip Pre-Drying" > "Skip" > "Skip Loading" > "Skip" > "Skip Preparing" > "Print started".
5. You should now be at the printing screen, which you normally see when the printer is printing and the dryer
is drying the filament as it is being extruded by the printer.
5. Select your print file on your printer and start printing normally.
6. You may return to whatever you are working on.
## Explanation of script parameters
- **Extruder in Use**: Which extruder is connected to the dryer
- **Distance from extruder to nozzle**: Self-explanatory. Notice that this value is usually constant among printers
of the same model. As a result, we provide some preset values for the printers that we have tested.
- **Distance from dryer output to extruder**: Self-explanatory. This value is highly dependent on your printer-drywise
set up, therefore we leave it up to you to measure it.
- **Position of the purge cube**: Allows you to choose on which side of the buildplate the purge cube is printed.
- **Cube height**: By default, the cube height is 1.5 mm. Please, review the Issue #1, which describes our motivations
for why it was not made any larger.
To measure the `Distance from extruder to nozzle` parameter, load the filament into the printer, mark the location of
the extruder gear as close as you can, extract the filament and measure the distance between the filament tip
# Current limitations
- The purge cube does not appear in the "Prepare" and "Preview" tabs
- There is no check for whether the model is printed too close to the purge cube
- Two separate gcodes need to be sliced (one with the script and one without).
- Wet filament has to be purged. This results in wasted material and money. However:
1. Your time and attention may be worth more than purged material. After all, this is why you
bought Drywise - to stop worrying about wet filament and focus on important work.
2. The amount of purged material is constant regardless of the length of the print. Consequently,
the larger the print, the smaller relative amount of material is wasted. As an example, for Ultimaker printers and
Ender S1 Pro, the amount of material purged is ~2.0 g, therefore when printing a somewhat large model weighing 70 g,
only 2.8% of the printed material is wasted.
3. After printing the filament inside the printer and the dryer stay dry for some time. As a
result, you may continue printing later in the day without purging any more material. However, after **X** hours
we cannot guarantee the dryness of the material
# Troubleshooting
### Material leaking out of the nozzle during loading
It appears that you have overestimated the loading length of the filament. The solution is to reduce
the value of the **Distance from extruder to nozzle** parameter.
### First layer of the purge cube looks terrible / The purge cube got detached
This could be caused by **Distance from extruder to nozzle** being too low, so when the printer starts
purging the wet material, the filament is not fully loaded. You may try to increase the value of
this parameter.
Note that bed adhesion is a common 3d printing problem, so the script may not be at fault. Poor bed adhesion
may also be caused by:
- Miscalibrated bed leveling and/or z offset.
- Absense of adhesive on the buildplate
### Filament is ground in the extruder
Although unlikely, this might be caused by the script where the **Distance from extruder to nozzle** parameter
is grossly overestimated. However, it is more likely that the problem is caused by:
- A clog in the nozzle
- Too low nozzle temperature during printing
- Extruder tension too small
### Material still wet even after the purge cube
If the bottom of the model is still wet, while the top is dry, then you did not purge enough wet material.
To fix this problem, increase **Distance from dryer output to extruder** accordingly.
If the whole model is still wet, then there may be a problem with your Drywise. Please, contact our support
team at support@drywise.co
### The dryer shows the "Filament stopped moving" message during loading
The "Filament stopped moving" means that the dryer sensed that the filament moved <5cm in the last 5 mins.
A possible simple solution for this problem is to check if the extruder is actually gripping the filament.
If not, then check if the filament is ground. If yes, review the "Filament is ground in the extruder" section.
Otherwise, the cause of this problem may be complex, therefore, please, contact our support
team at support@drywise.co

File diff suppressed because it is too large Load diff

Binary file not shown.