def __init__(self, d: str, canvas_height: float, transform_origin=True, transformation=None): self.canvas_height = canvas_height self.transform_origin = transform_origin self.curves = [] self.initial_point = Vector(0, 0) # type: Vector self.current_point = Vector(0, 0) self.last_control = None # type: Vector point_transformation = Transformation() if self.transform_origin: point_transformation.add_translation(0, canvas_height) point_transformation.add_scale(1, -1) if transformation is not None: point_transformation.extend(transformation) self.point_transformation = point_transformation try: self._parse_commands(d) except Exception as generic_exception: raise generic_exception warnings.warn( f"Terminating path. The following unforeseen exception occurred: {generic_exception}" )
def generate_debug(approximations, svg_file_name, debug_file_name): tree = ElementTree() tree.parse(svg_file_name) root = tree.getroot() height_str = root.get("height") canvas_height = float(height_str) if height_str.isnumeric() else float( height_str[:-2]) for path in root.iter("{%s}path" % name_space): path.set("fill", "none") path.set("stroke", "black") path.set("stroke-width", f"{TOLERANCES['approximation']}mm") style = path.get("style") if style and "display:none" in style: path.set("style", "display:none") elif style and ("visibility:hidden" in style or "visibility:collapse" in style): path.set("style", "visibility:hidden") else: path.set("style", "") group = Element("{%s}g" % name_space) change_origin = Transformation() change_origin.add_scale(1, -1) change_origin.add_translation(0, -canvas_height) defs = debug_methods.arrow_defs() group.append(defs) for approximation in approximations: path = debug_methods.to_svg_path( approximation, color="red", stroke_width=f"{TOLERANCES['approximation']/2}mm", transformation=change_origin, draw_arrows=True) """ path = Element("{%s}path" % name_space) path.set("d", ) add_def = False path.set("fill", "none") """ group.append(path) root.append(group) tree.write(debug_file_name)
def draw_debug_traces(self, curves): """Traces arrows over all parsed paths""" root = self.document.getroot() origin = self.options.machine_origin bed_width = self.options.bed_width bed_height = self.options.bed_height height_str = root.get("height") canvas_height = float(height_str) if height_str.isnumeric() else float( height_str[:-2]) group = etree.Element("{%s}g" % svg_name_space) group.set("id", "debug_traces") group.set("{%s}groupmode" % inkscape_name_space, "layer") group.set("{%s}label" % inkscape_name_space, "debug traces") group.append( etree.fromstring( xml_tree.tostring( debug_methods.arrow_defs( arrow_scale=self.options.debug_arrow_scale)))) for curve in curves: approximation = LineSegmentChain.line_segment_approximation(curve) change_origin = Transformation() if origin != "top-left": change_origin.add_scale(1, -1) change_origin.add_translation(0, -canvas_height) if origin == "center": change_origin.add_translation(bed_width / 2, bed_height / 2) path_string = xml_tree.tostring( debug_methods.to_svg_path( approximation, color="red", stroke_width=f"{self.options.debug_line_width}px", transformation=change_origin, draw_arrows=True)) group.append(etree.fromstring(path_string)) root.append(group)
class Path: """The Path class represents a generic svg path.""" command_lengths = { 'M': 2, 'm': 2, 'L': 2, 'l': 2, 'H': 1, 'h': 1, 'V': 1, 'v': 1, 'Z': 0, 'z': 0, 'C': 6, 'c': 6, 'Q': 4, 'q': 4, 'S': 4, 's': 4, 'T': 2, 't': 2, 'A': 7, 'a': 7 } __slots__ = "curves", "initial_point", "current_point", "last_control", "canvas_height", "draw_move", \ "transform_origin", "transformation" def __init__(self, d: str, canvas_height: float, transform_origin=True, transformation=None): self.canvas_height = canvas_height self.transform_origin = transform_origin self.curves = [] self.initial_point = Vector(0, 0) # type: Vector self.current_point = Vector(0, 0) self.last_control = None # type: Vector self.transformation = Transformation() if self.transform_origin: self.transformation.add_translation(0, canvas_height) self.transformation.add_scale(1, -1) if transformation is not None: self.transformation.extend(transformation) try: self._parse_commands(d) except Exception as generic_exception: warnings.warn( f"Terminating path. The following unforeseen exception occurred: {generic_exception}" ) def __repr__(self): return f"Path({self.curves})" def _parse_commands(self, d: str): """Parse svg commands (stored in value of the d key) into geometric curves.""" command_key = '' # A character representing a specific command based on the svg standard command_arguments = [ ] # A list containing the arguments for the current command_key number_str = '' # A buffer used to store numeric characters before conferring them to a number # Parse each character in d i = 0 while i < len(d): character = d[i] is_numeric = character.isnumeric() or character in [ '-', '.', 'e' ] # Yes, "-6.2e-4" is a valid float. is_delimiter = character.isspace() or character in [','] is_command_key = character in self.command_lengths.keys() is_final = i == len(d) - 1 # If the current command is complete, however the next command does not specify a new key, assume the next # command has the same key. This is implemented by inserting the current key before the next command and # restarting the loop without incrementing i try: if command_key and len( command_arguments ) == self.command_lengths[command_key] and is_numeric: duplicate = command_key # If a moveto is followed by multiple pairs of coordinates, the subsequent pairs are treated as # implicit lineto commands. https://www.w3.org/TR/SVG2/paths.html#PathDataMovetoCommands if command_key == 'm': duplicate = 'l' if command_key == 'M': duplicate = 'L' d = d[:i] + duplicate + d[i:] continue except KeyError as key_error: warnings.warn( f"Unknown command key {command_key}. Skipping curve.") # If the character is part of a number, keep on composing it if is_numeric: number_str += character # if a negative number follows another number, no delimiter is required. # implicitly stated decimals like .6 don't require a delimiter. In either case we add a delimiter. negatives = not is_final and character != 'e' and d[i + 1] == '-' implicit_decimals = not is_final and d[ i + 1] == '.' and '.' in number_str if negatives or implicit_decimals: d = d[:i + 1] + ',' + d[i + 1:] # If the character is a delimiter or a command key or the last character, complete the number and save it # as an argument if is_delimiter or is_command_key or is_final: if number_str: # In svg form '-.5' can be written as '-.5'. Python doesn't like that notation. if number_str[0] == '.': number_str = '0' + number_str if number_str[0] == '-' and number_str[1] == '.': number_str = '-0' + number_str[1:] command_arguments.append(float(number_str)) number_str = '' # If it's a command key or the last character, parse the previous (now complete) command and save the letter # as the new command key if is_command_key or is_final: if command_key: self._add_svg_curve(command_key, command_arguments) command_key = character command_arguments.clear() # If the last character is a command key (only useful for Z), save if is_command_key and is_final: self._add_svg_curve(command_key, command_arguments) i += 1 def _add_svg_curve(self, command_key: str, command_arguments: List[float]): """ Offer a representation of a curve using the geometry sub-module. Based on Mozilla Docs: https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths Each sub-method must be implemented with the following structure: def descriptive_name(*command_arguments): execute calculations and transformations, **do not modify or create any instance variables** generate curve modify instance variables return curve Alternatively a sub-method may simply call a base command. :param command_key: a character representing a specific command based on the svg standard :param command_arguments: A list containing the arguments for the current command_key """ # Establish a new initial point and a new current point. (multiple coordinates are parsed as lineto commands) def absolute_move(x, y): self.initial_point = Vector(x, y) self.current_point = Vector(x, y) return None def relative_move(dx, dy): return absolute_move(*(self.current_point + Vector(dx, dy))) # Draw straight line def absolute_line(x, y): start = self.current_point end = Vector(x, y) line = Line(self.transformation.apply_affine_transformation(start), self.transformation.apply_affine_transformation(end)) self.current_point = end return line def relative_line(dx, dy): return absolute_line(*(self.current_point + Vector(dx, dy))) def absolute_horizontal_line(x): return absolute_line(x, self.current_point.y) def relative_horizontal_line(dx): return absolute_horizontal_line(self.current_point.x + dx) def absolute_vertical_line(y): return absolute_line(self.current_point.x, y) def relative_vertical_line(dy): return absolute_vertical_line(self.current_point.y + dy) def close_path(): return absolute_line(*self.initial_point) # Draw curvy curves def absolute_cubic_bazier(control1_x, control1_y, control2_x, control2_y, x, y): trans_start = self.transformation.apply_affine_transformation( self.current_point) trans_end = self.transformation.apply_affine_transformation( Vector(x, y)) trans_control1 = self.transformation.apply_affine_transformation( Vector(control1_x, control1_y)) trans_control2 = self.transformation.apply_affine_transformation( Vector(control2_x, control2_y)) cubic_bezier = CubicBazier(trans_start, trans_end, trans_control1, trans_control2) self.last_control = Vector(control2_x, control2_y) self.current_point = Vector(x, y) return cubic_bezier def relative_cubic_bazier(dx1, dy1, dx2, dy2, dx, dy): return absolute_cubic_bazier(self.current_point.x + dx1, self.current_point.y + dy1, self.current_point.x + dx2, self.current_point.y + dy2, self.current_point.x + dx, self.current_point.y + dy) def absolute_cubic_bezier_extension(x2, y2, x, y): start = self.current_point control2 = Vector(x2, y2) end = Vector(x, y) if self.last_control: control1 = 2 * start - self.last_control bazier = absolute_cubic_bazier(*control1, *control2, *end) else: bazier = absolute_quadratic_bazier(*control2, *end) self.current_point = start return bazier def relative_cubic_bazier_extension(dx2, dy2, dx, dy): return absolute_cubic_bezier_extension(self.current_point.x + dx2, self.current_point.y + dy2, self.current_point.x + dx, self.current_point.y + dy) def absolute_quadratic_bazier(control1_x, control1_y, x, y): trans_end = self.transformation.apply_affine_transformation( self.current_point) trans_new_end = self.transformation.apply_affine_transformation( Vector(x, y)) trans_control1 = self.transformation.apply_affine_transformation( Vector(control1_x, control1_y)) quadratic_bezier = QuadraticBezier(trans_end, trans_new_end, trans_control1) self.last_control = Vector(control1_x, control1_y) self.current_point = Vector(x, y) return quadratic_bezier def relative_quadratic_bazier(dx1, dy1, dx, dy): return absolute_quadratic_bazier(self.current_point.x + dx1, self.current_point.y + dy1, self.current_point.x + dx, self.current_point.y + dy) def absolute_quadratic_bazier_extension(x, y): start = self.current_point end = Vector(x, y) if self.last_control: control = 2 * start - self.last_control bazier = absolute_quadratic_bazier(*control, *end) else: bazier = absolute_quadratic_bazier(*start, *end) self.current_point = end return bazier def relative_quadratic_bazier_extension(dx, dy): return absolute_quadratic_bazier_extension( self.current_point.x + dx, self.current_point.y + dy) # Generate EllipticalArc with center notation from svg endpoint notation. # Based on w3.org implementation notes. https://www.w3.org/TR/SVG2/implnote.html # Todo transformations aren't applied correctly to elliptical arcs def absolute_arc(rx, ry, deg_from_horizontal, large_arc_flag, sweep_flag, x, y): end = Vector(x, y) start = self.current_point radii = Vector(rx, ry) rotation_rad = math.radians(deg_from_horizontal) if abs(start - end) == 0: raise ValueError("start and end points can't be equal") radii, center, start_angle, sweep_angle = formulas.endpoint_to_center_parameterization( start, end, radii, rotation_rad, large_arc_flag, sweep_flag) arc = EllipticalArc(center, radii, rotation_rad, start_angle, sweep_angle, transformation=self.transformation) self.current_point = end return arc def relative_arc(rx, ry, deg_from_horizontal, large_arc_flag, sweep_flag, dx, dy): return absolute_arc(rx, ry, deg_from_horizontal, large_arc_flag, sweep_flag, self.current_point.x + dx, self.current_point.y + dy) command_methods = { # Only move end point 'M': absolute_move, 'm': relative_move, # Draw straight line 'L': absolute_line, 'l': relative_line, 'H': absolute_horizontal_line, 'h': relative_horizontal_line, 'V': absolute_vertical_line, 'v': relative_vertical_line, 'Z': close_path, 'z': close_path, # Draw bazier curves 'C': absolute_cubic_bazier, 'c': relative_cubic_bazier, 'S': absolute_cubic_bezier_extension, 's': relative_cubic_bazier_extension, 'Q': absolute_quadratic_bazier, 'q': relative_quadratic_bazier, 'T': absolute_quadratic_bazier_extension, 't': relative_quadratic_bazier_extension, # Draw elliptical arcs 'A': absolute_arc, 'a': relative_arc } try: curve = command_methods[command_key](*command_arguments) except TypeError as type_error: warnings.warn( f"Mis-formed input. Skipping command {command_key, command_arguments} because it caused the " f"following error: \n{type_error}") except ValueError as value_error: warnings.warn( f"Impossible geometry. Skipping curve {command_key, command_arguments} because it caused the " f"following value error:\n{value_error}") else: if curve is not None: self.curves.append(curve) if verbose: print(f"{command_key}{tuple(command_arguments)} -> {curve}")
def generate_debug(approximations, svg_file_name, debug_file_name): tree = ElementTree() tree.parse(svg_file_name) root = tree.getroot() height_str = root.get("height") (canvas_height_raw, scale_factor) = _parse_length(height_str) canvas_height = canvas_height_raw * scale_factor for path in root.iter("{%s}path" % name_space): path.set("fill", "none") path.set("stroke", "black") path.set("stroke-width", f"{TOLERANCES['approximation']}mm") style = path.get("style") if style and "display:none" in style: path.set("style", "display:none") elif style and ("visibility:hidden" in style or "visibility:collapse" in style): path.set("style", "visibility:hidden") else: path.set("style", "") group = Element("{%s}g" % name_space) change_origin = Transformation() viewbox_str = root.get("viewBox") if viewbox_str is None: # Inverse scale scale = 25.4 / 96.0 change_origin.add_scale(1.0 / scale, 1.0 / scale) else: # TODO Build a more resilient parser here parts = re.search( r'([\d\.\-e]+)[,\s]+([\d\.\-e]+)[,\s]+([\d\.\-e]+)[,\s]+([\d\.\-e]+)', viewbox_str) if parts is not None: # TODO Can these values be anything other than numbers? "123mm" maybe? # The spec says they're "number"s, so no units, but possibly + or - or e-notation vb_x = float(parts[1]) vb_y = float(parts[2]) vb_width = float(parts[3]) vb_height = float(parts[4]) # TODO handle the preserveAspectRatio attribute # Defaults if not otherwise specified align = "xMidYMid" meet_or_slice = "meet" e_x = 0.0 e_y = 0.0 width_str = root.get("width") (canvas_width_raw, _) = _parse_length(width_str) e_width = canvas_width_raw # use raw number e_height = canvas_height_raw # use raw number scale_x = e_width / vb_width scale_y = e_height / vb_height if align != "none" and meet_or_slice == "meet": if scale_x > scale_y: scale_x = scale_y else: scale_y = scale_x if align != "none" and meet_or_slice == "slice": if scale_x < scale_y: scale_x = scale_y else: scale_y = scale_x # Inverse scale if scale_x != 1.0 or scale_y != 1.0: change_origin.add_scale(1.0 / scale_factor / scale_x, 1.0 / scale_factor / scale_y) else: change_origin.add_scale(1.0 / scale_factor, 1.0 / scale_factor) # Inverse translation translate_x = e_x + (vb_x * scale_x) translate_y = e_y + (vb_y * scale_y) if translate_x != 0 or translate_y != 0: change_origin.add_translation(translate_x, translate_y) change_origin.add_scale(1, -1) change_origin.add_translation(0, -canvas_height) group.append(debug_methods.arrow_defs()) for approximation in approximations: path = debug_methods.to_svg_path( approximation, color="red", stroke_width=f"{TOLERANCES['approximation']/2}mm", transformation=change_origin, draw_arrows=True) group.append(path) root.append(group) tree.write(debug_file_name)
def effect(self): """Takes the SVG from Inkscape, generates gcode, returns the SVG after adding debug lines.""" root = self.document.getroot() # Change svg_to_gcode's approximation tolerance TOLERANCES["approximation"] = float( self.options.approximation_tolerance.replace(',', '.')) # Construct output path output_path = os.path.join(self.options.directory, self.options.filename) if self.options.filename_suffix: try: filename, extension = output_path.split('.') except: self.msg("Error in output directory!") exit(1) n = 1 while os.path.isfile(output_path): output_path = filename + str(n) + '.' + extension n += 1 # Load header and footer files header = [] if os.path.isfile(self.options.header_path): with open(self.options.header_path, 'r') as header_file: header = header_file.read().splitlines() elif self.options.header_path != os.getcwd( ): # The Inkscape file selector defaults to the working directory self.debug( f"Header file does not exist at {self.options.header_path}") footer = [] if os.path.isfile(self.options.footer_path): with open(self.options.footer_path, 'r') as footer_file: footer = footer_file.read().splitlines() elif self.options.footer_path != os.getcwd(): self.debug( f"Footer file does not exist at {self.options.footer_path}") # Customize header/footer custom_interface = generate_custom_interface( self.options.tool_off_command, self.options.tool_power_command) interface_instance = custom_interface() if self.options.do_laser_off_start: header.append(interface_instance.laser_off()) if self.options.do_laser_off_end: footer.append(interface_instance.laser_off()) header.append( interface_instance.set_movement_speed(self.options.travel_speed)) if self.options.do_z_axis_start: header.append( interface_instance.linear_move(z=self.options.z_axis_start)) if self.options.move_to_origin_end: footer.append(interface_instance.linear_move(x=0, y=0)) # Generate gcode gcode_compiler = Compiler(custom_interface, self.options.travel_speed, self.options.cutting_speed, self.options.pass_depth, dwell_time=self.options.dwell_time, custom_header=header, custom_footer=footer, unit=self.options.unit) transformation = Transformation() transformation.add_translation(self.options.horizontal_offset, self.options.vertical_offset) transformation.add_scale(self.options.scaling_factor) if self.options.machine_origin == "center": transformation.add_translation(-self.options.bed_width / 2, self.options.bed_height / 2) elif self.options.machine_origin == "top-left": transformation.add_translation(0, self.options.bed_height) curves = parse_root(root, transform_origin=not self.options.invert_y_axis, root_transformation=transformation, canvas_height=self.options.bed_height) gcode_compiler.append_curves(curves) gcode_compiler.compile_to_file(output_path, passes=self.options.passes) # Draw debug lines self.clear_debug() if self.options.draw_debug: self.draw_debug_traces(curves) self.draw_unit_reference() self.select_non_debug_layer() return self.document
def effect(self): """Takes the SVG from Inkscape, generates gcode, returns the SVG after adding debug lines.""" root = self.document.getroot() approximation_tolerance = float( self.options.approximation_tolerance.replace(',', '.')) output_path = os.path.join(self.options.directory, self.options.filename) if self.options.filename_suffix: filename, extension = output_path.split('.') n = 1 while os.path.isfile(output_path): output_path = filename + str(n) + '.' + extension n += 1 header = None if self.options.header_path: with open(self.options.header_path, 'r') as header_file: header = header_file.readlines() footer = None if self.options.footer_path: with open(self.options.footer_path, 'r') as footer_file: footer = footer_file.readlines() # Generate gcode self.clear_debug() TOLERANCES["approximation"] = approximation_tolerance custom_interface = generate_custom_interface( self.options.laser_off_command, self.options.laser_power_command, self.options.laser_power_range) gcode_compiler = Compiler(custom_interface, self.options.travel_speed, self.options.cutting_speed, self.options.pass_depth, dwell_time=self.options.dwell_time, custom_header=header, custom_footer=footer, unit=self.options.unit) transformation = Transformation() transformation.add_translation(self.options.horizontal_offset, self.options.vertical_offset) transformation.add_scale(self.options.scaling_factor) if self.options.machine_origin == "center": transformation.add_translation(-self.options.bed_width / 2, self.options.bed_height / 2) transform_origin = True if self.options.machine_origin == "top-left": transform_origin = False curves = parse_root(root, transform_origin=transform_origin, root_transformation=transformation) gcode_compiler.append_curves(curves) gcode_compiler.compile_to_file(output_path, passes=self.options.passes) # Generate debug lines if self.options.draw_debug: self.draw_debug_traces(curves) self.draw_unit_reference() self.select_non_debug_layer() return self.document
def effect(self): """Takes the SVG from Inkscape, generates gcode, returns the SVG after adding debug lines.""" root = self.document.getroot() approximation_tolerance = float( self.options.approximation_tolerance.replace(',', '.')) output_path = os.path.join(self.options.directory, self.options.filename) if self.options.filename_suffix: filename, extension = output_path.split('.') n = 1 while os.path.isfile(output_path): output_path = filename + str(n) + '.' + extension n += 1 header = None if os.path.isfile(self.options.header_path): logger.debug(F"going to read{self.options.header_path}") with open(self.options.header_path, 'r') as header_file: header = header_file.read().splitlines() logger.debug(F"This is my header: >>>{header}<<<") elif self.options.header_path != os.getcwd(): self.debug( f"Header file does not exist at {self.options.header_path}") if self.options.set_z_axis_start_pos: unit = "G21" if self.options.unit == "in": unit = "G20" temp = F"{unit};\nG1 Z{self.options.z_axis_start};" if header is None: header = [temp] else: header.append(temp) footer = None if os.path.isfile(self.options.footer_path): with open(self.options.footer_path, 'r') as footer_file: footer = footer_file.read().splitlines() elif self.options.footer_path != os.getcwd(): self.debug( f"Footer file does not exist at {self.options.footer_path}") if self.options.move_to_zero_at_end: temp = F"M5;\nG1 F{self.options.travel_speed} X0.0 Y0.0 Z0.0;" if footer is None: footer = [temp] else: footer.append(temp) # Generate gcode self.clear_debug() TOLERANCES["approximation"] = approximation_tolerance custom_interface = generate_custom_interface( self.options.laser_off_command, self.options.laser_power_command, self.options.laser_power_range) gcode_compiler = Compiler(custom_interface, self.options.travel_speed, self.options.cutting_speed, self.options.pass_depth, dwell_time=self.options.dwell_time, custom_header=header, custom_footer=footer, unit=self.options.unit) transformation = Transformation() transformation.add_translation(self.options.horizontal_offset, self.options.vertical_offset) transformation.add_scale(self.options.scaling_factor) if self.options.machine_origin == "center": transformation.add_translation(-self.options.bed_width / 2, self.options.bed_height / 2) curves = parse_root(root, transform_origin=not self.options.invert_y_axis, root_transformation=transformation, canvas_height=self.options.bed_height) gcode_compiler.append_curves(curves) gcode_compiler.compile_to_file(output_path, passes=self.options.passes) # Generate debug lines if self.options.draw_debug: self.draw_debug_traces(curves) self.draw_unit_reference() self.select_non_debug_layer() return self.document