-
Notifications
You must be signed in to change notification settings - Fork 29
Refactor for clarity and performance improvements, add CLI #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a505e51
b6dd503
7c2d91d
f78bac0
5591b6d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| *.gcode | ||
| **__pycache__** | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,8 +7,14 @@ | |
| # Usage | ||
| - Place your part preferably in the middle of your print plate with known center X coordinates | ||
| - Place the sliced GCode in the same directory as the Python script | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I haven't gotten far enough in the review to know if it is still required to
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, the argument works fine with a path. I decided not to change that usage line from Stefan's description because I expect most users won't be programmers, in which case it might be easier to leave like that, and someone who's comfortable with CLIs would likely try to just specify a path if it's more convenient to do so. That said, I don't feel strongly about it, so would be happy for it to be changed to better reflect the actual usage. Perhaps this could end up being pip-installable as a script or something, in which case that would be more important. |
||
| - Set *INPUT_FILE_NAME* to your GCode file name | ||
| - Set *LAYER_HEIGHT* to your slicing layer height. Important, because you don't set it correctly you'll get under- or over extrusions | ||
| - Set *WARNING_ANGLE* to the maximum angle your system can print at due to clearances | ||
| - Define your spline with *SPLINE_X* and *SPLINE_Z*. This array can contain an arbitrary number of points. Make sure the first X-coordinate is in the center of your part. Make sure the last z coordinate is higher or equal the highest z-coordiante in your GCode. | ||
| - *SPLINE = CubicSpline(SPLINE_Z, SPLINE_X, bc_type=((1, 0), (1, -np.pi/6)))* defines the spline. You can alter the last pair of of *bc_type* (here *1,-np.pi/6*). This defines the final angle of your spline in RAD. | ||
| - Run `bend_gcode.py` in your Terminal/Command Prompt | ||
| - Run `python3 bend_gcode.py --help` to see the available options (or `py bend_gcode.py --help`) | ||
| - Pass in your gcode file name as the first argument | ||
| - Set `-l` or `--layer_height` to your slicing layer height. | ||
| Important, because you don't set it correctly you'll get under- or over extrusions | ||
| - Set `-a` or `--warning_angle` to the maximum angle your system can print at due to clearances | ||
| - Define your spline with `-x`/`--x_values` and `-z`/`--z_values`, with at least two points. | ||
| - The first x-coordinate should be in the center of your part | ||
| - The first z-coordinate should be 0 (at the print bed) | ||
| - The last z-coordinate should be at or above the highest z-coordinate in your GCode | ||
ES-Alexander marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| - `-b`/`--bend_angle` determines the angle of the spline at the last coordinate | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,135 +5,91 @@ | |
| @author: stefa | ||
| """ | ||
|
|
||
| from dataclasses import dataclass | ||
| from datetime import timedelta | ||
| from time import perf_counter | ||
| from typing import ClassVar | ||
| from spline import Spline | ||
| import numpy as np | ||
| import math | ||
| from scipy.interpolate import CubicSpline | ||
| import matplotlib.pyplot as plt | ||
| import re | ||
| from collections import namedtuple | ||
|
|
||
| Point2D = namedtuple('Point2D', 'x y') | ||
| GCodeLine = namedtuple('GCodeLine', 'x y z e f') | ||
|
|
||
|
|
||
| ################# USER INPUT PARAMETERS ######################### | ||
|
|
||
| INPUT_FILE_NAME = "pipe_mk2.gcode" | ||
| OUTPUT_FILE_NAME = "BENT_" + INPUT_FILE_NAME | ||
| LAYER_HEIGHT = 0.3 #Layer height of the sliced gcode | ||
| WARNING_ANGLE = 30 #Maximum Angle printable with your setup | ||
|
|
||
| #2-point spline | ||
| SPLINE_X = [125, 95] | ||
| SPLINE_Z = [0, 140] | ||
|
|
||
| #4-point spline example | ||
| #SPLINE_X = [150, 156,144,150] | ||
| #SPLINE_Z = [0,30,60,90] | ||
|
|
||
|
|
||
| SPLINE = CubicSpline(SPLINE_Z, SPLINE_X, bc_type=((1, 0), (1, -np.pi/6))) #define spline with BC-conditions | ||
| #SPLINE = CubicSpline(SPLINE_Z, SPLINE_X, bc_type=((1, 0), (1, -0.5235988))) #bent 30° | ||
|
|
||
| DISCRETIZATION_LENGTH = 0.01 #discretization length for the spline length lookup table | ||
|
|
||
| ################# USER INPUT PARAMETERS END ######################### | ||
|
|
||
|
|
||
| SplineLookupTable = [0.0] | ||
|
|
||
| nx = np.arange(0,SPLINE_Z[-1],1) | ||
|
|
||
| xs = np.arange(0,SPLINE_Z[-1],1) | ||
| fig, ax = plt.subplots(figsize=(6.5, 4)) | ||
| ax.plot(SPLINE_X, SPLINE_Z, 'o', label='data') | ||
| ax.plot(SPLINE(xs), xs, label="S") | ||
| ax.set_xlim(0, 200) | ||
| ax.set_ylim(0, 200) | ||
| plt.gca().set_aspect('equal', adjustable='box') | ||
| # ax.legend(loc='lower left', ncol=2) | ||
| plt.show() | ||
|
|
||
|
|
||
| def getNormalPoint(currentPoint: Point2D, derivative: float, distance: float) -> Point2D: #claculates the normal of a point on the spline | ||
| angle = np.arctan(derivative) + math.pi /2 | ||
| return Point2D(currentPoint.x + distance * np.cos(angle), currentPoint.y + distance * np.sin(angle)) | ||
|
|
||
| def parseGCode(currentLine: str) -> GCodeLine: #parse a G-Code line | ||
| thisLine = re.compile('(?i)^[gG][0-3](?:\s+x(?P<x>-?[0-9.]{1,15})|\s+y(?P<y>-?[0-9.]{1,15})|\s+z(?P<z>-?[0-9.]{1,15})|\s+e(?P<e>-?[0-9.]{1,15})|\s+f(?P<f>-?[0-9.]{1,15}))*') | ||
| lineEntries = thisLine.match(currentLine) | ||
| if lineEntries: | ||
| return GCodeLine(lineEntries.group('x'), lineEntries.group('y'), lineEntries.group('z'), lineEntries.group('e'), lineEntries.group('f')) | ||
|
|
||
| def writeLine(G, X, Y, Z, F = None, E = None): #write a line to the output file | ||
| outputSting = "G" + str(int(G)) + " X" + str(round(X,5)) + " Y" + str(round(Y,5)) + " Z" + str(round(Z,3)) | ||
| if E is not None: | ||
| outputSting = outputSting + " E" + str(round(float(E),5)) | ||
| if F is not None: | ||
| outputSting = outputSting + " F" + str(int(float(F))) | ||
| outputFile.write(outputSting + "\n") | ||
|
|
||
| """ | ||
| # legacy - toooo slow! | ||
| def onSplineLength(Zheight) -> float: #calculates a new z height if the spline is followed | ||
| return Zheight #for debugging | ||
| discretizationLength = 0.01 #Steps taken to find the spline height | ||
| currentHeight = 0.00 | ||
| currentLength = 0.00 | ||
| while currentLength < Zheight: | ||
| currentLength += np.sqrt((SPLINE(currentHeight)-SPLINE(currentHeight+discretizationLength))**2 + discretizationLength**2) | ||
| currentHeight += discretizationLength | ||
| return currentHeight | ||
| """ | ||
|
|
||
| def onSplineLength(Zheight) -> float: #calculates a new z height if the spline is followed | ||
| for i in range(len(SplineLookupTable)): | ||
| height = SplineLookupTable[i] | ||
| if height >= Zheight: | ||
| return i * DISCRETIZATION_LENGTH | ||
| print("Error! Spline not defined high enough!") | ||
|
|
||
| def createSplineLookupTable(): | ||
| heightSteps = np.arange(DISCRETIZATION_LENGTH, SPLINE_Z[-1], DISCRETIZATION_LENGTH) | ||
| for i in range(len(heightSteps)): | ||
| height = heightSteps[i] | ||
| SplineLookupTable.append(SplineLookupTable[i] + np.sqrt((SPLINE(height)-SPLINE(height-DISCRETIZATION_LENGTH))**2 + DISCRETIZATION_LENGTH**2)) | ||
|
|
||
|
|
||
|
|
||
| lastPosition = Point2D(0, 0) | ||
| currentZ = 0.0 | ||
| lastZ = 0.0 | ||
| currentLayer = 0 | ||
| relativeMode = False | ||
| createSplineLookupTable() | ||
|
|
||
| with open(INPUT_FILE_NAME, "r") as gcodeFile, open(OUTPUT_FILE_NAME, "w+") as outputFile: | ||
| for currentLine in gcodeFile: | ||
| @dataclass | ||
| class Point2D: | ||
| x: float | ||
| y: float | ||
|
|
||
| @dataclass | ||
| class GCodeLine: | ||
| x: float | ||
| y: float | ||
| z: float | ||
| e: float | ||
| f: int | ||
|
|
||
| gcode_format: ClassVar[re.Pattern] = ( | ||
| re.compile('(?i)^[gG][0-3](?:\s' | ||
| '+x(?P<x>-?[0-9.]{1,15})|\s' | ||
| '+y(?P<y>-?[0-9.]{1,15})|\s' | ||
| '+z(?P<z>-?[0-9.]{1,15})|\s' | ||
| '+e(?P<e>-?[0-9.]{1,15})|\s' | ||
| '+f(?P<f>-?[0-9.]{1,15}))*') | ||
| ) | ||
|
|
||
| @classmethod | ||
| def from_gcode(cls, line: str): | ||
| line_entries = cls.gcode_format.match(line) | ||
| if line_entries: | ||
| return cls(*line_entries.groups()) | ||
ES-Alexander marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| def main(in_file, out_file, spline, layer_height, warning_angle, | ||
| xy_precision=4, z_precision=3, e_precision=5): | ||
|
|
||
| lastPosition = Point2D(0, 0) | ||
| currentZ = 0.0 | ||
| lastZ = 0.0 | ||
| currentLayer = 0 | ||
| relativeMode = False | ||
|
|
||
| with open(in_file, "r") as gcodeFile, open(out_file, "w+") as outputFile: | ||
| def writeLine(G, X, Y, Z, E=None, F=None): | ||
| output = f'G{G} X{X:.{xy_precision}f} Y{Y:.{xy_precision}f} Z{Z:.{z_precision}f}' | ||
| if E is not None: | ||
| output += f" E{E:.{e_precision}f}" | ||
| if F is not None: | ||
| output += f" F{F}" | ||
| outputFile.write(output + '\n') | ||
|
|
||
| print('Processing: (press CTRL+C to abort)') | ||
| start = perf_counter() | ||
| for index, currentLine in enumerate(gcodeFile): | ||
ES-Alexander marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if index % 1000 == 0: # track progress | ||
| print(f"[{timedelta(seconds=perf_counter()-start)}]: line {index}\r", end='') | ||
| if currentLine[0] == ";": #if NOT a comment | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't this say
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See, I think it should, but it's Stefan's comment, and I'm not certain if he intended it to be commenting about the block being entered, or what happens if execution gets past that block... It's his project, so he gets to choose the commenting style, even if it means I'm stuck between severely disliking this style or it just actually being a mistake. |
||
| outputFile.write(currentLine) | ||
| continue | ||
| if currentLine.find("G91 ") != -1: #filter relative commands | ||
| if currentLine.startswith("G91"): #filter relative commands | ||
| relativeMode = True | ||
| outputFile.write(currentLine) | ||
| continue | ||
| if currentLine.find("G90 ") != -1: #set absolute mode | ||
| if currentLine.startswith("G90 "): #set absolute mode | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You removed the trailing space on L72 but left it in L76. Is there a reason for this or was it an oversight? I feel that both
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I think I actually added an extra space on 72 so the comments would line up between 72 and 76, since they already lined up (which can aid readability). Once again, not sure if an intentional commenting style from Stefan, or just the number of spaces that happened to end up on those lines.
Do you have any particular preference as to what should be there?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. More generally, I expect a bunch of the logic throughout this function could be cleaned up somewhat, I just left it alone beyond where I thought something was error-prone, or needed to change due to the spline refactoring. In my profiling the logic itself wasn't an obvious performance throttle, so I figured my time would be more efficiently spent elsewhere :-) |
||
| relativeMode = False | ||
| outputFile.write(currentLine) | ||
| continue | ||
| if relativeMode: #if in relative mode don't do anything | ||
| outputFile.write(currentLine) | ||
| continue | ||
| currentLineCommands = parseGCode(currentLine) | ||
| currentLineCommands = GCodeLine.from_gcode(currentLine) | ||
| if currentLineCommands is not None: #if current comannd is a valid gcode | ||
| if currentLineCommands.z is not None: #if there is a z height in the command | ||
| currentZ = float(currentLineCommands.z) | ||
|
|
||
| if currentLineCommands.x is None or currentLineCommands.y is None: #if command does not contain x and y movement it#s probably not a print move | ||
| if currentLineCommands.z is not None: #if there is only z movement (e.g. z-hop) | ||
| outputFile.write("G91\nG1 Z" + str(currentZ-lastZ)) | ||
| outputFile.write(f"G91\nG1 Z{currentZ - lastZ}") | ||
| if currentLineCommands.f is not None: | ||
| outputFile.write(" F" + str(currentLineCommands.f)) | ||
| outputFile.write(f" F{currentLineCommands.f}") | ||
| outputFile.write("\nG90\n") | ||
| lastZ = currentZ | ||
| continue | ||
|
|
@@ -142,46 +98,89 @@ def createSplineLookupTable(): | |
| currentPosition = Point2D(float(currentLineCommands.x), float(currentLineCommands.y)) | ||
| midpointX = lastPosition.x + (currentPosition.x - lastPosition.x) / 2 #look for midpoint | ||
|
|
||
| distToSpline = midpointX - SPLINE_X[0] | ||
| distToSpline = midpointX - spline.X[0] | ||
|
|
||
| #Correct the z-height if the spline gets followed | ||
| correctedZHeight = onSplineLength(currentZ) | ||
|
|
||
| angleSplineThisLayer = np.arctan(SPLINE(correctedZHeight, 1)) #inclination angle this layer | ||
|
|
||
| angleLastLayer = np.arctan(SPLINE(correctedZHeight - LAYER_HEIGHT, 1)) # inclination angle previous layer | ||
|
|
||
| correctedZHeight = spline.projected_length(currentZ) | ||
|
|
||
| angleSplineThisLayer = spline.inclination_angle(correctedZHeight) | ||
| angleLastLayer = spline.inclination_angle(correctedZHeight - layer_height) | ||
| heightDifference = np.sin(angleSplineThisLayer - angleLastLayer) * distToSpline * -1 # layer height difference | ||
|
|
||
| transformedGCode = getNormalPoint(Point2D(correctedZHeight, SPLINE(correctedZHeight)), SPLINE(correctedZHeight, 1), currentPosition.x - SPLINE_X[0]) | ||
|
|
||
| transformedGCode = Point2D(*spline.normal_point(currentPosition.x, correctedZHeight)) | ||
|
|
||
| #Check if a move is below Z = 0 | ||
| if float(transformedGCode.x) <= 0.0: | ||
| print("Warning! Movement below build platform. Check your spline!") | ||
|
|
||
| #Detect unplausible moves | ||
| if transformedGCode.x < 0 or np.abs(transformedGCode.x - currentZ) > 50: | ||
| print("Warning! Possibly unplausible move detected on height " + str(currentZ) + " mm!") | ||
| print(f"Warning! Possibly unplausible move detected on height {currentZ} mm!") | ||
| outputFile.write(currentLine) | ||
| continue | ||
| continue | ||
| #Check for self intersection | ||
| if (LAYER_HEIGHT + heightDifference) < 0: | ||
| print("ERROR! Self intersection on height " + str(currentZ) + " mm! Check your spline!") | ||
| if (layer_height + heightDifference) < 0: | ||
| print(f"ERROR! Self intersection on height {currentZ} mm! Check your spline!") | ||
|
|
||
| #Check the angle of the printed layer and warn if it's above the machine limit | ||
| if angleSplineThisLayer > (WARNING_ANGLE * np.pi / 180.): | ||
| print("Warning! Spline angle is", (angleSplineThisLayer * 180. / np.pi), "at height ", str(currentZ), " mm! Check your spline!") | ||
| if angleSplineThisLayer > np.radians(warning_angle): | ||
| print(f"Warning! Spline angle is {np.degrees(angleSplineThisLayer)}, at height {currentZ} mm! Check your spline!") | ||
|
|
||
| if currentLineCommands.e is not None: #if this is a line with extrusion | ||
| """if float(currentLineCommands.e) < 0.0: | ||
| print("Retraction")""" | ||
| extrusionAmount = float(currentLineCommands.e) * ((LAYER_HEIGHT + heightDifference)/LAYER_HEIGHT) | ||
| #outputFile.write(";was" + currentLineCommands.e + " is" + str(extrusionAmount) + " diff" + str(int(((LAYER_HEIGHT + heightDifference)/LAYER_HEIGHT)*100)) + "\n") | ||
| extrusionAmount = float(currentLineCommands.e) * ((layer_height + heightDifference)/layer_height) | ||
| #outputFile.write(";was" + currentLineCommands.e + " is" + str(extrusionAmount) + " diff" + str(int(((layer_height + heightDifference)/layer_height)*100)) + "\n") | ||
| else: | ||
| extrusionAmount = None | ||
| writeLine(1,transformedGCode.y, currentPosition.y, transformedGCode.x, None, extrusionAmount) | ||
| extrusionAmount = None | ||
| writeLine(1,transformedGCode.y, currentPosition.y, transformedGCode.x, extrusionAmount, None) | ||
| lastPosition = currentPosition | ||
| lastZ = currentZ | ||
| else: | ||
| outputFile.write(currentLine) | ||
| print("GCode bending finished!") | ||
|
|
||
| print("\nGCode bending finished!") | ||
| print(f"Processed {index} lines in {timedelta(seconds=perf_counter()-start)}") | ||
|
|
||
| if __name__ == "__main__": | ||
| from argparse import ArgumentParser, ArgumentDefaultsHelpFormatter | ||
|
|
||
| parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter) | ||
| parser.add_argument("in_file", help="input file name (*.gcode)") | ||
| parser.add_argument("-o", "--out_file", help="output filename, default 'BENT_{in_file}'") | ||
| parser.add_argument("-x", "--x_values", default=(125, 95), nargs="*", type=float, | ||
| help=("x values that define the spline, space-separated (e.g. '125 50 33')." | ||
| " First should be in the center of your part.")) | ||
| parser.add_argument("-z", "--z_values", default=(0, 140), nargs="*", type=float, | ||
| help=("corresponding z values that define the spline, space-separated." | ||
| " First should be 0 (e.g. '0 80 140').")) | ||
| parser.add_argument("-l", "--layer_height", default=0.3, type=float, | ||
| help="layer height of the sliced gcode [mm].") | ||
| parser.add_argument("-a", "--warning_angle", default=30, type=float, | ||
| help="Maximum angle [degrees] printable with your setup") | ||
| parser.add_argument("-b", "--bend_angle", default=-30, type=float, | ||
| help="Angle [degrees] of the spline at the top point") | ||
| parser.add_argument("-d", "--discretization_length", default=0.01, type=float, | ||
| help="Discretization length for the spline length lookup table") | ||
| parser.add_argument("-s", "--skip_plot", action="store_true", | ||
| help="flag to skip plotting of the spline") | ||
| parser.add_argument("--xy_precision", type=int, default=4, | ||
| help="Decimals of precision to round x/y values to.") | ||
| parser.add_argument("--z_precision", type=int, default=3, | ||
| help="Decimals of precision to round z (height) values to.") | ||
| parser.add_argument("--e_precision", type=int, default=5, | ||
| help="Decimals of precision to round extrusion amounts to.") | ||
| parser.add_argument("--printer_dims", nargs=2, default=(200, 200), type=float, | ||
| help="printer width and height [mm], space-separated.") | ||
|
|
||
| args = parser.parse_args() | ||
|
|
||
| spline = Spline(args.x_values, args.z_values, args.discretization_length, | ||
| args.bend_angle) | ||
| if not args.skip_plot: | ||
| print("Press Q to close the plot and continue") | ||
| spline.plot(printer_dims=args.printer_dims) | ||
|
|
||
| out_file = args.out_file or f"BENT_{args.in_file}" | ||
|
|
||
| main(args.in_file, out_file, spline, args.layer_height, args.warning_angle, | ||
| args.xy_precision, args.z_precision, args.e_precision) | ||
|
Comment on lines
+145
to
+186
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would have put all of this in a
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't see a particular benefit to that, given it's already in the script portion (so can't be imported), and is only 'called' once. I've never seen a def cli():
parser = ArgumentParser()
parser.add_argument(...)
...
return parser.parse_args()or would the parser be returned instead of the parsed arguments? Given it's your preferred approach, are there benefits I'm missing? |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you should also include
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed that would be useful. I made my own test gcode file to compare against while I was refactoring so I could know the results weren't just garbage. From my verification, the only change that actually affects the generated output is the rounding in the
writeLinefunction, because:str(round(1.23999, 3))->'1.24', whereasf'{1.23999:.3f}'->'1.240') - this may slightly increase file sizes, but is processed faster, and is closer to normal slicer outputsI believe they're always generated inside a
__pycache__directory?