From 06d2e008ffbef2f5a26c9854f1561634f70a2de6 Mon Sep 17 00:00:00 2001 From: IridiumIO Date: Fri, 29 Dec 2023 20:35:16 +1000 Subject: [PATCH 01/10] - Refactor to use argparse instead of getopt to minimise code duplication - Removes manual "help" and "default" console commands and uses argparse generators instead. - config files are now passed directly into the command line by using a '$' prefix, e.g. gcodeplot.py $config.txt - most '--no' prefixes are now reduntant. Some are still retained but I don't really know why they were needed in the first place. Renamed some arguments to be clearer in this regard. - Add type hinting for some parameters Issues: - for some reason the 'cut' mode doesn't generate the exact same ordering of overlapping cuts anymore. All cuts still execute correctly, just the order of shapes being cut is different. To be investigated. --- gcodeplot.py | 694 +++++++++++++++++++-------------------------------- 1 file changed, 262 insertions(+), 432 deletions(-) diff --git a/gcodeplot.py b/gcodeplot.py index 6e5e3f9..b47cc95 100755 --- a/gcodeplot.py +++ b/gcodeplot.py @@ -1,8 +1,8 @@ #!/usr/bin/python from __future__ import print_function +from pathlib import Path import re import sys -import getopt import math import xml.etree.ElementTree as ET import gcodeplotutils.anneal as anneal @@ -14,11 +14,12 @@ from svgpath.shader import Shader from gcodeplotutils.processoffset import OffsetProcessor from gcodeplotutils.evaluate import evaluate +import argparse SCALE_NONE = 0 SCALE_DOWN_ONLY = 1 SCALE_FIT = 2 -ALIGN_NONE = 0 +ALIGN_SCALE_NONE = 0 ALIGN_BOTTOM = 1 ALIGN_TOP = 2 ALIGN_LEFT = ALIGN_BOTTOM @@ -26,9 +27,9 @@ ALIGN_CENTER = 3 class Plotter(object): - def __init__(self, xyMin=(7,8), xyMax=(204,178), - drawSpeed=35, moveSpeed=40, zSpeed=5, workZ = 14.5, liftDeltaZ = 2.5, safeDeltaZ = 20, - liftCommand=None, safeLiftCommand=None, downCommand=None, comment=";", + def __init__(self, xyMin:tuple=(7,8), xyMax:tuple=(204,178), + drawSpeed:int=35, moveSpeed:int=40, zSpeed:int=5, workZ:float = 14.5, liftDeltaZ:float= 2.5, safeDeltaZ:float = 20, + liftCommand:str=None, safeLiftCommand:str=None, downCommand:str=None, comment:str=";", initCode = "G00 S1; endstops|" "G00 E0; no extrusion|" "G01 S1; endstops|" @@ -60,6 +61,10 @@ def inRange(self, point): if point[i] < self.xyMin[i]-.001 or point[i] > self.xyMax[i]+.001: return False return True + + def setCoordinates(self,Xmin, Ymin, Xmax, Ymax): + self.xyMin = (Xmin, Ymin) + self.xyMax = (Xmax, Ymax) @property def safeUpZ(self): @@ -109,6 +114,9 @@ def __init__(self, text): self.offset = tuple(map(float, re.sub(r'[()]',r'',data[1]).split(','))) self.color = parser.rgbFromColor(data[2]) self.name = data[3] + + def __repr__(self): + return f"Pen(pen={self.pen}, offset={self.offset}, color={self.color}, name={self.name})" class Scale(object): def __init__(self, scale=(1.,1.), offset=(0.,0.)): @@ -140,7 +148,7 @@ def align(self, plotter, xyMin, xyMax, align): o[i] = plotter.xyMin[i] - self.scale[i]*xyMin[i] elif align[i] == ALIGN_RIGHT: o[i] = plotter.xyMax[i] - self.scale[i]*xyMax[i] - elif align[i] == ALIGN_NONE: + elif align[i] == ALIGN_SCALE_NONE: o[i] = self.offset[i] # self.xyMin[i] elif align[i] == ALIGN_CENTER: o[i] = 0.5 * (plotter.xyMin[i] - self.scale[i]*xyMin[i] + plotter.xyMax[i] - self.scale[i]*xyMax[i]) @@ -330,7 +338,7 @@ def penColor(pens, pen): else: return (0.,0.,0.) -def emitGcode(data, pens = {}, plotter=Plotter(), scalingMode=SCALE_NONE, align = None, tolerance=0, gcodePause="@pause", pauseAtStart = False, simulation = False): +def emitGcode(data, pens = {}, plotter=Plotter(), scalingMode=SCALE_NONE, align = None, tolerance=0, gcodePause="@pause", pauseAtStart = False, simulation = False, quiet = False): if len(data) == 0: return None @@ -693,397 +701,219 @@ def fixComments(plotter, data, comment = ";"): return out -if __name__ == '__main__': - def help(error=False): - if error: - output = sys.stderr + +class PrintDefaultsAction(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + printed = set() + formatted_strings = [ + self.format_argument(action, namespace) + for action in parser._actions + if not isinstance(action, argparse._HelpAction) + and action.help != argparse.SUPPRESS + and (formatted := f'{action.dest}: {action.default}') not in printed and not printed.add(formatted) + ] + print('\n'.join(formatted_strings)) + # parser.exit() + + def format_argument(self, action, namespace): + + if action.dest in ('scale', 'align_x', 'align_y'): + value = parse_alignment(getattr(namespace, action.dest, action.default), reverse=True) + elif action.dest == 'extract_color' and (value := getattr(namespace, action.dest, action.default) ) == None: + value = 'all' else: - output = sys.stdout - output.write("gcodeplot.py [options] [inputfile [> output.gcode]\n") - output.write(""" - --dump-options: show current settings instead of doing anything - -h|--help: this - -r|--allow-repeats*: do not deduplicate paths - -f|--scale=mode: scaling option: none(n), fit(f), down-only(d) [default none; other options don't work with tool-offset] - -D|--input-dpi=xdpi[,ydpi]: hpgl dpi - -t|--tolerance=x: ignore (some) deviations of x millimeters or less [default 0.05] - -s|--send=port*: send gcode to serial port instead of stdout - -S|--send-speed=baud: set baud rate for sending - -x|--align-x=mode: horizontal alignment: none(n), left(l), right(r) or center(c) - -y|--align-y=mode: vertical alignment: none(n), bottom(b), top(t) or center(c) - -a|--area=x1,y1,x2,y2: gcode print area in millimeters - -Z|--lift-delta-z=z: amount to lift for pen-up (millimeters) - -z|--work-z=z: z-position for drawing (millimeters) - -F|--pen-up-speed=z: speed for moving with pen up (millimeters/second) - -f|--pen-down-speed=z: speed for moving with pen down (millimeters/second) - -u|--z-speed=s: speed for up/down movement (millimeters/second) - -H|--hpgl-out*: output is HPGL, not gcode; most options ignored [default: off] - -T|--shading-threshold=n: darkest grayscale to leave unshaded (decimal, 0. to 1.; set to 0 to turn off SVG shading) [default 1.0] - -m|--shading-lightest=x: shading spacing for lightest colors (millimeters) [default 3.0] - -M|--shading-darkest=x: shading spacing for darkest color (millimeters) [default 0.5] - -A|--shading-angle=x: shading angle (degrees) [default 45] - -X|--shading-crosshatch*: cross hatch shading - -L|--stroke-all*: stroke even regions specified by SVG to have no stroke - -O|--shading-avoid-outline*: avoid going over outline twice when shading - -o|--optimization-time=t: max time to spend optimizing (seconds; set to 0 to turn off optimization) [default 60] - -e|--direction=angle: for slanted pens: prefer to draw in given direction (degrees; 0=positive x, 90=positive y, none=no preferred direction) [default none] - -d|--sort*: sort paths from inside to outside for cutting [default off] - -c|--config-file=filename: read arguments, one per line, from filename - -w|--gcode-pause=cmd: gcode pause command [default: @pause] - -P|--pens=penfile: read output pens from penfile - -U|--pause-at-start*: pause at start (can be included without any input file to manually move stuff) - -R|--extract-color=c: extract color (specified in SVG format , e.g., rgb(1,0,0) or #ff0000 or red) - --comment-delimiters=xy: one or two characters specifying comment delimiters, e.g., ";" or "()" - --tool-offset=x: cutting tool offset (millimeters) [default 0.0] - --overcut=x: overcut (millimeters) [default 0.0] - --lift-command=gcode: gcode lift command (separate lines with |) - --down-command=gcode: gcode down command (separate lines with |) - --init-code=gcode: gcode init commands (separate lines with |) - - The options with an asterisk are default off and can be turned off again by adding "no-" at the beginning to the long-form option, e.g., --no-stroke-all or --no-send. -""") - - - tolerance = 0.05 - doDedup = True - sendPort = None - sendSpeed = 115200 - hpglLength = 279.4 - scalingMode = SCALE_NONE - shader = Shader() - align = [ALIGN_NONE, ALIGN_NONE] - plotter = Plotter() - hpglOut = False - strokeAll = False - extractColor = None - gcodePause = "@pause" - optimizationTime = 30 - dpi = (1016., 1016.) - pens = {1:Pen('1 (0.,0.) black default')} - doDump = False - penFilename = None - pauseAtStart = False - sortPaths = False - svgSimulation = False - toolOffset = 0. - overcut = 0. - toolMode = "custom" - booleanExtractColor = False - quiet = False - comment = ";" - sendAndSave = False - directionAngle = None - moonraker = "" - moonrakerFilename = "" - moonrakerAutoprint = "false" + value = getattr(namespace, action.dest, action.default) + return f'{action.dest + ":":<25}{value}' + + +class PenAction(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + pens = {} + pen_file = Path(values) + if pen_file.is_file(): + pens = {p.pen: p for line in open(pen_file) if (line_stripped := line.strip()) and (p := Pen(line_stripped))} + else: + parser.error(f'Invalid filename provided in {self.dest} \n') + setattr(namespace, self.dest, pens) + + + +def parse_alignment(arg, enumMode=False, reverse=False): + verbose_mapping = {'none': 'n', 'left': 'l', 'right': 'r', 'center': 'c', 'bottom': 'b', 'top': 't', 'down': 'd', 'fit': 'f'} + enum_mapping = {'n': ALIGN_SCALE_NONE, 'l': ALIGN_LEFT, 'r': ALIGN_RIGHT, 'c': ALIGN_CENTER, 'b': ALIGN_BOTTOM, 't': ALIGN_TOP, 'd': SCALE_DOWN_ONLY, 'f': SCALE_FIT} + if enumMode: return enum_mapping.get(arg, ALIGN_SCALE_NONE) + if reverse: return next((key for key, value in verbose_mapping.items() if value == arg), None) + return verbose_mapping.get(arg.lower(), 'n') if len(arg) > 1 else arg + +def none_or_str(value): + return None if value=='none' else value + + +def parse_arguments(argparser:argparse.ArgumentParser): + + argparser.add_argument('--dump-options', help='show current settings instead of doing anything', action=PrintDefaultsAction, nargs=0) + + argparser.add_argument('-r', '--allow-repeats', dest='deduplicate', help='do not deduplicate paths',action='store_false') + argparser.add_argument('-f', '--scale', metavar='MODE', choices=['n', 'f', 'd'], default='n', type=parse_alignment, help='scaling option: none(n), fit(f), down-only(d) [default none; other options do not work with tool-offset]') + argparser.add_argument('-D', '--input-dpi', metavar='x[,y]', default=(1016., 1016.), help='hpgl dpi', type=lambda s: tuple(map(float, s.split(','))) if ',' in s else (float(s), float(s))) # returns (x,x) if only one number provided, otherwise returns (x,y) + argparser.add_argument('-t', '--tolerance', metavar='x', default=0.05, type=float, help='ignore (some) deviations of x millimeters or less [default: %(default)s]') - def maybeNone(a): - return None if a=='none' else a + group_send = argparser.add_mutually_exclusive_group() + group_send.add_argument('-s', '--send', type=int, metavar='PORT', default=None, help='Send gcode to serial port instead of stdout') + group_send.add_argument('--no-send', dest='send', action='store_const', const=None, help='Set sendport to None') - try: - opts, args = getopt.getopt(sys.argv[1:], "e:UR:Uhdulw:P:o:Oc:LT:M:m:A:XHrf:na:D:t:s:S:x:y:z:Z:p:f:F:", - ["help", "down", "up", "lower-left", "allow-repeats", "no-allow-repeats", "scale=", "config-file=", - "area=", 'align-x=', 'align-y=', 'optimization-time=', "pens=", - 'input-dpi=', 'tolerance=', 'send=', 'send-speed=', 'work-z=', 'lift-delta-z=', 'safe-delta-z=', - 'pen-down-speed=', 'pen-up-speed=', 'z-speed=', 'hpgl-out', 'no-hpgl-out', 'shading-threshold=', - 'shading-angle=', 'shading-crosshatch', 'no-shading-crosshatch', 'shading-avoid-outline', - 'pause-at-start', 'no-pause-at-start', 'min-x=', 'max-x=', 'min-y=', 'max-y=', - 'no-shading-avoid-outline', 'shading-darkest=', 'shading-lightest=', 'stroke-all', 'no-stroke-all', 'gcode-pause', 'dump-options', 'tab=', 'extract-color=', 'sort', 'no-sort', 'simulation', 'no-simulation', 'tool-offset=', 'overcut=', - 'boolean-shading-crosshatch=', 'boolean-sort=', 'tool-mode=', 'send-and-save=', 'moonraker=', 'moonraker-filename=', 'moonraker-autoprint=','direction=', 'lift-command=', 'down-command=', - 'init-code=', 'comment-delimiters=', 'end-code=' ], ) - - if len(args) + len(opts) == 0: - raise getopt.GetoptError("invalid commandline") - - i = 0 - while i < len(opts): - opt,arg = opts[i] - if opt in ('-r', '--allow-repeats'): - doDedup = False - elif opt == '--no-allow-repeats': - doDedup = True - elif opt in ('-w', '--gcode-pause'): - gcodePause = arg - elif opt in ('-p', '--pens'): - pens = {} - penFilename = arg - with open(arg) as f: - for line in f: - if line.strip(): - p = Pen(line) - pens[p.pen] = p - elif opt in ('-f', '--scale'): - arg = arg.lower() - if arg.startswith('n'): - scalingMode = SCALE_NONE - elif arg.startswith('d'): - scalingMode = SCALE_DOWN_ONLY - elif arg.startswith('f'): - scalingMode = SCALE_FIT - elif opt in ('-x', '--align-x'): - arg = arg.lower() - if arg.startswith('l'): - align[0] = ALIGN_LEFT - elif arg.startswith('r'): - align[0] = ALIGN_RIGHT - elif arg.startswith('c'): - align[0] = ALIGN_CENTER - elif arg.startswith('n'): - align[0] = ALIGN_NONE - else: - raise ValueError() - elif opt in ('-y', '--align-y'): - arg = arg.lower() - if arg.startswith('b'): - align[1] = ALIGN_LEFT - elif arg.startswith('t'): - align[1] = ALIGN_RIGHT - elif arg.startswith('c'): - align[1] = ALIGN_CENTER - elif arg.startswith('n'): - align[1] = ALIGN_NONE - else: - raise ValueError() - elif opt in ('-t', '--tolerance'): - tolerance = float(arg) - elif opt in ('-s', '--send'): - sendPort = None if len(arg.strip()) == 0 else arg - elif opt == '--send-and-save': - sendPort = None if len(arg.strip()) == 0 else arg - if sendPort is not None: - sendAndSave = True - elif opt== '--moonraker': - moonraker = None if len(arg.strip()) == 0 else arg - elif opt== '--moonraker-filename': - moonrakerFilename = "Inkscape.gcode" if len(arg.strip()) == 0 else arg.strip(".gcode") + ".gcode" - elif opt== '--moonraker-autoprint': - moonrakerAutoprint = arg - elif opt == '--no-send': - sendPort = None - elif opt in ('-S', '--send-speed'): - sendSpeed = int(arg) - elif opt in ('-a', '--area'): - v = list(map(float, arg.split(','))) - plotter.xyMin = (v[0],v[1]) - plotter.xyMax = (v[2],v[3]) - elif opt == '--min-x': - plotter.xyMin = (float(arg),plotter.xyMin[1]) - elif opt == '--min-y': - plotter.xyMin = (plotter.xyMin[0],float(arg)) - elif opt == '--max-x': - plotter.xyMax = (float(arg),plotter.xyMax[1]) - elif opt == '--max-y': - plotter.xyMax = (plotter.xyMax[0],float(arg)) - elif opt in ('-D', '--input-dpi'): - v = list(map(float, arg.split(','))) - if len(v) > 1: - dpi = v[0:2] - else: - dpi = (v[0],v[0]) - elif opt in ('-Z', '--lift-delta-z'): - plotter.liftDeltaZ = float(arg) - elif opt in ('-z', '--work-z'): - plotter.workZ = float(arg) - elif opt == '--tool-offset': - toolOffset = float(arg) - elif opt == '--overcut': - overcut = float(arg) - elif opt in ('-p', '--safe-delta-z'): - plotter.safeDeltaZ = float(arg) - elif opt in ('-F', '--pen-up-speed'): - plotter.moveSpeed = float(arg) - elif opt in ('-f', '--pen-down-speed'): - plotter.drawSpeed = float(arg) - elif opt in ('-u', '--z-speed'): - plotter.zSpeed = float(arg) - elif opt in ('-H', '--hpgl-out'): - hpglOut = True - elif opt == '--no-hpgl-out': - hpglOut = False - elif opt in ('-T', '--shading-threshold'): - shader.unshadedThreshold = float(arg) - elif opt in ('-m', '--shading-lightest'): - shader.lightestSpacing = float(arg) - elif opt in ('-M', '--shading-darkest'): - shader.darkestSpacing = float(arg) - elif opt in ('-A', '--shading-angle'): - shader.angle = float(arg) - elif opt == '--boolean-shading-crosshatch': - shader.crossHatch = arg.strip() != 'false' - elif opt == '--boolean-sort': - sort = arg.strip() != 'false' - elif opt in ('-X', '--shading-crosshatch'): - shader.crossHatch = True - elif opt == '--no-shading-crosshatch': - shader.crossHatch = False - elif opt in ('-O', '--shading-avoid-outline'): - avoidOutline = True - elif opt == '--no-shading-avoid-outline': - avoidOutline = False - elif opt == '--no-shading-crosshatch': - shader.crossHatch = False - elif opt == '--pause-at-start': - pauseAtStart = True - elif opt == '--no-pause-at-start': - pauseAtStart = False - elif opt in ('-L', '--stroke-all'): - strokeAll = True - elif opt == '--no-stroke-all': - strokeAll = False - elif opt in ('-c', '--config-file'): - configOpts = getConfigOpts(arg) - opts = opts[:i+1] + configOpts + opts[i+1:] - elif opt in ('-o', '--optimization-time'): - optimizationTime = float(arg) - if optimizationTime > 0: - sort = False - elif opt in ('-h', '--help'): - help() - sys.exit(0) - elif opt == '--dump-options': - doDump = True - elif opt in ('-R', '--extract-color'): - arg = arg.lower() - if arg == 'all' or len(arg.strip())==0: - extractColor = None - else: - extractColor = parser.rgbFromColor(arg) - elif opt in ('-d', '--sort'): - sortPaths = True - optimizationTime = 0 - elif opt == '--no-sort': - sortPaths = False - elif opt in ('U', '--simulation'): - svgSimulation = True - elif opt == '--no-simulation': - svgSimulation = False - elif opt == '--tab': - quiet = True # Inkscape - elif opt == "--tool-mode": - toolMode = arg - elif opt in ('e', '--direction'): - if len(arg.strip()) == 0 or arg == 'none': - directionAngle = None - else: - directionAngle = float(arg) - elif opt == '--lift-command': - plotter.liftCommand = maybeNone(arg) - elif opt == '--down-command': - plotter.downCommand = maybeNone(arg) - elif opt == '--init-code': - plotter.initCode = maybeNone(arg) - elif opt == '--end-code': - plotter.endCode = maybeNone(arg) - elif opt == '--comment-delimiters': - plotter.comment = maybeNone(arg) - else: - raise ValueError("Unrecognized argument "+opt) - i += 1 + argparser.add_argument('-S', '--send-speed', metavar='BAUD', default=115200, help='set baud rate for sending') + argparser.add_argument('--send-and-save', metavar='PORT', default=None, help=argparse.SUPPRESS) + + + argparser.add_argument('-x', '--align-x', metavar='MODE', choices=['n', 'l', 'r', 'c'], default='l', type=parse_alignment, help='horizontal alignment: none(n), left(l), right(r) or center(c)') + argparser.add_argument('-y', '--align-y', metavar='MODE', choices=['n', 'b', 't', 'c'], default='t', type=parse_alignment, help='horizontal alignment: none(n), bottom(b), top(t) or center(c)') - except getopt.GetoptError as e: - sys.stderr.write(str(e)+"\n") - help(error=True) - sys.exit(2) - if doDump: - print('no-allow-repeats' if doDedup else 'allow-repeats') + + # PLOTTER INIT + + argparser.add_argument('-a', '--area', metavar='x1,y1,x2,y2', default=[7, 8, 204, 178], type=lambda s: list(map(float, s.split(','))), help='gcode print area in millimeters') + argparser.add_argument('--min-x', type=float, default=None, help=argparse.SUPPRESS) + argparser.add_argument('--min-y', type=float, default=None, help=argparse.SUPPRESS) + argparser.add_argument('--max-x', type=float, default=None, help=argparse.SUPPRESS) + argparser.add_argument('--max-y', type=float, default=None, help=argparse.SUPPRESS) + + argparser.add_argument('-Z', '--lift-delta-z', metavar='Z', default=2.5, type=float, help='amount to lift for pen-up (millimeters)') + argparser.add_argument('-z', '--work-z', metavar='Z', default=14.5, type=float, help='z-position for drawing (millimeters)') + argparser.add_argument('-V', '--pen-up-speed', metavar='S', default=40, type=float, help='speed for moving with pen up (millimeters/second)') + argparser.add_argument('-v', '--pen-down-speed', metavar='S', default=35, type=float, help='speed for moving with pen down (millimeters/second)') + argparser.add_argument('-u', '--z-speed', metavar='S', default=5, type=float, help='speed for up/down movement (millimeters/second)') + argparser.add_argument('--safe-delta-z', metavar='Z', default=20.0, type=float, help='height to lift tool for safe parking (Default: 20)') + argparser.add_argument('--comment-delimiters', metavar='XY', type=none_or_str, default=';', help='one or two characters specifying comment delimiters, e.g., ";" or "()"') + argparser.add_argument('--lift-command', metavar='GCODE', type=none_or_str, default=None, help='gcode lift command (separate lines with |)') + argparser.add_argument('--down-command', metavar='GCODE', type=none_or_str, default=None, help='gcode down command (separate lines with |)') + argparser.add_argument('--init-code', metavar='GCODE', type=none_or_str, default="G00 S1; endstops|G00 E0; no extrusion|G01 S1; endstops|G01 E0; no extrusion|G21; millimeters|G91 G0 F%.1f{{zspeed*60}} Z%.3f{{safe}}; pen park !!Zsafe|G90; absolute|G28 X; home|G28 Y; home|G28 Z; home", help='gcode init commands (separate lines with |)') + argparser.add_argument('--end-code', metavar='GCODE', type=none_or_str, default=None, help='Gcode to run at end of task') + + + argparser.add_argument('-H', '--hpgl-out', action=argparse.BooleanOptionalAction, default=False, help='output is HPGL, not gcode; most options are ignored.') + + argparser.add_argument('-T', '--shading-threshold', metavar='N', default=1.0, type=float, help='darkest grayscale to leave unshaded (decimal, 0. to 1.; set to 0 to turn off SVG shading) [default 1.0]') + + + argparser.add_argument('-m', '--shading-lightest', metavar='X', default=3.0, type=float, help='shading spacing for lightest colors (millimeters) [default 3.0]') + argparser.add_argument('-M', '--shading-darkest', metavar='X', default=0.5, type=float, help='shading spacing for darkest color (millimeters) [default 0.5]') + argparser.add_argument('-A', '--shading-angle', metavar='X', default=45, type=float, help='shading angle (degrees) [default 45]') + + argparser.add_argument('-X', '--shading-crosshatch', action=argparse.BooleanOptionalAction, default=False, help='cross hatch shading') + + argparser.add_argument('-L', '--stroke-all', action=argparse.BooleanOptionalAction, default=False, help='stroke even regions specified by SVG to have no stroke') + argparser.add_argument('-O', '--shading-avoid-outline', action=argparse.BooleanOptionalAction, default=False, help='avoid going over outline twice when shading') #?Unused + + argparser.add_argument('-e', '--direction', metavar='ANGLE', default=None, type=float, help='for slanted pens: prefer to draw in given direction (degrees; 0=positive x, 90=positive y, -1=no preferred direction) [default none]') + + argparser.add_argument('-o', '--optimization-time', metavar='T', default=60, type=int, help='max time to spend optimizing (seconds; set to 0 to turn off optimization) [default 60]') + argparser.add_argument('-d', '--sort', action=argparse.BooleanOptionalAction, default=False, help='sort paths from inside to outside for cutting [default off]') + + + + # parser.add_argument('-c', '--config-file', metavar='$FILENAME', help='read arguments, one per line, from filename. Prepend the filename with "$" e.g. $"args.txt"') + argparser.add_argument('-w', '--gcode-pause', metavar='CMD', default='@pause', help='gcode pause command [default: @pause]') + argparser.add_argument('-P', '--pens', metavar='PENFILE', default={1:Pen('1 (0.,0.) black default')}, action=PenAction, help='read output pens from penfile') + + argparser.add_argument('-U', '--pause-at-start', action=argparse.BooleanOptionalAction, default=False, help='pause at start (can be included without any input file to manually move stuff)') + + argparser.add_argument('-R', '--extract-color', metavar='C', default=None, type=parser.rgbFromColor, help='extract color (specified in SVG format , e.g., rgb(1,0,0) or #ff0000 or red)') - print('gcode-pause=' + gcodePause) + argparser.add_argument('--tool-offset', metavar='X', default=0.0, type=float, help='cutting tool offset (millimeters) [default 0.0]') + argparser.add_argument('--overcut', metavar='X', default=0.0, type=float, help='overcut (millimeters) [default 0.0]') - if penFilename is not None: - print('pens=' + penFilename) + + argparser.add_argument('--moonraker', metavar='URL', default=None, help='moonraker url') + argparser.add_argument('--moonraker-filename', metavar='FILENAME', default='toolpath.gcode', help='name of uploaded file') + argparser.add_argument('--moonraker-autoprint', metavar='TRUE/FALSE', default=False, help='whether to automatically begin the print job after upload') + + argparser.add_argument('--simulation', metavar='TRUE/FALSE', action=argparse.BooleanOptionalAction, default=False, help=argparse.SUPPRESS) + argparser.add_argument('--tab', dest='quiet', default=False, type=bool, help=argparse.SUPPRESS) + argparser.add_argument('--tool-mode', metavar='MODE', choices=['custom','cut','draw'], default='custom', help=argparse.SUPPRESS) + + #Inkscape specific boolean parameters + argparser.add_argument('--boolean-extract-color', metavar='TRUE/FALSE', dest='extract_color', help=argparse.SUPPRESS) + argparser.add_argument('--boolean-shading-crosshatch', metavar='TRUE/FALSE', dest='shading_crosshatch', help=argparse.SUPPRESS) + argparser.add_argument('--boolean-sort', metavar='TRUE/FALSE', dest='sort', help=argparse.SUPPRESS) + + + + # First pass parsing to set values that will be used for the rest of the calculations + args,_ = argparser.parse_known_args() + + argparser.add_argument('--align', help=argparse.SUPPRESS, default=[parse_alignment(args.align_x, enumMode=True), parse_alignment(args.align_y, enumMode=True)]) + + return argparser.parse_known_args() - if scalingMode == SCALE_NONE: - print('scale=none') - elif scalingMode == SCALE_DOWN_ONLY: - print('scale=down') - else: - print('scale=fit') - - if align[0] == ALIGN_LEFT: - print('align-x=left') - elif align[0] == ALIGN_CENTER: - print('align-x=center') - elif align[0] == ALIGN_RIGHT: - print('align-x=right') - else: - print('align-x=none') - - if align[1] == ALIGN_BOTTOM: - print('align-y=bottom') - elif align[1] == ALIGN_CENTER: - print('align-y=center') - elif align[1] == ALIGN_TOP: - print('align-y=top') - else: - print('align-y=none') - print('tolerance=' + str(tolerance)) +def parse_svg_file(data): + try: + return (elem := ET.fromstring(data)) if 'svg' in elem.tag else None + except: + return None - if sendPort is not None: - print('send=' + str(sendPort)) - else: - print('no-send') - - print('send-speed=' + str(sendSpeed)) - print('area=%g,%g,%g,%g' % tuple(list(plotter.xyMin)+list(plotter.xyMax))) - print('input-dpi=%g,%g' % tuple(dpi)) - print('safe-delta-z=%g' % (plotter.safeDeltaZ)) - print('lift-delta-z=%g' % (plotter.liftDeltaZ)) - print('work-z=%g' % (plotter.workZ)) - print('pen-down-speed=%g' % (plotter.drawSpeed)) - print('pen-up-speed=%g' % (plotter.moveSpeed)) - print('z-speed=%g' % (plotter.zSpeed)) - print('hpgl-out' if hpglOut else 'no-hpgl-out') - print('shading-threshold=%g' % (shader.unshadedThreshold)) - print('shading-lightest=%g' % (shader.lightestSpacing)) - print('shading-darkest=%g' % (shader.darkestSpacing)) - print('shading-angle=%g' % (shader.angle)) - print('shading-crosshatch' if shader.crossHatch else 'no-shading-crosshatch') - print('stroke-all' if strokeAll else 'no-stroke-all') - print('optimization-time=%g' % (optimizationTime)) - print('sort' if sortPaths else 'no-sort') - print('pause-at-start' if pauseAtStart else 'no-pause-at-start') - print('extract-color=all' if extractColor is None else 'extract-color=rgb(%.3f,%.3f,%.3f)' % tuple(extractColor)) - print('tool-offset=%.3f' % toolOffset) - print('overcut=%.3f' % overcut) - print('simulation' if svgSimulation else 'no-simulation') - print('direction=' + ('none' if directionAngle is None else '%.3f'%directionAngle)) - print('lift-command=' + ('none' if plotter.liftCommand is None else plotter.liftCommand)) - print('down-command=' + ('none' if plotter.downCommand is None else plotter.downCommand)) - print('init-code=' + ('none' if plotter.initCode is None else plotter.initCode)) - print('end-code=' + ('none' if plotter.endCode is None else plotter.endCode)) - print('comment-delimiters=' + ('none' if plotter.comment is None else plotter.comment)) - sys.exit(0) +if __name__ == '__main__': + + argparser = argparse.ArgumentParser(prog='Gcode Plot', description='test', fromfile_prefix_chars='$', epilog="You can load options from a text file by passing the filename prefixed with a '$' e.g. [python gcodeplot.py $'args.txt']", formatter_class=argparse.ArgumentDefaultsHelpFormatter) + args, rem = parse_arguments(argparser) + + sendPort = args.send if args.send is not None else args.send_and_save + sendAndSave = args.send_and_save is not None + scalingMode = parse_alignment(args.scale, enumMode=True) + optimizationTime = 0 if args.sort else args.optimization_time + sortPaths = False if optimizationTime > 0 else args.sort + # directionAngle = args.direction #TODO: go back to the argument and replace "-1" with "none" using type=lamba allocation + + plotter = Plotter(xyMin=tuple((args.min_x if args.min_x is not None else args.area[0], args.min_y if args.min_y is not None else args.area[1])), + xyMax=tuple((args.max_x if args.max_x is not None else args.area[2], args.max_y if args.max_y is not None else args.area[3])), + drawSpeed=args.pen_down_speed, + moveSpeed=args.pen_up_speed, + zSpeed=args.z_speed, + workZ=args.work_z, + liftDeltaZ=args.lift_delta_z, + safeDeltaZ=args.safe_delta_z, + liftCommand=args.lift_command, + safeLiftCommand=None, + downCommand=args.down_command, + initCode=args.init_code, + endCode=args.end_code, + comment=args.comment_delimiters) + + shader = Shader(unshadedThreshold=args.shading_threshold, + lightestSpacing=args.shading_lightest, + darkestSpacing=args.shading_darkest, + angle=args.shading_angle, + crossHatch=args.shading_crosshatch) - if toolMode == 'cut': + if args.tool_mode == 'cut': shader.unshadedThreshold = 0 optimizationTime = 0 sortPaths = True - directionAngle = None - elif toolMode == 'draw': - toolOffset = 0. + args.direction = None + elif args.tool_mode == 'draw': + args.tool_offset = 0. sortPaths = False - - plotter.updateVariables() - - if len(args) == 0: - if not pauseAtStart: - help() - if sendPort is None: + plotter.updateVariables() + + if len(rem) == 0: + if not args.pause_at_start: + argparser.print_help() + if sendPort is None: sys.stderr.write("Need to specify --send=port to be able to pause without any file.") sys.exit(1) import gcodeplotutils.sendgcode as sendgcode - - sendgcode.sendGcode(port=sendPort, speed=sendSpeed, commands=gcodeHeader(plotter) + [gcodePause], gcodePause=gcodePause, variables=plotter.variables, formulas=plotter.formulas) + + sendgcode.sendGcode(port=sendPort, speed=args.send_speed, commands=gcodeHeader(plotter) + [args.gcode_pause], gcodePause=args.gcode_pause, variables=plotter.variables, formulas=plotter.formulas) sys.exit(0) - - with open(args[0], 'r') as f: + + with open(rem[0], 'r') as f: #TODO: Change this back to 'r' instead of binary if Inkscape works data = f.read() - + svgTree = None try: @@ -1097,86 +927,86 @@ def maybeNone(a): sys.stderr.write("Unrecognized file.\n") exit(1) - shader.setDrawingDirectionAngle(directionAngle) + shader.setDrawingDirectionAngle(args.direction) + if svgTree is not None: - penData = parseSVG(svgTree, tolerance=tolerance, shader=shader, strokeAll=strokeAll, pens=pens, extractColor=extractColor) + penData = parseSVG(svgTree, tolerance=args.tolerance, shader=shader, strokeAll=args.stroke_all, pens=args.pens, extractColor=args.extract_color if args.extract_color != False else None) else: - penData = parseHPGL(data, dpi=dpi) + penData = parseHPGL(data, dpi=args.input_dpi) penData = removePenBob(penData) - - if doDedup: + + if args.deduplicate: penData = dedup(penData) - + if sortPaths: for pen in penData: penData[pen] = safeSorted(penData[pen], comparison=comparePaths) penData = removePenBob(penData) - - if optimizationTime > 0. and directionAngle is None: + + if optimizationTime > 0. and args.direction is None: for pen in penData: - penData[pen] = anneal.optimize(penData[pen], timeout=optimizationTime/2., quiet=quiet) + penData[pen] = anneal.optimize(penData[pen], timeout=optimizationTime/2., quiet=args.quiet) penData = removePenBob(penData) - - if toolOffset > 0. or overcut > 0.: + + if args.tool_offset > 0. or args.overcut > 0.: if scalingMode != SCALE_NONE: sys.stderr.write("Scaling with tool-offset > 0 will produce unpredictable results.\n") - op = OffsetProcessor(toolOffset=toolOffset, overcut=overcut, tolerance=tolerance) + op = OffsetProcessor(toolOffset=args.tool_offset, overcut=args.overcut, tolerance=args.tolerance) for pen in penData: penData[pen] = op.processPath(penData[pen]) + - if directionAngle is not None: + if args.direction is not None: for pen in penData: - penData[pen] = directionalize(penData[pen], directionAngle) + penData[pen] = directionalize(penData[pen], args.direction) penData = removePenBob(penData) - + if len(penData) > 1: sys.stderr.write("Uses the following pens:\n") for pen in sorted(penData): - sys.stderr.write(describePen(pens, pen)+"\n") - - if hpglOut and not svgSimulation: - g = emitHPGL(penData, pens=pens) + sys.stderr.write(describePen(args.pens, pen)+"\n") + + if args.hpgl_out and not args.simulation: + g = emitHPGL(penData, pens=args.pens) else: - g = emitGcode(penData, align=align, scalingMode=scalingMode, tolerance=tolerance, - plotter=plotter, gcodePause=gcodePause, pens=pens, pauseAtStart=pauseAtStart, simulation=svgSimulation) - - if g: - dump = True - if sendPort is not None and not svgSimulation: - import gcodeplotutils.sendgcode as sendgcode - - dump = sendAndSave - - if hpglOut: - sendgcode.sendHPGL(port=sendPort, speed=sendSpeed, commands=g) - else: - sendgcode.sendGcode(port=sendPort, speed=sendSpeed, commands=g, gcodePause=gcodePause, plotter=plotter, variables=plotter.variables, formulas=plotter.formulas) - - if dump: - if hpglOut: - sys.stdout.write(g) - else: - if moonraker != "": - - - moonraker = moonraker.strip("/") + "/server/files/upload" - - filtered = '\n'.join(fixComments(plotter, g, comment=plotter.comment)) + '\n' - - virtual_file = io.BytesIO(filtered.encode('utf-8')) - - files = {'file': (moonrakerFilename, virtual_file), 'print': moonrakerAutoprint} - response = requests.post(moonraker, files=files) - if response.status_code != 201: - sys.stderr.write(f"Error uploading file. Status code: {response.status_code}") - - print('\n'.join(fixComments(plotter, g, comment=plotter.comment))) - - else: - print('\n'.join(fixComments(plotter, g, comment=plotter.comment))) + g = emitGcode(penData, align=args.align, scalingMode=scalingMode, tolerance=args.tolerance, + plotter=plotter, gcodePause=args.gcode_pause, pens=args.pens, pauseAtStart=args.pause_at_start, simulation=args.simulation, quiet=args.quiet) - else: + if not g: sys.stderr.write("No points.") sys.exit(1) + + dump = True + + if sendPort is not None and not args.simulation: + import gcodeplotutils.sendgcode as sendgcode + + dump = sendAndSave + + if args.hpgl_out: + sendgcode.sendHPGL(port=sendPort, speed=args.send_speed, commands=g) + else: + sendgcode.sendGcode(port=sendPort, speed=args.send_speed, commands=g, gcodePause=args.gcode_pause, plotter=plotter, variables=plotter.variables, formulas=plotter.formulas) + + if not dump: + sys.exit(0) + + + if args.hpgl_out: + sys.stdout.write(g) + sys.exit(0) + + filtered = '\n'.join(fixComments(plotter, g, comment=plotter.comment)) + '\n' + if args.moonraker != "" and args.moonraker is not None: + moonraker = args.moonraker.strip("/") + "/server/files/upload" + + virtual_file = io.BytesIO(filtered.encode('utf-8')) + files = {'file': (args.moonraker_filename, virtual_file), 'print': args.moonraker_autoprint} + response = requests.post(moonraker, files=files) + if response.status_code != 201: + sys.stderr.write(f"Error uploading file. Status code: {response.status_code}") + + print('\n'.join(fixComments(plotter, g, comment=plotter.comment))) + \ No newline at end of file From e1635b9a81d169ab015ee77f43df8113db8b2ef9 Mon Sep 17 00:00:00 2001 From: IridiumIO Date: Fri, 29 Dec 2023 20:40:29 +1000 Subject: [PATCH 02/10] GCode can now be exported using the File > Export menu (to avoid constantly having to use "Save As" Updated layout of Inkscape config menu. - Added separators and groups to collect similar settings together visually - Now uses type="color" for the extract-color option, allowing visual picking of a color instead of typing in a string. --- gcodeplot.inx | 82 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 51 insertions(+), 31 deletions(-) diff --git a/gcodeplot.inx b/gcodeplot.inx index 7028dde..e1a0bb6 100644 --- a/gcodeplot.inx +++ b/gcodeplot.inx @@ -6,29 +6,42 @@ gcodeplot.py - - drawing - cutting - custom + + - none (needed if tool offset>0) - fit - down-only + none (needed if tool offset>0) + fit + down-only none @@ -42,17 +55,22 @@ center right - + + + + + + 1 3 0.5 45 - 0 + false 60 - none + none 0 (positive x) 45 90 (positive y) @@ -66,24 +84,26 @@ 1 1 - 1 + true - + - - Inkscape.gcode + + Inkscape.gcode + + false - + - - + + 115200 300 600 @@ -102,14 +122,14 @@ - + .gcode text/plain - 3-axis gcode plotter (*.gcode) - Export 3-axis gcode plotter file + G-Code (*.gcode) + Export 3-axis G-code plotter file true - + \ No newline at end of file From 31816fb2f06bbde9e545a2b0e2bc554aa4667b68 Mon Sep 17 00:00:00 2001 From: IridiumIO Date: Fri, 29 Dec 2023 20:48:26 +1000 Subject: [PATCH 03/10] - Removed uneccesary argument creation - Separated svgTree parsing --- gcodeplot.py | 27 +++++---------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/gcodeplot.py b/gcodeplot.py index b47cc95..bcbebf4 100755 --- a/gcodeplot.py +++ b/gcodeplot.py @@ -839,19 +839,13 @@ def parse_arguments(argparser:argparse.ArgumentParser): argparser.add_argument('--boolean-shading-crosshatch', metavar='TRUE/FALSE', dest='shading_crosshatch', help=argparse.SUPPRESS) argparser.add_argument('--boolean-sort', metavar='TRUE/FALSE', dest='sort', help=argparse.SUPPRESS) - - - # First pass parsing to set values that will be used for the rest of the calculations - args,_ = argparser.parse_known_args() - - argparser.add_argument('--align', help=argparse.SUPPRESS, default=[parse_alignment(args.align_x, enumMode=True), parse_alignment(args.align_y, enumMode=True)]) - return argparser.parse_known_args() def parse_svg_file(data): try: - return (elem := ET.fromstring(data)) if 'svg' in elem.tag else None + svgTree = ET.fromstring(data) + return svgTree if 'svg' in svgTree.tag else None except: return None @@ -914,18 +908,7 @@ def parse_svg_file(data): with open(rem[0], 'r') as f: #TODO: Change this back to 'r' instead of binary if Inkscape works data = f.read() - svgTree = None - - try: - svgTree = ET.fromstring(data) - if not 'svg' in svgTree.tag: - svgTree = None - except: - svgTree = None - - if svgTree is None and 'PD' not in data and 'PU' not in data: - sys.stderr.write("Unrecognized file.\n") - exit(1) + svgTree = parse_svg_file(data) shader.setDrawingDirectionAngle(args.direction) @@ -969,8 +952,8 @@ def parse_svg_file(data): if args.hpgl_out and not args.simulation: g = emitHPGL(penData, pens=args.pens) else: - - g = emitGcode(penData, align=args.align, scalingMode=scalingMode, tolerance=args.tolerance, + align = [parse_alignment(args.align_x, enumMode=True), parse_alignment(args.align_y, enumMode=True)] + g = emitGcode(penData, align=align, scalingMode=scalingMode, tolerance=args.tolerance, plotter=plotter, gcodePause=args.gcode_pause, pens=args.pens, pauseAtStart=args.pause_at_start, simulation=args.simulation, quiet=args.quiet) if not g: From d9f71da2b1e510384a31c157470b55a11491021a Mon Sep 17 00:00:00 2001 From: IridiumIO Date: Fri, 29 Dec 2023 20:49:52 +1000 Subject: [PATCH 04/10] Update rgbFromColor() to accept the new handling of colors from Inkscape as uint32 numbers. --- svgpath/parser.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/svgpath/parser.py b/svgpath/parser.py index 2f6f867..79c6e3c 100644 --- a/svgpath/parser.py +++ b/svgpath/parser.py @@ -412,7 +412,7 @@ def sizeFromString(text): def rgbFromColor(colorName): colorName = colorName.strip().lower() - if colorName == 'none': + if colorName == 'none' or colorName == 'all' or len(colorName) == 0 or colorName == False: return None cmd = re.split(r'[\s(),]+', colorName) if cmd[0] == 'rgb': @@ -429,6 +429,9 @@ def rgbFromColor(colorName): return (int(colorName[1],16)/15., int(colorName[2],16)/15., int(colorName[3],16)/15.) else: return (int(colorName[1:3],16)/255., int(colorName[3:5],16)/255., int(colorName[5:7],16)/255.) + elif colorName.isdigit(): + hex_color = '#' + hex(int(colorName))[2:].zfill(8) + return rgbFromColor(hex_color) else: return SVG_COLORS[colorName] From 44ff30c1f519f1c00a0cd96631e529860dd0f4ee Mon Sep 17 00:00:00 2001 From: IridiumIO Date: Fri, 29 Dec 2023 20:57:51 +1000 Subject: [PATCH 05/10] Undid the previous change to use "-1" instead of "none" for null direction angle values. Now accepts "none" at the commandline again - possible that this was causing the variation in the order that shapes were being cut when using 'cut' mode. --- gcodeplot.inx | 2 +- gcodeplot.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/gcodeplot.inx b/gcodeplot.inx index e1a0bb6..b524409 100644 --- a/gcodeplot.inx +++ b/gcodeplot.inx @@ -70,7 +70,7 @@ false 60 - none + none 0 (positive x) 45 90 (positive y) diff --git a/gcodeplot.py b/gcodeplot.py index bcbebf4..8532916 100755 --- a/gcodeplot.py +++ b/gcodeplot.py @@ -807,7 +807,7 @@ def parse_arguments(argparser:argparse.ArgumentParser): argparser.add_argument('-L', '--stroke-all', action=argparse.BooleanOptionalAction, default=False, help='stroke even regions specified by SVG to have no stroke') argparser.add_argument('-O', '--shading-avoid-outline', action=argparse.BooleanOptionalAction, default=False, help='avoid going over outline twice when shading') #?Unused - argparser.add_argument('-e', '--direction', metavar='ANGLE', default=None, type=float, help='for slanted pens: prefer to draw in given direction (degrees; 0=positive x, 90=positive y, -1=no preferred direction) [default none]') + argparser.add_argument('-e', '--direction', metavar='ANGLE', default=None, type=lambda value: None if value.lower() == 'none' else float(value), help='for slanted pens: prefer to draw in given direction (degrees; 0=positive x, 90=positive y, none=no preferred direction) [default none]') argparser.add_argument('-o', '--optimization-time', metavar='T', default=60, type=int, help='max time to spend optimizing (seconds; set to 0 to turn off optimization) [default 60]') argparser.add_argument('-d', '--sort', action=argparse.BooleanOptionalAction, default=False, help='sort paths from inside to outside for cutting [default off]') @@ -859,8 +859,7 @@ def parse_svg_file(data): sendAndSave = args.send_and_save is not None scalingMode = parse_alignment(args.scale, enumMode=True) optimizationTime = 0 if args.sort else args.optimization_time - sortPaths = False if optimizationTime > 0 else args.sort - # directionAngle = args.direction #TODO: go back to the argument and replace "-1" with "none" using type=lamba allocation + sortPaths = False if optimizationTime > 0 else args.sort plotter = Plotter(xyMin=tuple((args.min_x if args.min_x is not None else args.area[0], args.min_y if args.min_y is not None else args.area[1])), xyMax=tuple((args.max_x if args.max_x is not None else args.area[2], args.max_y if args.max_y is not None else args.area[3])), From d7768599e3dfbe04f818cd11b759a8a73ab586d3 Mon Sep 17 00:00:00 2001 From: IridiumIO Date: Fri, 29 Dec 2023 21:24:03 +1000 Subject: [PATCH 06/10] Split penData creation into separate function --- gcodeplot.py | 115 +++++++++++++++++++++++++++------------------------ 1 file changed, 60 insertions(+), 55 deletions(-) diff --git a/gcodeplot.py b/gcodeplot.py index 8532916..3641d7c 100755 --- a/gcodeplot.py +++ b/gcodeplot.py @@ -850,10 +850,49 @@ def parse_svg_file(data): return None + +def generate_pen_data(svgTree, data, args, sortPaths, optimizationTime, shader:Shader): + penData = None + + if svgTree is not None: + penData = parseSVG(svgTree, tolerance=args.tolerance, shader=shader, strokeAll=args.stroke_all, pens=args.pens, extractColor=args.extract_color if args.extract_color is not False else None) + else: + penData = parseHPGL(data, dpi=args.input_dpi) + + penData = removePenBob(penData) + + if args.deduplicate: + penData = dedup(penData) + + if sortPaths and penData: + penData = {pen: safeSorted(paths, comparison=comparePaths) for pen, paths in penData.items()} + penData = removePenBob(penData) + + if optimizationTime > 0. and args.direction is None and penData: + penData = {pen: anneal.optimize(paths, timeout=optimizationTime/2., quiet=args.quiet) for pen, paths in penData.items()} + penData = removePenBob(penData) + + if (args.tool_offset > 0. or args.overcut > 0.) and penData: + if args.scalingMode != SCALE_NONE: + sys.stderr.write("Scaling with tool-offset > 0 will produce unpredictable results.\n") + op = OffsetProcessor(toolOffset=args.tool_offset, overcut=args.overcut, tolerance=args.tolerance) + penData = {pen: op.processPath(paths) for pen, paths in penData.items()} + + if args.direction is not None and penData: + penData = {pen: directionalize(paths, args.direction) for pen, paths in penData.items()} + penData = removePenBob(penData) + + if len(penData) > 1 and penData: + sys.stderr.write("Uses the following pens:\n") + for pen in sorted(penData): + sys.stderr.write(describePen(args.pens, pen)+"\n") + + + if __name__ == '__main__': argparser = argparse.ArgumentParser(prog='Gcode Plot', description='test', fromfile_prefix_chars='$', epilog="You can load options from a text file by passing the filename prefixed with a '$' e.g. [python gcodeplot.py $'args.txt']", formatter_class=argparse.ArgumentDefaultsHelpFormatter) - args, rem = parse_arguments(argparser) + args, positional = parse_arguments(argparser) sendPort = args.send if args.send is not None else args.send_and_save sendAndSave = args.send_and_save is not None @@ -862,25 +901,25 @@ def parse_svg_file(data): sortPaths = False if optimizationTime > 0 else args.sort plotter = Plotter(xyMin=tuple((args.min_x if args.min_x is not None else args.area[0], args.min_y if args.min_y is not None else args.area[1])), - xyMax=tuple((args.max_x if args.max_x is not None else args.area[2], args.max_y if args.max_y is not None else args.area[3])), - drawSpeed=args.pen_down_speed, - moveSpeed=args.pen_up_speed, - zSpeed=args.z_speed, - workZ=args.work_z, - liftDeltaZ=args.lift_delta_z, - safeDeltaZ=args.safe_delta_z, - liftCommand=args.lift_command, - safeLiftCommand=None, - downCommand=args.down_command, - initCode=args.init_code, - endCode=args.end_code, - comment=args.comment_delimiters) + xyMax=tuple((args.max_x if args.max_x is not None else args.area[2], args.max_y if args.max_y is not None else args.area[3])), + drawSpeed=args.pen_down_speed, + moveSpeed=args.pen_up_speed, + zSpeed=args.z_speed, + workZ=args.work_z, + liftDeltaZ=args.lift_delta_z, + safeDeltaZ=args.safe_delta_z, + liftCommand=args.lift_command, + safeLiftCommand=None, + downCommand=args.down_command, + initCode=args.init_code, + endCode=args.end_code, + comment=args.comment_delimiters) shader = Shader(unshadedThreshold=args.shading_threshold, - lightestSpacing=args.shading_lightest, - darkestSpacing=args.shading_darkest, - angle=args.shading_angle, - crossHatch=args.shading_crosshatch) + lightestSpacing=args.shading_lightest, + darkestSpacing=args.shading_darkest, + angle=args.shading_angle, + crossHatch=args.shading_crosshatch) if args.tool_mode == 'cut': shader.unshadedThreshold = 0 @@ -893,7 +932,7 @@ def parse_svg_file(data): plotter.updateVariables() - if len(rem) == 0: + if len(positional) == 0: if not args.pause_at_start: argparser.print_help() if sendPort is None: @@ -904,49 +943,15 @@ def parse_svg_file(data): sendgcode.sendGcode(port=sendPort, speed=args.send_speed, commands=gcodeHeader(plotter) + [args.gcode_pause], gcodePause=args.gcode_pause, variables=plotter.variables, formulas=plotter.formulas) sys.exit(0) - with open(rem[0], 'r') as f: #TODO: Change this back to 'r' instead of binary if Inkscape works + with open(positional[0], 'r') as f: data = f.read() svgTree = parse_svg_file(data) shader.setDrawingDirectionAngle(args.direction) - if svgTree is not None: - penData = parseSVG(svgTree, tolerance=args.tolerance, shader=shader, strokeAll=args.stroke_all, pens=args.pens, extractColor=args.extract_color if args.extract_color != False else None) - else: - penData = parseHPGL(data, dpi=args.input_dpi) - penData = removePenBob(penData) - - if args.deduplicate: - penData = dedup(penData) - - if sortPaths: - for pen in penData: - penData[pen] = safeSorted(penData[pen], comparison=comparePaths) - penData = removePenBob(penData) - - if optimizationTime > 0. and args.direction is None: - for pen in penData: - penData[pen] = anneal.optimize(penData[pen], timeout=optimizationTime/2., quiet=args.quiet) - penData = removePenBob(penData) - - if args.tool_offset > 0. or args.overcut > 0.: - if scalingMode != SCALE_NONE: - sys.stderr.write("Scaling with tool-offset > 0 will produce unpredictable results.\n") - op = OffsetProcessor(toolOffset=args.tool_offset, overcut=args.overcut, tolerance=args.tolerance) - for pen in penData: - penData[pen] = op.processPath(penData[pen]) - + penData = generate_pen_data(svgTree, data, args, sortPaths, optimizationTime, shader) - if args.direction is not None: - for pen in penData: - penData[pen] = directionalize(penData[pen], args.direction) - penData = removePenBob(penData) - - if len(penData) > 1: - sys.stderr.write("Uses the following pens:\n") - for pen in sorted(penData): - sys.stderr.write(describePen(args.pens, pen)+"\n") if args.hpgl_out and not args.simulation: g = emitHPGL(penData, pens=args.pens) From dd33b3ee6d0272b7ee321250487c288973ade063 Mon Sep 17 00:00:00 2001 From: IridiumIO Date: Fri, 29 Dec 2023 23:13:07 +1000 Subject: [PATCH 07/10] Add missing return in generate_pen_data Clean up imports --- gcodeplot.py | 48 ++++++++++++++++++++++-------------------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/gcodeplot.py b/gcodeplot.py index 3641d7c..2ef8e4a 100755 --- a/gcodeplot.py +++ b/gcodeplot.py @@ -1,20 +1,21 @@ #!/usr/bin/python from __future__ import print_function +from gcodeplotutils.evaluate import evaluate +from gcodeplotutils.processoffset import OffsetProcessor from pathlib import Path -import re -import sys -import math -import xml.etree.ElementTree as ET -import gcodeplotutils.anneal as anneal -import svgpath.parser as parser -import cmath -import requests -import io from random import sample from svgpath.shader import Shader -from gcodeplotutils.processoffset import OffsetProcessor -from gcodeplotutils.evaluate import evaluate import argparse +import cmath +import gcodeplotutils.anneal as anneal +import gcodeplotutils.sendgcode as sendgcode +import io +import math +import re +import requests +import svgpath.parser as parser +import sys +import xml.etree.ElementTree as ET SCALE_NONE = 0 SCALE_DOWN_ONLY = 1 @@ -851,8 +852,8 @@ def parse_svg_file(data): -def generate_pen_data(svgTree, data, args, sortPaths, optimizationTime, shader:Shader): - penData = None +def generate_pen_data(svgTree, data, args, sortPaths, optimizationTime, scalingMode, shader:Shader): + penData = {} if svgTree is not None: penData = parseSVG(svgTree, tolerance=args.tolerance, shader=shader, strokeAll=args.stroke_all, pens=args.pens, extractColor=args.extract_color if args.extract_color is not False else None) @@ -873,7 +874,7 @@ def generate_pen_data(svgTree, data, args, sortPaths, optimizationTime, shader:S penData = removePenBob(penData) if (args.tool_offset > 0. or args.overcut > 0.) and penData: - if args.scalingMode != SCALE_NONE: + if scalingMode != SCALE_NONE: sys.stderr.write("Scaling with tool-offset > 0 will produce unpredictable results.\n") op = OffsetProcessor(toolOffset=args.tool_offset, overcut=args.overcut, tolerance=args.tolerance) penData = {pen: op.processPath(paths) for pen, paths in penData.items()} @@ -887,7 +888,7 @@ def generate_pen_data(svgTree, data, args, sortPaths, optimizationTime, shader:S for pen in sorted(penData): sys.stderr.write(describePen(args.pens, pen)+"\n") - + return penData if __name__ == '__main__': @@ -932,25 +933,25 @@ def generate_pen_data(svgTree, data, args, sortPaths, optimizationTime, shader:S plotter.updateVariables() + # If no file is provided on the input, assume the intent is to run the init g-code over serial. if len(positional) == 0: if not args.pause_at_start: argparser.print_help() if sendPort is None: sys.stderr.write("Need to specify --send=port to be able to pause without any file.") sys.exit(1) - import gcodeplotutils.sendgcode as sendgcode - + sendgcode.sendGcode(port=sendPort, speed=args.send_speed, commands=gcodeHeader(plotter) + [args.gcode_pause], gcodePause=args.gcode_pause, variables=plotter.variables, formulas=plotter.formulas) sys.exit(0) - + + # Otherwise, open the input file with open(positional[0], 'r') as f: data = f.read() svgTree = parse_svg_file(data) - shader.setDrawingDirectionAngle(args.direction) - penData = generate_pen_data(svgTree, data, args, sortPaths, optimizationTime, shader) + penData = generate_pen_data(svgTree, data, args, sortPaths, optimizationTime, scalingMode, shader) if args.hpgl_out and not args.simulation: @@ -964,12 +965,9 @@ def generate_pen_data(svgTree, data, args, sortPaths, optimizationTime, shader:S sys.stderr.write("No points.") sys.exit(1) - dump = True if sendPort is not None and not args.simulation: - import gcodeplotutils.sendgcode as sendgcode - dump = sendAndSave if args.hpgl_out: @@ -979,7 +977,6 @@ def generate_pen_data(svgTree, data, args, sortPaths, optimizationTime, shader:S if not dump: sys.exit(0) - if args.hpgl_out: sys.stdout.write(g) @@ -987,8 +984,7 @@ def generate_pen_data(svgTree, data, args, sortPaths, optimizationTime, shader:S filtered = '\n'.join(fixComments(plotter, g, comment=plotter.comment)) + '\n' if args.moonraker != "" and args.moonraker is not None: - moonraker = args.moonraker.strip("/") + "/server/files/upload" - + moonraker = args.moonraker.strip("/") + "/server/files/upload" virtual_file = io.BytesIO(filtered.encode('utf-8')) files = {'file': (args.moonraker_filename, virtual_file), 'print': args.moonraker_autoprint} response = requests.post(moonraker, files=files) From 2cb4dc9ab19ad53ef5ddedfce10814ee9dfb8f0b Mon Sep 17 00:00:00 2001 From: IridiumIO Date: Sat, 30 Dec 2023 00:00:10 +1000 Subject: [PATCH 08/10] Remove unecessary(?) variable assignment --- gcodeplot.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/gcodeplot.py b/gcodeplot.py index 2ef8e4a..a2e57eb 100755 --- a/gcodeplot.py +++ b/gcodeplot.py @@ -852,7 +852,7 @@ def parse_svg_file(data): -def generate_pen_data(svgTree, data, args, sortPaths, optimizationTime, scalingMode, shader:Shader): +def generate_pen_data(svgTree, data, args, scalingMode, shader:Shader): penData = {} if svgTree is not None: @@ -865,12 +865,12 @@ def generate_pen_data(svgTree, data, args, sortPaths, optimizationTime, scalingM if args.deduplicate: penData = dedup(penData) - if sortPaths and penData: + if args.sort and penData: penData = {pen: safeSorted(paths, comparison=comparePaths) for pen, paths in penData.items()} penData = removePenBob(penData) - if optimizationTime > 0. and args.direction is None and penData: - penData = {pen: anneal.optimize(paths, timeout=optimizationTime/2., quiet=args.quiet) for pen, paths in penData.items()} + if args.optimization_time > 0. and args.direction is None and penData: + penData = {pen: anneal.optimize(paths, timeout=args.optimization_time/2., quiet=args.quiet) for pen, paths in penData.items()} penData = removePenBob(penData) if (args.tool_offset > 0. or args.overcut > 0.) and penData: @@ -898,8 +898,8 @@ def generate_pen_data(svgTree, data, args, sortPaths, optimizationTime, scalingM sendPort = args.send if args.send is not None else args.send_and_save sendAndSave = args.send_and_save is not None scalingMode = parse_alignment(args.scale, enumMode=True) - optimizationTime = 0 if args.sort else args.optimization_time - sortPaths = False if optimizationTime > 0 else args.sort + args.optimization_time = 0 if args.sort else args.optimization_time + args.sort = False if args.optimization_time > 0 else args.sort plotter = Plotter(xyMin=tuple((args.min_x if args.min_x is not None else args.area[0], args.min_y if args.min_y is not None else args.area[1])), xyMax=tuple((args.max_x if args.max_x is not None else args.area[2], args.max_y if args.max_y is not None else args.area[3])), @@ -924,12 +924,12 @@ def generate_pen_data(svgTree, data, args, sortPaths, optimizationTime, scalingM if args.tool_mode == 'cut': shader.unshadedThreshold = 0 - optimizationTime = 0 - sortPaths = True + args.optimization_time = 0 + args.sort = True args.direction = None elif args.tool_mode == 'draw': args.tool_offset = 0. - sortPaths = False + args.sort = False plotter.updateVariables() @@ -951,7 +951,7 @@ def generate_pen_data(svgTree, data, args, sortPaths, optimizationTime, scalingM svgTree = parse_svg_file(data) shader.setDrawingDirectionAngle(args.direction) - penData = generate_pen_data(svgTree, data, args, sortPaths, optimizationTime, scalingMode, shader) + penData = generate_pen_data(svgTree, data, args, scalingMode, shader) if args.hpgl_out and not args.simulation: From b8016e8dc2ae9c5214081a31c29b4f96e9cdbae0 Mon Sep 17 00:00:00 2001 From: IridiumIO Date: Sat, 30 Dec 2023 02:26:35 +1000 Subject: [PATCH 09/10] Fixed shading and color extraction not working in 'draw' mode. --- .gitignore | 1 + gcodeplot.py | 4 ++-- svgpath/shader.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 5cf4994..4ed0ec4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /svgpath/__pycache__/ *.pyc +/test \ No newline at end of file diff --git a/gcodeplot.py b/gcodeplot.py index a2e57eb..211410b 100755 --- a/gcodeplot.py +++ b/gcodeplot.py @@ -836,7 +836,7 @@ def parse_arguments(argparser:argparse.ArgumentParser): argparser.add_argument('--tool-mode', metavar='MODE', choices=['custom','cut','draw'], default='custom', help=argparse.SUPPRESS) #Inkscape specific boolean parameters - argparser.add_argument('--boolean-extract-color', metavar='TRUE/FALSE', dest='extract_color', help=argparse.SUPPRESS) + argparser.add_argument('--boolean-extract-color', metavar='TRUE/FALSE', type=lambda val: True if val.lower() == 'true' else False, help=argparse.SUPPRESS) argparser.add_argument('--boolean-shading-crosshatch', metavar='TRUE/FALSE', dest='shading_crosshatch', help=argparse.SUPPRESS) argparser.add_argument('--boolean-sort', metavar='TRUE/FALSE', dest='sort', help=argparse.SUPPRESS) @@ -856,7 +856,7 @@ def generate_pen_data(svgTree, data, args, scalingMode, shader:Shader): penData = {} if svgTree is not None: - penData = parseSVG(svgTree, tolerance=args.tolerance, shader=shader, strokeAll=args.stroke_all, pens=args.pens, extractColor=args.extract_color if args.extract_color is not False else None) + penData = parseSVG(svgTree, tolerance=args.tolerance, shader=shader, strokeAll=args.stroke_all, pens=args.pens, extractColor=args.extract_color if args.boolean_extract_color else None) else: penData = parseHPGL(data, dpi=args.input_dpi) diff --git a/svgpath/shader.py b/svgpath/shader.py index a49a104..8d43717 100755 --- a/svgpath/shader.py +++ b/svgpath/shader.py @@ -11,7 +11,7 @@ def __init__(self, unshadedThreshold=1., lightestSpacing=3., darkestSpacing=0.5, self.darkestSpacing = darkestSpacing self.angle = angle self.secondaryAngle = angle + 90 - self.crossHatch = False + self.crossHatch = crossHatch def isActive(self): return self.unshadedThreshold > 0.000001 From 5f19c3f61cc8941bf9d0d0eef8b1d70e310425cf Mon Sep 17 00:00:00 2001 From: IridiumIO Date: Sat, 30 Dec 2023 21:03:18 +1000 Subject: [PATCH 10/10] - Breakout argparse functions and classes into separate file - Cleanup unecessary arguments and enable input parity with old "--no" prefixes - Create enums.py so that argparser_c.py can use it too. - Tidy up __main__ section --- .gitignore | 3 +- gcodeplot.py | 292 ++++++++++++++-------------------- gcodeplotutils/argparser_c.py | 116 ++++++++++++++ gcodeplotutils/enums.py | 9 ++ 4 files changed, 247 insertions(+), 173 deletions(-) create mode 100644 gcodeplotutils/argparser_c.py create mode 100644 gcodeplotutils/enums.py diff --git a/.gitignore b/.gitignore index 4ed0ec4..f596c91 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /svgpath/__pycache__/ *.pyc -/test \ No newline at end of file +/test +gcodeplot_orig.py \ No newline at end of file diff --git a/gcodeplot.py b/gcodeplot.py index 211410b..763a76e 100755 --- a/gcodeplot.py +++ b/gcodeplot.py @@ -16,16 +16,8 @@ import svgpath.parser as parser import sys import xml.etree.ElementTree as ET - -SCALE_NONE = 0 -SCALE_DOWN_ONLY = 1 -SCALE_FIT = 2 -ALIGN_SCALE_NONE = 0 -ALIGN_BOTTOM = 1 -ALIGN_TOP = 2 -ALIGN_LEFT = ALIGN_BOTTOM -ALIGN_RIGHT = ALIGN_TOP -ALIGN_CENTER = 3 +from gcodeplotutils.enums import * +from gcodeplotutils.argparser_c import cArgumentParser, PrintDefaultsAction, CustomBooleanAction, PenAction, parse_alignment, none_or_str class Plotter(object): def __init__(self, xyMin:tuple=(7,8), xyMax:tuple=(204,178), @@ -703,84 +695,91 @@ def fixComments(plotter, data, comment = ";"): - -class PrintDefaultsAction(argparse.Action): - def __call__(self, parser, namespace, values, option_string=None): - printed = set() - formatted_strings = [ - self.format_argument(action, namespace) - for action in parser._actions - if not isinstance(action, argparse._HelpAction) - and action.help != argparse.SUPPRESS - and (formatted := f'{action.dest}: {action.default}') not in printed and not printed.add(formatted) - ] - print('\n'.join(formatted_strings)) - # parser.exit() - - def format_argument(self, action, namespace): - - if action.dest in ('scale', 'align_x', 'align_y'): - value = parse_alignment(getattr(namespace, action.dest, action.default), reverse=True) - elif action.dest == 'extract_color' and (value := getattr(namespace, action.dest, action.default) ) == None: - value = 'all' - else: - value = getattr(namespace, action.dest, action.default) - return f'{action.dest + ":":<25}{value}' +def parse_svg_file(data): + try: + svgTree = ET.fromstring(data) + return svgTree if 'svg' in svgTree.tag else None + except: + return None -class PenAction(argparse.Action): - def __call__(self, parser, namespace, values, option_string=None): - pens = {} - pen_file = Path(values) - if pen_file.is_file(): - pens = {p.pen: p for line in open(pen_file) if (line_stripped := line.strip()) and (p := Pen(line_stripped))} - else: - parser.error(f'Invalid filename provided in {self.dest} \n') - setattr(namespace, self.dest, pens) +def generate_pen_data(svgTree, data, args, shader:Shader): + penData = {} + + if svgTree is not None: + penData = parseSVG(svgTree, tolerance=args.tolerance, shader=shader, strokeAll=args.stroke_all, pens=args.pens, extractColor=args.extract_color if args.boolean_extract_color else None) + else: + penData = parseHPGL(data, dpi=args.input_dpi) + + penData = removePenBob(penData) + + if not args.allow_repeats: + penData = dedup(penData) + + if args.sort and penData: + penData = {pen: safeSorted(paths, comparison=comparePaths) for pen, paths in penData.items()} + penData = removePenBob(penData) + + if args.optimization_time > 0. and args.direction is None and penData: + penData = {pen: anneal.optimize(paths, timeout=args.optimization_time/2., quiet=args.quiet) for pen, paths in penData.items()} + penData = removePenBob(penData) + + if (args.tool_offset > 0. or args.overcut > 0.) and penData: + if parse_alignment(args.scale, enumMode=True) != SCALE_NONE: + sys.stderr.write("Scaling with tool-offset > 0 will produce unpredictable results.\n") + op = OffsetProcessor(toolOffset=args.tool_offset, overcut=args.overcut, tolerance=args.tolerance) + penData = {pen: op.processPath(paths) for pen, paths in penData.items()} + if args.direction is not None and penData: + penData = {pen: directionalize(paths, args.direction) for pen, paths in penData.items()} + penData = removePenBob(penData) + + if len(penData) > 1 and penData: + sys.stderr.write("Uses the following pens:\n") + for pen in sorted(penData): + sys.stderr.write(describePen(args.pens, pen)+"\n") + + return penData -def parse_alignment(arg, enumMode=False, reverse=False): - verbose_mapping = {'none': 'n', 'left': 'l', 'right': 'r', 'center': 'c', 'bottom': 'b', 'top': 't', 'down': 'd', 'fit': 'f'} - enum_mapping = {'n': ALIGN_SCALE_NONE, 'l': ALIGN_LEFT, 'r': ALIGN_RIGHT, 'c': ALIGN_CENTER, 'b': ALIGN_BOTTOM, 't': ALIGN_TOP, 'd': SCALE_DOWN_ONLY, 'f': SCALE_FIT} - if enumMode: return enum_mapping.get(arg, ALIGN_SCALE_NONE) - if reverse: return next((key for key, value in verbose_mapping.items() if value == arg), None) - return verbose_mapping.get(arg.lower(), 'n') if len(arg) > 1 else arg -def none_or_str(value): - return None if value=='none' else value +def generate_HPGL_or_GCODE(penData, args, plotter): + + if args.hpgl_out and not args.simulation: + res = emitHPGL(penData, pens=args.pens) + else: + align = [parse_alignment(args.align_x, enumMode=True), parse_alignment(args.align_y, enumMode=True)] + res = emitGcode(penData, align=align, scalingMode=parse_alignment(args.scale, enumMode=True), tolerance=args.tolerance, + plotter=plotter, gcodePause=args.gcode_pause, pens=args.pens, pauseAtStart=args.pause_at_start, simulation=args.simulation, quiet=args.quiet) + + if not res: + sys.stderr.write("No points.") + sys.exit(1) + + return res -def parse_arguments(argparser:argparse.ArgumentParser): +def parse_arguments(argparser:cArgumentParser): argparser.add_argument('--dump-options', help='show current settings instead of doing anything', action=PrintDefaultsAction, nargs=0) - argparser.add_argument('-r', '--allow-repeats', dest='deduplicate', help='do not deduplicate paths',action='store_false') + argparser.add_argument('-r', '--allow-repeats', help='do not deduplicate paths', action=CustomBooleanAction, default=False) argparser.add_argument('-f', '--scale', metavar='MODE', choices=['n', 'f', 'd'], default='n', type=parse_alignment, help='scaling option: none(n), fit(f), down-only(d) [default none; other options do not work with tool-offset]') argparser.add_argument('-D', '--input-dpi', metavar='x[,y]', default=(1016., 1016.), help='hpgl dpi', type=lambda s: tuple(map(float, s.split(','))) if ',' in s else (float(s), float(s))) # returns (x,x) if only one number provided, otherwise returns (x,y) argparser.add_argument('-t', '--tolerance', metavar='x', default=0.05, type=float, help='ignore (some) deviations of x millimeters or less [default: %(default)s]') - - group_send = argparser.add_mutually_exclusive_group() - group_send.add_argument('-s', '--send', type=int, metavar='PORT', default=None, help='Send gcode to serial port instead of stdout') - group_send.add_argument('--no-send', dest='send', action='store_const', const=None, help='Set sendport to None') + argparser.add_argument('-s', '--send', metavar='PORT', default=None, action=CustomBooleanAction, help='Send gcode to serial port instead of stdout') argparser.add_argument('-S', '--send-speed', metavar='BAUD', default=115200, help='set baud rate for sending') - argparser.add_argument('--send-and-save', metavar='PORT', default=None, help=argparse.SUPPRESS) - argparser.add_argument('-x', '--align-x', metavar='MODE', choices=['n', 'l', 'r', 'c'], default='l', type=parse_alignment, help='horizontal alignment: none(n), left(l), right(r) or center(c)') argparser.add_argument('-y', '--align-y', metavar='MODE', choices=['n', 'b', 't', 'c'], default='t', type=parse_alignment, help='horizontal alignment: none(n), bottom(b), top(t) or center(c)') - - # PLOTTER INIT - argparser.add_argument('-a', '--area', metavar='x1,y1,x2,y2', default=[7, 8, 204, 178], type=lambda s: list(map(float, s.split(','))), help='gcode print area in millimeters') argparser.add_argument('--min-x', type=float, default=None, help=argparse.SUPPRESS) argparser.add_argument('--min-y', type=float, default=None, help=argparse.SUPPRESS) argparser.add_argument('--max-x', type=float, default=None, help=argparse.SUPPRESS) argparser.add_argument('--max-y', type=float, default=None, help=argparse.SUPPRESS) - argparser.add_argument('-Z', '--lift-delta-z', metavar='Z', default=2.5, type=float, help='amount to lift for pen-up (millimeters)') argparser.add_argument('-z', '--work-z', metavar='Z', default=14.5, type=float, help='z-position for drawing (millimeters)') argparser.add_argument('-V', '--pen-up-speed', metavar='S', default=40, type=float, help='speed for moving with pen up (millimeters/second)') @@ -793,114 +792,72 @@ def parse_arguments(argparser:argparse.ArgumentParser): argparser.add_argument('--init-code', metavar='GCODE', type=none_or_str, default="G00 S1; endstops|G00 E0; no extrusion|G01 S1; endstops|G01 E0; no extrusion|G21; millimeters|G91 G0 F%.1f{{zspeed*60}} Z%.3f{{safe}}; pen park !!Zsafe|G90; absolute|G28 X; home|G28 Y; home|G28 Z; home", help='gcode init commands (separate lines with |)') argparser.add_argument('--end-code', metavar='GCODE', type=none_or_str, default=None, help='Gcode to run at end of task') - argparser.add_argument('-H', '--hpgl-out', action=argparse.BooleanOptionalAction, default=False, help='output is HPGL, not gcode; most options are ignored.') + argparser.add_argument('-P', '--pens', metavar='PENFILE', default={1:Pen('1 (0.,0.) black default')}, action=PenAction, PenClass=Pen, help='read output pens from penfile') argparser.add_argument('-T', '--shading-threshold', metavar='N', default=1.0, type=float, help='darkest grayscale to leave unshaded (decimal, 0. to 1.; set to 0 to turn off SVG shading) [default 1.0]') - - argparser.add_argument('-m', '--shading-lightest', metavar='X', default=3.0, type=float, help='shading spacing for lightest colors (millimeters) [default 3.0]') argparser.add_argument('-M', '--shading-darkest', metavar='X', default=0.5, type=float, help='shading spacing for darkest color (millimeters) [default 0.5]') argparser.add_argument('-A', '--shading-angle', metavar='X', default=45, type=float, help='shading angle (degrees) [default 45]') - argparser.add_argument('-X', '--shading-crosshatch', action=argparse.BooleanOptionalAction, default=False, help='cross hatch shading') - - argparser.add_argument('-L', '--stroke-all', action=argparse.BooleanOptionalAction, default=False, help='stroke even regions specified by SVG to have no stroke') argparser.add_argument('-O', '--shading-avoid-outline', action=argparse.BooleanOptionalAction, default=False, help='avoid going over outline twice when shading') #?Unused + argparser.add_argument('-R', '--extract-color', metavar='C', default=None, type=parser.rgbFromColor, help='extract color (specified in SVG format , e.g., rgb(1,0,0) or #ff0000 or red)') + argparser.add_argument('-L', '--stroke-all', action=argparse.BooleanOptionalAction, default=False, help='stroke even regions specified by SVG to have no stroke') argparser.add_argument('-e', '--direction', metavar='ANGLE', default=None, type=lambda value: None if value.lower() == 'none' else float(value), help='for slanted pens: prefer to draw in given direction (degrees; 0=positive x, 90=positive y, none=no preferred direction) [default none]') argparser.add_argument('-o', '--optimization-time', metavar='T', default=60, type=int, help='max time to spend optimizing (seconds; set to 0 to turn off optimization) [default 60]') argparser.add_argument('-d', '--sort', action=argparse.BooleanOptionalAction, default=False, help='sort paths from inside to outside for cutting [default off]') - - - - # parser.add_argument('-c', '--config-file', metavar='$FILENAME', help='read arguments, one per line, from filename. Prepend the filename with "$" e.g. $"args.txt"') + argparser.add_argument('-w', '--gcode-pause', metavar='CMD', default='@pause', help='gcode pause command [default: @pause]') - argparser.add_argument('-P', '--pens', metavar='PENFILE', default={1:Pen('1 (0.,0.) black default')}, action=PenAction, help='read output pens from penfile') - argparser.add_argument('-U', '--pause-at-start', action=argparse.BooleanOptionalAction, default=False, help='pause at start (can be included without any input file to manually move stuff)') - argparser.add_argument('-R', '--extract-color', metavar='C', default=None, type=parser.rgbFromColor, help='extract color (specified in SVG format , e.g., rgb(1,0,0) or #ff0000 or red)') - + argparser.add_argument('--tool-mode', metavar='MODE', choices=['custom','cut','draw'], default='custom', help=argparse.SUPPRESS) argparser.add_argument('--tool-offset', metavar='X', default=0.0, type=float, help='cutting tool offset (millimeters) [default 0.0]') argparser.add_argument('--overcut', metavar='X', default=0.0, type=float, help='overcut (millimeters) [default 0.0]') - argparser.add_argument('--moonraker', metavar='URL', default=None, help='moonraker url') argparser.add_argument('--moonraker-filename', metavar='FILENAME', default='toolpath.gcode', help='name of uploaded file') argparser.add_argument('--moonraker-autoprint', metavar='TRUE/FALSE', default=False, help='whether to automatically begin the print job after upload') argparser.add_argument('--simulation', metavar='TRUE/FALSE', action=argparse.BooleanOptionalAction, default=False, help=argparse.SUPPRESS) - argparser.add_argument('--tab', dest='quiet', default=False, type=bool, help=argparse.SUPPRESS) - argparser.add_argument('--tool-mode', metavar='MODE', choices=['custom','cut','draw'], default='custom', help=argparse.SUPPRESS) #Inkscape specific boolean parameters argparser.add_argument('--boolean-extract-color', metavar='TRUE/FALSE', type=lambda val: True if val.lower() == 'true' else False, help=argparse.SUPPRESS) argparser.add_argument('--boolean-shading-crosshatch', metavar='TRUE/FALSE', dest='shading_crosshatch', help=argparse.SUPPRESS) argparser.add_argument('--boolean-sort', metavar='TRUE/FALSE', dest='sort', help=argparse.SUPPRESS) + argparser.add_argument('--send-and-save', metavar='PORT', default=False, help=argparse.SUPPRESS) #Could probably roll this into "send" and check if we're in Inkscape at the end of __main__ by using tab/quiet instead + argparser.add_argument('--tab', dest='quiet', default=False, type=bool, help=argparse.SUPPRESS) - return argparser.parse_known_args() - - -def parse_svg_file(data): - try: - svgTree = ET.fromstring(data) - return svgTree if 'svg' in svgTree.tag else None - except: - return None - - - -def generate_pen_data(svgTree, data, args, scalingMode, shader:Shader): - penData = {} + args, positional = argparser.parse_known_args() - if svgTree is not None: - penData = parseSVG(svgTree, tolerance=args.tolerance, shader=shader, strokeAll=args.stroke_all, pens=args.pens, extractColor=args.extract_color if args.boolean_extract_color else None) - else: - penData = parseHPGL(data, dpi=args.input_dpi) - - penData = removePenBob(penData) + # I probably shouldn't have done this. If a port is provided on SEND, use it, + # otherwise check if it was provided on send_and_save, otherwise set SEND to None + # If a port is provided on send_and_save, then it sets SEND to the port, then sets itself to True. + args.send = args.send if str(args.send).isdigit() else args.send_and_save if str(args.send_and_save).isdigit() else None + args.send_and_save = True if str(args.send_and_save).isdigit() else False - if args.deduplicate: - penData = dedup(penData) - - if args.sort and penData: - penData = {pen: safeSorted(paths, comparison=comparePaths) for pen, paths in penData.items()} - penData = removePenBob(penData) - - if args.optimization_time > 0. and args.direction is None and penData: - penData = {pen: anneal.optimize(paths, timeout=args.optimization_time/2., quiet=args.quiet) for pen, paths in penData.items()} - penData = removePenBob(penData) + args.optimization_time = 0 if args.sort else args.optimization_time + args.sort = False if args.optimization_time > 0 else args.sort - if (args.tool_offset > 0. or args.overcut > 0.) and penData: - if scalingMode != SCALE_NONE: - sys.stderr.write("Scaling with tool-offset > 0 will produce unpredictable results.\n") - op = OffsetProcessor(toolOffset=args.tool_offset, overcut=args.overcut, tolerance=args.tolerance) - penData = {pen: op.processPath(paths) for pen, paths in penData.items()} + if args.tool_mode == 'cut': + args.optimization_time = 0 + args.sort = True + args.direction = None + elif args.tool_mode == 'draw': + args.tool_offset = 0. + args.sort = False + + + return args, positional + - if args.direction is not None and penData: - penData = {pen: directionalize(paths, args.direction) for pen, paths in penData.items()} - penData = removePenBob(penData) - - if len(penData) > 1 and penData: - sys.stderr.write("Uses the following pens:\n") - for pen in sorted(penData): - sys.stderr.write(describePen(args.pens, pen)+"\n") - - return penData if __name__ == '__main__': - argparser = argparse.ArgumentParser(prog='Gcode Plot', description='test', fromfile_prefix_chars='$', epilog="You can load options from a text file by passing the filename prefixed with a '$' e.g. [python gcodeplot.py $'args.txt']", formatter_class=argparse.ArgumentDefaultsHelpFormatter) + argparser = cArgumentParser(prog='Gcode Plot', description='test', fromfile_prefix_chars='$', epilog="You can load options from a text file by passing the filename prefixed with a '$' e.g. [python gcodeplot.py $'args.txt']", formatter_class=argparse.ArgumentDefaultsHelpFormatter) args, positional = parse_arguments(argparser) - sendPort = args.send if args.send is not None else args.send_and_save - sendAndSave = args.send_and_save is not None - scalingMode = parse_alignment(args.scale, enumMode=True) - args.optimization_time = 0 if args.sort else args.optimization_time - args.sort = False if args.optimization_time > 0 else args.sort - plotter = Plotter(xyMin=tuple((args.min_x if args.min_x is not None else args.area[0], args.min_y if args.min_y is not None else args.area[1])), xyMax=tuple((args.max_x if args.max_x is not None else args.area[2], args.max_y if args.max_y is not None else args.area[3])), drawSpeed=args.pen_down_speed, @@ -916,73 +873,52 @@ def generate_pen_data(svgTree, data, args, scalingMode, shader:Shader): endCode=args.end_code, comment=args.comment_delimiters) - shader = Shader(unshadedThreshold=args.shading_threshold, + shader = Shader(unshadedThreshold= 0 if args.tool_mode == 'cut' else args.shading_threshold, lightestSpacing=args.shading_lightest, darkestSpacing=args.shading_darkest, angle=args.shading_angle, crossHatch=args.shading_crosshatch) - if args.tool_mode == 'cut': - shader.unshadedThreshold = 0 - args.optimization_time = 0 - args.sort = True - args.direction = None - elif args.tool_mode == 'draw': - args.tool_offset = 0. - args.sort = False - + plotter.updateVariables() - # If no file is provided on the input, assume the intent is to run the init g-code over serial. + # If no input SVG is provided on stdin, assume the intent is to just run the init g-code over serial. if len(positional) == 0: if not args.pause_at_start: argparser.print_help() - if sendPort is None: + if args.send is None: sys.stderr.write("Need to specify --send=port to be able to pause without any file.") sys.exit(1) - sendgcode.sendGcode(port=sendPort, speed=args.send_speed, commands=gcodeHeader(plotter) + [args.gcode_pause], gcodePause=args.gcode_pause, variables=plotter.variables, formulas=plotter.formulas) + sendgcode.sendGcode(port=args.send, speed=args.send_speed, commands=gcodeHeader(plotter) + [args.gcode_pause], gcodePause=args.gcode_pause, variables=plotter.variables, formulas=plotter.formulas) sys.exit(0) - # Otherwise, open the input file + # Otherwise, open the input file... with open(positional[0], 'r') as f: data = f.read() + # Gather the SVG data and generate pen data, then generate the output HPGL/GCode... + # Note the program will exit if HPGL/GCode cannot be created svgTree = parse_svg_file(data) shader.setDrawingDirectionAngle(args.direction) - - penData = generate_pen_data(svgTree, data, args, scalingMode, shader) - - - if args.hpgl_out and not args.simulation: - g = emitHPGL(penData, pens=args.pens) - else: - align = [parse_alignment(args.align_x, enumMode=True), parse_alignment(args.align_y, enumMode=True)] - g = emitGcode(penData, align=align, scalingMode=scalingMode, tolerance=args.tolerance, - plotter=plotter, gcodePause=args.gcode_pause, pens=args.pens, pauseAtStart=args.pause_at_start, simulation=args.simulation, quiet=args.quiet) - - if not g: - sys.stderr.write("No points.") - sys.exit(1) + penData = generate_pen_data(svgTree, data, args, shader) + g = generate_HPGL_or_GCODE(penData, args, plotter) + filtered = '\n'.join(fixComments(plotter, g, comment=plotter.comment)) + '\n' + # "Dump" here refers to whether the output code will be sent to stdout or not. dump = True - - if sendPort is not None and not args.simulation: - dump = sendAndSave + + # If we have a port to send to, and we're not in simulation mode, send either the GCode or HPGL over serial. + # If send_and_save is false, then it means we don't want to save the data (from Inkscape; saving is done by returning the data via stdout) + if args.send is not None and not args.simulation: + dump = args.send_and_save if args.hpgl_out: - sendgcode.sendHPGL(port=sendPort, speed=args.send_speed, commands=g) + sendgcode.sendHPGL(port=args.send, speed=args.send_speed, commands=g) else: - sendgcode.sendGcode(port=sendPort, speed=args.send_speed, commands=g, gcodePause=args.gcode_pause, plotter=plotter, variables=plotter.variables, formulas=plotter.formulas) - - if not dump: - sys.exit(0) - - if args.hpgl_out: - sys.stdout.write(g) - sys.exit(0) + sendgcode.sendGcode(port=args.send, speed=args.send_speed, commands=g, gcodePause=args.gcode_pause, plotter=plotter, variables=plotter.variables, formulas=plotter.formulas) - filtered = '\n'.join(fixComments(plotter, g, comment=plotter.comment)) + '\n' + # If we want to upload to Klipper via Moonraker if args.moonraker != "" and args.moonraker is not None: moonraker = args.moonraker.strip("/") + "/server/files/upload" virtual_file = io.BytesIO(filtered.encode('utf-8')) @@ -991,5 +927,17 @@ def generate_pen_data(svgTree, data, args, scalingMode, shader:Shader): if response.status_code != 201: sys.stderr.write(f"Error uploading file. Status code: {response.status_code}") + # If we don't want to return the file over stdout, we exit here... + if not dump: + sys.exit(0) + + # Otherwise, save the file to stdout if it's HPGL... + if args.hpgl_out: + sys.stdout.write(g) + sys.exit(0) + + # Or, if it's GCode, check if we want to send it to Moonraker in addition to saving it to stdout. + + print('\n'.join(fixComments(plotter, g, comment=plotter.comment))) \ No newline at end of file diff --git a/gcodeplotutils/argparser_c.py b/gcodeplotutils/argparser_c.py new file mode 100644 index 0000000..8061240 --- /dev/null +++ b/gcodeplotutils/argparser_c.py @@ -0,0 +1,116 @@ +#Custom argparse classes for additional function. Allows stripping '#' comments +#from files passed in, and also allows 'arg=value' format instead of 'arg value' +# +#Also allows negatable arguments; --arg=124 --arg=true --arg=false --no-arg + + +import argparse +from pathlib import Path +from .enums import * + +class cArgumentParser(argparse.ArgumentParser): + def convert_arg_line_to_args(self, arg_line): + + if arg_line.startswith("#"): + return [] + elif "=" in arg_line: + # Treat lines with "=" as if they were passed as command-line arguments + key, value = arg_line.split("=", 1) + return ['--' + key.strip(), value.strip()] + elif arg_line.startswith('no-'): + return ['--' + arg_line.strip()] + else: + return arg_line.split() + + +class CustomBooleanAction(argparse.Action): + def __init__(self,option_strings, + dest, + default=None, + required=False, + help=None, + metavar=None): + + _option_strings = [] + for option_string in option_strings: + _option_strings.append(option_string) + + if option_string.startswith('--'): + option_string = '--no-' + option_string[2:] + _option_strings.append(option_string) + + if help is not None and default is not None: + help += f" (default: {default})" + + super().__init__( + option_strings=_option_strings, + dest=dest, + nargs='?', + const=None, + default=default, + required=required, + help=help, + metavar=metavar) + + def __call__(self, parser, namespace, values, option_string=None): + if option_string and option_string.startswith('--no-'): + # Handle the case where the option is negated, e.g., --no-shading-crosshatch + setattr(namespace, self.dest, False) + elif values is None or values.lower() == 'true': + # Handle the cases where the option is provided without a value or explicitly set to 'true' + setattr(namespace, self.dest, True) + elif values.lower() == 'false': + # Handle the case where the option is explicitly set to 'false' + setattr(namespace, self.dest, False) + else: + # assign the target value to the provided input. e.g, --send=21523 + setattr(namespace, self.dest, values) + +class PrintDefaultsAction(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + printed = set() + formatted_strings = [ + self.format_argument(action, namespace) + for action in parser._actions + if not isinstance(action, argparse._HelpAction) + and action.help != argparse.SUPPRESS + and (formatted := f'{action.dest}: {action.default}') not in printed and not printed.add(formatted) + ] + print('\n'.join(formatted_strings)) + # parser.exit() + + def format_argument(self, action, namespace): + + if action.dest in ('scale', 'align_x', 'align_y'): + value = parse_alignment(getattr(namespace, action.dest, action.default), reverse=True) + elif action.dest == 'extract_color' and (value := getattr(namespace, action.dest, action.default) ) == None: + value = 'all' + else: + value = getattr(namespace, action.dest, action.default) + return f'{action.dest + ":":<25}{value}' + + +def parse_alignment(arg, enumMode=False, reverse=False): + verbose_mapping = {'none': 'n', 'left': 'l', 'right': 'r', 'center': 'c', 'bottom': 'b', 'top': 't', 'down': 'd', 'fit': 'f'} + enum_mapping = {'n': ALIGN_SCALE_NONE, 'l': ALIGN_LEFT, 'r': ALIGN_RIGHT, 'c': ALIGN_CENTER, 'b': ALIGN_BOTTOM, 't': ALIGN_TOP, 'd': SCALE_DOWN_ONLY, 'f': SCALE_FIT} + if enumMode: return enum_mapping.get(arg, ALIGN_SCALE_NONE) + if reverse: return next((key for key, value in verbose_mapping.items() if value == arg), None) + return verbose_mapping.get(arg.lower(), 'n') if len(arg) > 1 else arg + +def none_or_str(value): + return None if value=='none' else value + + + +class PenAction(argparse.Action): + def __init__(self, PenClass, *args, **kwargs): + super().__init__(*args, **kwargs) + self.Pen = PenClass + def __call__(self, parser, namespace, values, option_string=None): + pens = {} + pen_file = Path(values) + if pen_file.is_file(): + pens = {p.pen: p for line in open(pen_file) if (line_stripped := line.strip()) and (p := self.Pen(line_stripped))} + else: + parser.error(f'Invalid filename provided in {self.dest} \n') + setattr(namespace, self.dest, pens) \ No newline at end of file diff --git a/gcodeplotutils/enums.py b/gcodeplotutils/enums.py new file mode 100644 index 0000000..44d03b4 --- /dev/null +++ b/gcodeplotutils/enums.py @@ -0,0 +1,9 @@ +SCALE_NONE = 0 +SCALE_DOWN_ONLY = 1 +SCALE_FIT = 2 +ALIGN_SCALE_NONE = 0 +ALIGN_BOTTOM = 1 +ALIGN_TOP = 2 +ALIGN_LEFT = ALIGN_BOTTOM +ALIGN_RIGHT = ALIGN_TOP +ALIGN_CENTER = 3 \ No newline at end of file