From 89c59b035e93e87d2fc22010d7030dc86434ce03 Mon Sep 17 00:00:00 2001 From: Frank Tackitt Date: Tue, 1 Mar 2022 13:04:08 -0700 Subject: [PATCH] exclude_objects: initial implementation Adding Klipper functionality to support cancelling objects while printing. This module keeps track of motion in and out of objects and adjusts movements as needed. It also tracks object status and provides that to clients. The Klipper module is relatively simple, and only provides one piece of the workflow. Moonraker already supports processing uploaded files to insert the required gcode markers for cancelling objects, using https://github.com/kageurufu/cancelobject-preprocessor. This library is also available as an executable for use in slicers, and pip installations also include the script as a callable. Mainsail has integrated support, and code changes for Fluidd are available. Support in other interfaces is planned, and we've spoken to several other developers about integrating frontend support in their projects. Signed-off-by: Troy Jacobson Co-authored-by: Franklyn Tackitt Co-authored-by: Eric Callahan --- klippy/extras/exclude_object.py | 302 ++++++++++++++++++++++++++++++++ test/klippy/exclude_object.cfg | 117 +++++++++++++ test/klippy/exclude_object.test | 126 +++++++++++++ 3 files changed, 545 insertions(+) create mode 100644 klippy/extras/exclude_object.py create mode 100644 test/klippy/exclude_object.cfg create mode 100644 test/klippy/exclude_object.test diff --git a/klippy/extras/exclude_object.py b/klippy/extras/exclude_object.py new file mode 100644 index 00000000..0a68d9b5 --- /dev/null +++ b/klippy/extras/exclude_object.py @@ -0,0 +1,302 @@ +# 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("virtual_sdcard:reset_file", + self._reset_file) + self.next_transform = None + self.last_position_extruded = [0., 0., 0., 0.] + self.last_position_excluded = [0., 0., 0., 0.] + + self._reset_state() + self.gcode.register_command( + 'EXCLUDE_OBJECT_START', self.cmd_EXCLUDE_OBJECT_START, + desc=self.cmd_EXCLUDE_OBJECT_START_help) + self.gcode.register_command( + 'EXCLUDE_OBJECT_END', self.cmd_EXCLUDE_OBJECT_END, + desc=self.cmd_EXCLUDE_OBJECT_END_help) + self.gcode.register_command( + 'EXCLUDE_OBJECT', self.cmd_EXCLUDE_OBJECT, + desc=self.cmd_EXCLUDE_OBJECT_help) + self.gcode.register_command( + 'EXCLUDE_OBJECT_DEFINE', self.cmd_EXCLUDE_OBJECT_DEFINE, + desc=self.cmd_EXCLUDE_OBJECT_DEFINE_help) + + def _register_transform(self): + if self.next_transform is None: + tuning_tower = self.printer.lookup_object('tuning_tower') + if tuning_tower.is_active(): + 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.extrusion_offsets = {} + self.max_position_extruded = 0 + self.max_position_excluded = 0 + self.extruder_adj = 0 + self.initial_extrusion_moves = 5 + self.last_position = [0., 0., 0., 0.] + + self.get_position() + self.last_position_extruded[:] = self.last_position + self.last_position_excluded[:] = self.last_position + + def _handle_connect(self): + self.toolhead = self.printer.lookup_object('toolhead') + + def _unregister_transform(self): + if self.next_transform: + tuning_tower = self.printer.lookup_object('tuning_tower') + if tuning_tower.is_active(): + logging.error('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 _reset_state(self): + self.objects = [] + self.excluded_objects = [] + self.current_object = None + self.in_excluded_region = False + + def _reset_file(self): + self._reset_state() + self._unregister_transform() + + def _get_extrusion_offsets(self): + offset = self.extrusion_offsets.get( + self.toolhead.get_extruder().get_name()) + if offset is None: + offset = [0., 0., 0., 0.] + self.extrusion_offsets[self.toolhead.get_extruder().get_name()] = \ + offset + return offset + + def get_position(self): + offset = self._get_extrusion_offsets() + 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._get_extrusion_offsets() + + if self.initial_extrusion_moves > 0 and \ + self.last_position[3] != newpos[3]: + # Since the transform is not loaded until there is a request to + # exclude an object, the transform needs to track a few extrusions + # to get the state of the extruder + self.initial_extrusion_moves -= 1 + + 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._get_extrusion_offsets() + 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 + + # 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 \ + and self.initial_extrusion_moves == 0 + + def get_status(self, eventtime=None): + status = { + "objects": self.objects, + "excluded_objects": 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_EXCLUDE_OBJECT_START_help = "Marks the beginning the current object" \ + " as labeled" + def cmd_EXCLUDE_OBJECT_START(self, gcmd): + name = gcmd.get('NAME').upper() + if not any(obj["name"] == name for obj in self.objects): + self._add_object_definition({"name": name}) + self.current_object = name + self.was_excluded_at_start = self._test_in_excluded_region() + + cmd_EXCLUDE_OBJECT_END_help = "Marks the end the current object" + def cmd_EXCLUDE_OBJECT_END(self, gcmd): + if self.current_object == None and self.next_transform: + gcmd.respond_info("EXCLUDE_OBJECT_END 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("EXCLUDE_OBJECT_END NAME=%s does not match the" + " current object NAME=%s" % + (name.upper(), self.current_object)) + + self.current_object = None + + cmd_EXCLUDE_OBJECT_help = "Cancel moves inside a specified objects" + def cmd_EXCLUDE_OBJECT(self, gcmd): + reset = gcmd.get('RESET', None) + current = gcmd.get('CURRENT', None) + name = gcmd.get('NAME', '').upper() + + if reset: + if name: + self._unexclude_object(name) + + else: + self.excluded_objects = [] + + elif name: + if name.upper() not in self.excluded_objects: + self._exclude_object(name.upper()) + + elif current: + if not self.current_object: + gcmd.respond_error('There is no current object to cancel') + + else: + self._exclude_object(self.current_object) + + else: + self._list_excluded_objects(gcmd) + + cmd_EXCLUDE_OBJECT_DEFINE_help = "Provides a summary of an object" + def cmd_EXCLUDE_OBJECT_DEFINE(self, gcmd): + reset = gcmd.get('RESET', None) + name = gcmd.get('NAME', '').upper() + + if reset: + self._reset_file() + + elif name: + parameters = gcmd.get_command_parameters().copy() + parameters.pop('NAME') + center = parameters.pop('CENTER', None) + polygon = parameters.pop('POLYGON', None) + + obj = {"name": name.upper()} + obj.update(parameters) + + if center != None: + obj['center'] = json.loads('[%s]' % center) + + if polygon != None: + obj['polygon'] = json.loads(polygon) + + self._add_object_definition(obj) + + else: + self._list_objects(gcmd) + + def _add_object_definition(self, definition): + self.objects = sorted(self.objects + [definition], + key=lambda o: o["name"]) + + def _exclude_object(self, name): + self._register_transform() + self.gcode.respond_info('Excluding object {}'.format(name.upper())) + if name not in self.excluded_objects: + self.excluded_objects = sorted(self.excluded_objects + [name]) + + def _unexclude_object(self, name): + self.gcode.respond_info('Unexcluding object {}'.format(name.upper())) + if name in self.excluded_objects: + excluded_objects = list(self.excluded_objects) + excluded_objects.remove(name) + self.excluded_objects = sorted(excluded_objects) + + def _list_objects(self, gcmd): + if gcmd.get('JSON', None) is not None: + object_list = json.dumps(self.objects) + else: + object_list = " ".join(obj['name'] for obj in self.objects) + gcmd.respond_info('Known objects: {}'.format(object_list)) + + def _list_excluded_objects(self, gcmd): + object_list = " ".join(self.excluded_objects) + gcmd.respond_info('Excluded objects: {}'.format(object_list)) + +def load_config(config): + return ExcludeObject(config) diff --git a/test/klippy/exclude_object.cfg b/test/klippy/exclude_object.cfg new file mode 100644 index 00000000..54041fa2 --- /dev/null +++ b/test/klippy/exclude_object.cfg @@ -0,0 +1,117 @@ +[stepper_x] +step_pin: PF0 +dir_pin: PF1 +enable_pin: !PD7 +microsteps: 16 +rotation_distance: 40 +endstop_pin: ^PE5 +position_endstop: 0 +position_max: 200 +homing_speed: 50 + +[stepper_y] +step_pin: PF6 +dir_pin: !PF7 +enable_pin: !PF2 +microsteps: 16 +rotation_distance: 40 +endstop_pin: ^PJ1 +position_endstop: 0 +position_max: 200 +homing_speed: 50 + +[stepper_z] +step_pin: PL3 +dir_pin: PL1 +enable_pin: !PK0 +microsteps: 16 +rotation_distance: 8 +endstop_pin: ^PD3 +position_endstop: 0.5 +position_max: 200 + +[extruder] +step_pin: PA4 +dir_pin: PA6 +enable_pin: !PA2 +microsteps: 16 +rotation_distance: 33.5 +nozzle_diameter: 0.500 +filament_diameter: 3.500 +heater_pin: PB4 +sensor_type: EPCOS 100K B57560G104F +sensor_pin: PK5 +control: pid +pid_Kp: 22.2 +pid_Ki: 1.08 +pid_Kd: 114 +min_temp: 0 +max_temp: 210 + +[heater_bed] +heater_pin: PH5 +sensor_type: EPCOS 100K B57560G104F +sensor_pin: PK6 +control: watermark +min_temp: 0 +max_temp: 110 + +[mcu] +serial: /dev/ttyACM0 + +[printer] +kinematics: cartesian +max_velocity: 300 +max_accel: 3000 +max_z_velocity: 5 +max_z_accel: 100 + +# Test config for exclude_object +[exclude_object] + +[gcode_macro M486] +gcode: + # Parameters known to M486 are as follows: + # [C] Cancel the current object + # [P] Cancel the object with the given index + # [S] Set the index of the current object. + # If the object with the given index has been canceled, this will cause + # the firmware to skip to the next object. The value -1 is used to + # indicate something that isn’t an object and shouldn’t be skipped. + # [T] Reset the state and set the number of objects + # [U] Un-cancel the object with the given index. This command will be + # ignored if the object has already been skipped + + {% if 'exclude_object' not in printer %} + {action_raise_error("[exclude_object] is not enabled")} + {% endif %} + + {% if 'T' in params %} + EXCLUDE_OBJECT RESET=1 + + {% for i in range(params.T | int) %} + EXCLUDE_OBJECT_DEFINE NAME={i} + {% endfor %} + {% endif %} + + {% if 'C' in params %} + EXCLUDE_OBJECT CURRENT=1 + {% endif %} + + {% if 'P' in params %} + EXCLUDE_OBJECT NAME={params.P} + {% endif %} + + {% if 'S' in params %} + {% if params.S == '-1' %} + {% if printer.exclude_object.current_object %} + EXCLUDE_OBJECT_END NAME={printer.exclude_object.current_object} + {% endif %} + {% else %} + EXCLUDE_OBJECT_START NAME={params.S} + {% endif %} + {% endif %} + + {% if 'U' in params %} + EXCLUDE_OBJECT RESET=1 NAME={params.U} + {% endif %} diff --git a/test/klippy/exclude_object.test b/test/klippy/exclude_object.test new file mode 100644 index 00000000..9abcc45e --- /dev/null +++ b/test/klippy/exclude_object.test @@ -0,0 +1,126 @@ +DICTIONARY atmega2560.dict +CONFIG exclude_object.cfg + + +G28 +M83 + +M486 T3 + +M486 S0 + G0 X10 + +M486 S-1 + G0 X0 + +M486 S1 + G0 X11 +M486 C + +# "Prime" the transform +G1 X140 E0.5 +G1 X160 E0.5 +G1 X140 E0.5 +G1 X160 E0.5 +G1 X140 E0.5 +G1 X160 E0.5 +G1 X140 E0.5 + +M486 S-1 + G0 X0 + +M486 S2 + G0 X13 + +M486 S0 + G0 X10 + +M486 S-1 + G0 X0 + +M486 S1 + G0 X-11 + +M486 S-1 + G0 X0 + +M486 S2 + G0 X13 + +M486 P2 +EXCLUDE_OBJECT + +M486 S0 + G0 X10 + +M486 S-1 + G0 X0 + +M486 S1 + G0 X-11 + +M486 S-1 + G0 X0 + +M486 S2 + G0 X-13 + +M486 U2 +EXCLUDE_OBJECT + +M486 S0 + G0 X10 + +M486 S-1 + G0 X0 + +M486 S1 + G0 X-11 + +M486 S-1 + G0 X0 + +M486 S2 + G0 X13 + +M486 P0 +M486 P1 +M486 P2 +EXCLUDE_OBJECT + +M486 S0 + G0 X-10 + +M486 S-1 + G0 X0 + +M486 S1 + G0 X-11 + +M486 S-1 + G0 X0 + +M486 S2 + G0 X-13 + + +M486 S66 + G0 X66 + +M486 S-1 + G0 X0 + M486 P66 + +M486 S66 + G0 X-66 + +M486 T3 + +M486 S0 + G0 X10 + +M486 S1 + G0 X11 + +M486 S2 + G0 X13