Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.gcode
**__pycache__**

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

*.pyc

Copy link
Author

@ES-Alexander ES-Alexander Feb 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to add [at a minimum] some gcode files to serve as before and after examples to be used to test code refactors and optimizations. But eventually you are going to need full blown unit tests.

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 writeLine function, because:

  • it's now f-string style, which includes trailing zeros (e.g. str(round(1.23999, 3)) -> '1.24', whereas f'{1.23999:.3f}' -> '1.240') - this may slightly increase file sizes, but is processed faster, and is closer to normal slicer outputs
  • it now supports user-specified precision, which can be used to reduce file sizes for less accurate printers/prints

I think you should also include *.pyc

I believe they're always generated inside a __pycache__ directory?

16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Choose a reason for hiding this comment

The 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 Place the sliced GCode in the same directory as the Python script, but a mature CLI tool should not require this. I'm just noting it here before I forget.

Copy link
Author

Choose a reason for hiding this comment

The 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
- `-b`/`--bend_angle` determines the angle of the spline at the last coordinate
255 changes: 127 additions & 128 deletions bend_gcode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())


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):
if index % 1000 == 0: # track progress
print(f"[{timedelta(seconds=perf_counter()-start)}]: line {index}\r", end='')
if currentLine[0] == ";": #if NOT a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this say if IS a comment?

Copy link
Author

Choose a reason for hiding this comment

The 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

Choose a reason for hiding this comment

The 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 str().startswith and str().find are brittle and should be replaced with a dedicated function.

Copy link
Author

Choose a reason for hiding this comment

The 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 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.

I feel that both str().startswith and str().find are brittle and should be replaced with a dedicated function.

find could conceivably be incorrect if G90 appears in a comment. startswith should be correct, although could be marginally more robust with an lstrip() first. I chose to avoid that because it's then less clear, and I figured the input gcode is expected to come from a slicer, and I don't believe they ever put spaces before lines, but perhaps that's not the case (maybe some use indenting or something?).

Do you have any particular preference as to what should be there?

Copy link
Author

Choose a reason for hiding this comment

The 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
Expand All @@ -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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have put all of this in a cli function, but that's just a style preference.

Copy link
Author

Choose a reason for hiding this comment

The 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 cli function before, so am curious how it would be implemented. Are you thinking something like

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?

Loading