diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index 223b576ff398..a8099dbefefe 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -1425,6 +1425,18 @@ Enable the "M118" and "RESPOND" extended # override the "default_type". ``` +### [exclude_object] +Enables support to exclude or cancel individual objects during the printing +process. + +See the [exclude objects guide](Exclude_Object.md) and +[command reference](G-Codes.md#exclude-object) +for additional information. + +``` +[exclude_object] +``` + ## Resonance compensation ### [input_shaper] diff --git a/docs/Exclude_Object.md b/docs/Exclude_Object.md new file mode 100644 index 000000000000..c1b6cd6e36cb --- /dev/null +++ b/docs/Exclude_Object.md @@ -0,0 +1,106 @@ +# Exclude Obects + +The `[exclude_object]` module allows Klipper to exclude objects while a print is in progress. +To enable this feature include an [exclude_object config section](Config_Reference.md#exclude_object) +(also see the [command reference](G-Codes.md#exclude-object).) + +Unlike other 3D printer firmware options, a printer running Klipper utilizes a suite of +components and users have many options to choose from. Therefore, in order to provide a +a consistent user experience, the `[exclude_object]` moudle will establish a contract or API +of sorts. The contract covers the contents of the gcode file, how the internal state of the +module is controlled, and how that state is provided to clients. + +## Workflow Overview +A typical worfklow for printing a file might look like this: +1. Slicing is completed and the file is uploaded for printing. During the upload, the file + is processed and `[exclude_object]` markup is added to the file. +1. When printing starts, Klipper will reset the `[exclude_object]` status. +1. When Klipper processes the `DEFINE_OBJECT` block, it will update the status with the known + objects and pass it on to clients. +1. The client may use that information to present a UI to the user so that progress can be + tracked. Klipper will update the status to include the currently printing object which + the client can use for display purposes. +1. If the user requests that an object be cancelled, the client will issue an `EXCLUDE_OBJECT` + command to Klipper. +1. When Klipper process the command, it will add the object to the list of excluded objects + and update the status for the client. +1. The client will receive the updated status from Klipper and can use that information to + reflect the object's status in the UI. +1. When printing finishes, the `[exclude_object]` status will continue to be available until + another action resets it. + +## The GCode File +The specialized GCode processing needed to support exlucing objects does not fit into Klipper's +core design goals. Therefore, this module requires that the file is processed before being sent +to Klipper for printing. Using a post-process script in the slicer or having middleware process +the file on upload are two possibilities for preparing the file for Klipper. + +### GCode File Command Reference +`DEFINE_OBJECT`: Provides a summary of an object in the file. Objects don't need to be defined +in order to be referenced by other commands. The primary purpose of this command is to provide +information to the UI without needing to parse the entire gcode file. + +It takes the following parameters: + +- `NAME`: This parameter is required. It is the identifier used by other commands in this module. + The name must be unique among all objects in the file being printed, and must be consistent across all layers. +- `CENTER`: An X,Y coordinate for the object. Typically it will be in the center of the object, but + that is not a requirement. While this parameter is technically optional, not including it will + likely limit the functionality of other components. Example: `CENTER=150.07362,138.27616`. +- `POLYGON`: An array of X,Y coordinates specifying vertices that define a polygon outline for the object. + The polygon information is primarly for the use of graphical interfces. This parameter is optional, but + like `CENTER`, the functionality of other components may be reduced if it is not given. It is left to the + software processing the gcode file to determine the complexity of the polygon being provided. At a + minimum, it is recommended that this be a bounding box. + Example: `POLYGON=[[142.7,130.95],[142.7,145.75],[157.5,145.75],[157.5,130.95]]` + +`START_CURRENT_OBJECT`: This command takes a `NAME` parameter and denotes the start of +the gcode for an object on the current layer. + +`END_CURRENT_OBJECT`: Denotes the end of the object's gcode for the layer. It is paired with +`START_CURRENT_OBJECT`. A `NAME` parameter is optional. A warning will be given if +an `END_CURRENT_OBJECT` command is encountered when it wasn't expected or of the given +name does not match the current object. + +## Managing Excluded Objects +The `EXCLUDE_OBJECT` command is used to request that Klipper stops printing the specified object. +The command may be executed at any time and Klipper will track the object name until the status is +reset. This command may be executed manually, but will often be part of the exclude object implementation +of a client. + +`LIST_OBJECTS`, `LIST_EXCLUDED_OBJECTS`, and `EXCLUDE_OBJECT_RESET` commands are also available + +### Command Reference +`EXCLUDE_OBJECT`: This command takes a `NAME` parameter and instructs Klipper to ignore +gcode that is marked by `START_CURRENT_OBJECT` and `END_CURRENT_OBJECT` for the named +object. The command can be issued for the currently printing object. In that case, Klipper will +immediately move on to the next object. An object can be marked for exclusion before Klipper +encounters it in the file. + +`LIST_OBJECTS`: Lists the objects known to Klipper. + +`LIST_EXCLUDED_OBJECTS`: Lists the excluded objects. + +`EXCLUDE_OBJECT_RESET`: Resets the state of the `[exclude_object]` module. This clears the lists +containing known objects, cancelled objects, and the name of the current object. + +## Status Infomation +The state of this module is provided to clients by the [exclude_object status](Status_Reference.md#exclude_object). + +The status is reset when: +- The Klipper firmware is restarted. +- There is a reset of the `[virtual_sdcard]`. Notably, this is reset by Klipper at the start of a print. +- When an `EXCLUDE_OBJECT_RESET` command is issued. + +The list of defined objects is represented in the `exclude_object.objects` status field. In a well defined +gcode file, this will be done with `DEFINE_OBJECT` commands at the beginning of the file. This will provide +clients with object names and coordinates so the UI can provide a graphical representation of the objects if +desired. + +As the print progresses, the `exclude_object.current_object` status field will be updated as Klipper processes +`START_CURRENT_OBJECT` and `END_CURRENT_OBJECT` commands. The `current_object` field will be set even if the +object has been excluded. + +As `EXCLUDE_OBJECT` commands are issued, the list of excluded objects is provided in the `exclude_object.excluded_objects` +array. Since Klipper looks ahead to process upcoming gcode, there may be a delay betwen when the command is +issued and when the status is updated. diff --git a/docs/G-Codes.md b/docs/G-Codes.md index 1d14c949b34d..77d19195ee3f 100644 --- a/docs/G-Codes.md +++ b/docs/G-Codes.md @@ -284,6 +284,46 @@ parameter is provided it arranges for the given endstop phase setting to be written to the config file (in conjunction with the SAVE_CONFIG command). +### [exclude_object] + +The following commands are available when an +[exclude_object config section](Config_Reference.md#exclude_object) is +enabled (also see the [exclude object guide](Exclude_Object.md)): + +#### START_CURRENT_OBJECT +`START_CURRENT_OBJECT NAME=`: This denotes the start of +a block of gcode for the object identified by the `NAME` parameter. +If this object has been marked as excluded, any gcode between this +command and the matching `END_CURRENT_OBJECT` command is not printed. + +#### END_CURRENT_OBJECT +`END_CURRENT_OBJECT [NAME=]`: Denotes the end of a block +of gcode that was opened with `START_CURRENT_OBJECT`. + +#### DEFINE_OBJECT +`DEFINE_OBJECT NAME= [CENTER=] +[POLYGON=<[[x0,y0],[x1,y1],...,[xn,yn]>]`: Informs Klipper of an +object in a gcode file. The object's `NAME` is used by the other +commands for identification. The `CENTER` and `POLYGON` parameters +provide a representation of the object's geometery to UI clients. + +#### EXCLUDE_OBJECT +`EXCLUDE_OBJECT NAME=`: Instructs Klipper to skip printing +the object identified by `NAME`. + +#### LIST_OBJECTS +`LIST_OBJECTS [VERBOSE=1]`: Lists the objects known to Klipper. +Without parameters, it will return a list of object names. If the +`VERBOSE` parameter is given (value doesn't matter), it will +return object details. + +#### LIST_EXCLUDED_OBJECTS +`LIST_EXCLUDED_OBJECTS`: Lists the excluded objects. + +#### EXCLUDE_OBJECT_RESET +`EXCLUDE_OBJECT_RESET`: Clears the current list objects and excluded +objects. + ### [extruder] The following commands are available if an diff --git a/docs/Status_Reference.md b/docs/Status_Reference.md index a23c46a90316..c4d6c679d800 100644 --- a/docs/Status_Reference.md +++ b/docs/Status_Reference.md @@ -60,6 +60,39 @@ The following information is available in the forward direction minus the total number of steps taken in the reverse direction since the micro-controller was last restarted. +## exclude_object + +The following inforation is avaialbe in the [exclude_object](Exclude_Object.md) object: +- `objects`: An array of the known objects as provided by the `DEFINE_OBJECT` command. This is the same information + provided by the `LIST_OBJECTS` command in verbose mode. The `center` and `polygon` fields will not be present if + they weren't provided. Here is a JSON sample: +``` +[ + { + "polygon": [ + [ 156.25, 146.2511675 ], + [ 156.25, 153.7488325 ], + [ 163.75, 153.7488325 ], + [ 163.75, 146.2511675 ] + ], + "name": "CYLINDER_2_STL_ID_2_COPY_0", + "center": [ 160, 150 ] + }, + { + "polygon": [ + [ 146.25, 146.2511675 ], + [ 146.25, 153.7488325 ], + [ 153.75, 153.7488325 ], + [ 153.75, 146.2511675 ] + ], + "name": "CYLINDER_2_STL_ID_1_COPY_0", + "center": [ 150, 150 ] + } +] +``` +- `excluded_objects`: An array of strings listing the names of excluded objects. +- `current_object`: The name of the object currently being printed. + ## fan The following information is available in diff --git a/klippy/extras/exclude_object.py b/klippy/extras/exclude_object.py new file mode 100644 index 000000000000..53b783bb9340 --- /dev/null +++ b/klippy/extras/exclude_object.py @@ -0,0 +1,263 @@ +# Exclude moves toward and inside objects +# +# Copyright (C) 2019 Eric Callahan +# Copyright (C) 2021 Troy Jacobson +# +# This file may be distributed under the terms of the GNU GPLv3 license. + +import logging +import json + +class ExcludeObject: + def __init__(self, config): + self.printer = config.get_printer() + self.gcode = self.printer.lookup_object('gcode') + self.gcode_move = self.printer.load_object(config, 'gcode_move') + self.printer.register_event_handler('klippy:connect', + self._handle_connect) + self.printer.register_event_handler("sdcard:reset_file", + self._reset_file) + self.next_transform = None + self.objects = {} + self.excluded_objects = [] + self.current_object = None + self.in_excluded_region = False + self.extruder_idx = 0 + self.extrusion_offsets = [[0., 0., 0., 0.]] + self.last_position = [0., 0., 0., 0.] + self.last_position_extruded = [0., 0., 0., 0.] + self.last_position_excluded = [0., 0., 0., 0.] + self.gcode.register_command( + 'START_CURRENT_OBJECT', self.cmd_START_CURRENT_OBJECT, + desc=self.cmd_START_CURRENT_OBJECT_help) + self.gcode.register_command( + 'END_CURRENT_OBJECT', self.cmd_END_CURRENT_OBJECT, + desc=self.cmd_END_CURRENT_OBJECT_help) + self.gcode.register_command( + 'EXCLUDE_OBJECT', self.cmd_EXCLUDE_OBJECT, + desc=self.cmd_EXCLUDE_OBJECT_help) + self.gcode.register_command( + 'EXCLUDE_OBJECT_RESET', self.cmd_EXCLUDE_OBJECT_RESET, + desc=self.cmd_EXCLUDE_OBJECT_RESET_help) + self.gcode.register_command( + 'DEFINE_OBJECT', self.cmd_DEFINE_OBJECT, + desc=self.cmd_DEFINE_OBJECT_help) + self.gcode.register_command( + 'LIST_OBJECTS', self.cmd_LIST_OBJECTS, + desc=self.cmd_LIST_OBJECTS_help) + self.gcode.register_command( + 'LIST_EXCLUDED_OBJECTS', self.cmd_LIST_EXCLUDED_OBJECTS, + desc=self.cmd_LIST_EXCLUDED_OBJECTS_help) + def _setup_transform(self): + if not self.next_transform: + tuning_tower = self.printer.lookup_object('tuning_tower') + if tuning_tower.is_testing(): + logging.info('The ExcludeObject move transform is not being ' + 'loaded due to Tuning tower being Active') + return + + self.next_transform = self.gcode_move.set_move_transform(self, + force=True) + self.last_position[:] = self.get_position() + self.last_position_extruded[:] = self.get_position() + self.last_position_excluded[:] = self.get_position() + self.extrusion_offsets = [[0., 0., 0., 0.]] + self.max_position_extruded = 0 + self.max_position_excluded = 0 + self.extruder_adj = 0 + self.objects = {} + self.excluded_objects = [] + self.current_object = None + + def _handle_connect(self): + self.toolhead = self.printer.lookup_object('toolhead') + + def _reset_file(self): + self.objects = {} + self.excluded_objects = [] + self.current_object = None + if self.next_transform: + tuning_tower = self.printer.lookup_object('tuning_tower') + if tuning_tower.is_testing(): + logging.info('The Exclude Object move transform was not ' + 'unregistered because it is not at the head of the ' + 'transform chain.') + return + + self.gcode_move.set_move_transform(self.next_transform, force=True) + self.next_transform = None + self.gcode_move.reset_last_position() + + def get_position(self): + extr_idx = self.toolhead.get_extruder().get_name()[8:] + if extr_idx: + self.extruder_idx = int(extr_idx) + else: + self.extruder_idx = 0 + len_diff = self.extruder_idx + 1 - len(self.extrusion_offsets) + if len_diff > 0: + self.extrusion_offsets.extend([[0., 0., 0., 0.]] * len_diff) + offset = self.extrusion_offsets[self.extruder_idx] + pos = self.next_transform.get_position() + for i in range(4): + self.last_position[i] = pos[i] + offset[i] + return list(self.last_position) + + def _normal_move(self, newpos, speed): + offset = self.extrusion_offsets[self.extruder_idx] + + self.last_position[:] = newpos + self.last_position_extruded[:] = self.last_position + self.max_position_extruded = max(self.max_position_extruded, newpos[3]) + + # These next few conditionals handle the moves immediately after leaving + # and excluded object. The toolhead is at the end of the last printed + # object and the gcode is at the end of the last excluded object. + # + # Ideally, there will be Z and E moves right away to adjust any offsets + # before moving away from the last position. Any remaining corrections + # will be made on the firs XY move. + if (offset[0] != 0 or offset[1] != 0) and \ + (newpos[0] != self.last_position_excluded[0] or \ + newpos[1] != self.last_position_excluded[1]): + offset[0] = 0 + offset[1] = 0 + offset[2] = 0 + offset[3] += self.extruder_adj + self.extruder_adj = 0 + + if offset[2] != 0 and newpos[2] != self.last_position_excluded[2]: + offset[2] = 0 + + if self.extruder_adj != 0 and \ + newpos[3] != self.last_position_excluded[3]: + offset[3] += self.extruder_adj + self.extruder_adj = 0 + + tx_pos = newpos[:] + for i in range(4): + tx_pos[i] = newpos[i] - offset[i] + self.next_transform.move(tx_pos, speed) + + def _ignore_move(self, newpos, speed): + offset = self.extrusion_offsets[self.extruder_idx] + for i in range(3): + offset[i] = newpos[i] - self.last_position_extruded[i] + offset[3] = offset[3] + newpos[3] - self.last_position[3] + self.last_position[:] = newpos + self.last_position_excluded[:] =self.last_position + self.max_position_excluded = max(self.max_position_excluded, newpos[3]) + + def _move_into_excluded_region(self, newpos, speed): + self.in_excluded_region = True + self._ignore_move(newpos, speed) + + def _move_from_excluded_region(self, newpos, speed): + self.in_excluded_region = False + offset = self.extrusion_offsets[self.extruder_idx] + + # This adjustment value is used to compensate for any retraction + # differences between the last object printed and excluded one. + self.extruder_adj = self.max_position_excluded \ + - self.last_position_excluded[3] \ + - (self.max_position_extruded - self.last_position_extruded[3]) + self._normal_move(newpos, speed) + + def _test_in_excluded_region(self): + # Inside cancelled object + return self.current_object in self.excluded_objects + + def get_status(self, eventtime=None): + status = { + "objects": list(self.objects.values()), + "excluded_objects": list(self.excluded_objects), + "current_object": self.current_object + } + return status + + def move(self, newpos, speed): + move_in_excluded_region = self._test_in_excluded_region() + self.last_speed = speed + + if move_in_excluded_region: + if self.in_excluded_region: + self._ignore_move(newpos, speed) + else: + self._move_into_excluded_region(newpos, speed) + else: + if self.in_excluded_region: + self._move_from_excluded_region(newpos, speed) + else: + self._normal_move(newpos, speed) + + cmd_START_CURRENT_OBJECT_help = "Marks the beginning the current object" \ + " as labeled" + def cmd_START_CURRENT_OBJECT(self, params): + name = params.get('NAME').upper() + self.current_object = name + self.was_excluded_at_start = self._test_in_excluded_region() + cmd_END_CURRENT_OBJECT_help = "Markes the end the current object" + def cmd_END_CURRENT_OBJECT(self, gcmd): + if self.current_object == None and self.next_transform: + gcmd.respond_info("END_CURRENT_OBJECT called, but no object is" + " currently active") + return + name = gcmd.get('NAME', default=None) + if name != None and name.upper() != self.current_object: + gcmd.respond_info("END_CURRENT_OBJECT NAME=%s does not match the" + " current object NAME=%s" % + (name.upper(), self.current_object)) + + is_excluded = self._test_in_excluded_region() + self.current_object = None + cmd_EXCLUDE_OBJECT_help = "Cancel moves inside a specified objects" + def cmd_EXCLUDE_OBJECT(self, params): + name = params.get('NAME').upper() + if name not in self.excluded_objects: + self.excluded_objects.append(name) + cmd_EXCLUDE_OBJECT_RESET_help = "Resets the exclude_object state by" \ + " clearing the list of object definitions" \ + " and removed objects" + def cmd_EXCLUDE_OBJECT_RESET(self, params): + self._reset_file() + cmd_LIST_OBJECTS_help = "Lists the known objects" + def cmd_LIST_OBJECTS(self, gcmd): + if gcmd.get('VERBOSE', None) is not None: + object_list = " ".join (str(x) for x in self.objects.values()) + else: + object_list = " ".join(self.objects.keys()) + gcmd.respond_info(object_list) + cmd_LIST_EXCLUDED_OBJECTS_help = "Lists the excluded objects" + def cmd_LIST_EXCLUDED_OBJECTS(self, gcmd): + object_list = " ".join (str(x) for x in self.excluded_objects) + gcmd.respond_info(object_list) + cmd_DEFINE_OBJECT_help = "Provides a summary of an object" + def cmd_DEFINE_OBJECT(self, params): + self._setup_transform() + name = params.get('NAME').upper() + if self.next_transform is None: + logging.info('The definition for %s was not registered ' + 'since the Exclude Object move transform is not enabled. This ' + 'is most likely due to an active Tuning Tower command.', name) + params.respond_info("Exclude Object is not active while a " + "Tuning Tower test is in progress.") + return + + center = params.get('CENTER', default=None) + polygon = params.get('POLYGON', default=None) + + obj = { + "name": name, + } + + if center != None: + c = [float(coord) for coord in center.split(',')] + obj['center'] = c + + if polygon != None: + obj['polygon'] = json.loads(polygon) + + self.objects[name] = obj + +def load_config(config): + return ExcludeObject(config) diff --git a/klippy/extras/tuning_tower.py b/klippy/extras/tuning_tower.py index 493db2c87998..c337def67d41 100644 --- a/klippy/extras/tuning_tower.py +++ b/klippy/extras/tuning_tower.py @@ -99,6 +99,8 @@ def end_test(self): self.gcode.respond_info("Ending tuning test mode") self.gcode_move.set_move_transform(self.normal_transform, force=True) self.normal_transform = None + def is_testing(self): + return self.normal_transform is not None def load_config(config): return TuningTower(config) diff --git a/klippy/extras/virtual_sdcard.py b/klippy/extras/virtual_sdcard.py index 265a13f9f5fc..a3ed848754e8 100644 --- a/klippy/extras/virtual_sdcard.py +++ b/klippy/extras/virtual_sdcard.py @@ -9,7 +9,7 @@ class VirtualSD: def __init__(self, config): - printer = config.get_printer() + self.printer = printer = config.get_printer() printer.register_event_handler("klippy:shutdown", self.handle_shutdown) # sdcard state sd = config.get('path') @@ -125,6 +125,7 @@ def _reset_file(self): self.current_file = None self.file_position = self.file_size = 0. self.print_stats.reset() + self.printer.send_event("sdcard:reset_file") cmd_SDCARD_RESET_FILE_help = "Clears a loaded SD File. Stops the print "\ "if necessary" def cmd_SDCARD_RESET_FILE(self, gcmd):