diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d2015f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# python cache / compilations +*__pycache__ +*.pyc + +# editor backups +*.swp diff --git a/README.md b/README.md index 4a8a505..cc50c33 100644 --- a/README.md +++ b/README.md @@ -16,16 +16,19 @@ Just brainstorming here... import math import euclid - gfile_in = pygcode.Parser('part.gcode') - gfile_out = pygcode.Writer('part2.gcode') + gfile_in = pygcode.parse('part1.gcode') # + gfile_out = pygcode.GCodeFile('part2.gcode') total_travel = 0 total_time = 0 - for (state, block) in gfile_in.iterstate(): - # where: - # state = CNC's state before the block is executed - # block = the gcode to be executed next + machine = pygcode.Machine() + + for line in gfile_in.iterlines(): + + block = line.block + if block is None: + continue # validation if isinstance(block, pygcode.GCodeArc): @@ -36,13 +39,13 @@ Just brainstorming here... block.set_precision(0.0005, method=pygcode.GCodeArc.EFFECT_RADIUS) # random metrics - travel_vector = block.position - state.position # euclid.Vector3 instance + travel_vector = block.position - machine.state.position # euclid.Vector3 instance distance = travel_vector.magnitude() - travel = block.travel_distance(position=state.position) # eg: distance != travel for G02 & G03 + travel = block.travel_distance(position=machine.state.position) # eg: distance != travel for G02 & G03 total_travel += travel - #total_time += block.time(feed_rate=state.feed_rate) # doesn't consider the feedrate being changed in this block - total_time += block.time(state=state) + #total_time += block.time(feed_rate=machine.state.feed_rate) # doesn't consider the feedrate being changed in this block + total_time += block.time(state=machine.state) # rotate : entire file 90deg CCW block.rotate(euclid.Quaternion.new_rotate_axis( @@ -51,6 +54,8 @@ Just brainstorming here... # translate : entire file x += 1, y += 2 mm (after rotation) block.translate(euclid.Vector3(1, 2, 0), unit=pygcode.UNIT_MM) + + # TODO: then do something like write it to another file gfile_out.write(block) @@ -59,7 +64,7 @@ Just brainstorming here... ## Supported G-Codes -GCode support is planned to follow that of [GRBL](https://github.com/gnea/grbl). +GCode support is planned to follow that of [GRBL](https://github.com/gnea/grbl) which follows [LinuxCNC](http://linuxcnc.org) (list of gcodes documented [here](http://linuxcnc.org/docs/html/gcode.html)). But anything pre v1.0 will be a sub-set, focusing on the issues I'm having... I'm selfish that way. diff --git a/pygcode/__init__.py b/pygcode/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pygcode/block.py b/pygcode/block.py new file mode 100644 index 0000000..6018fd3 --- /dev/null +++ b/pygcode/block.py @@ -0,0 +1,36 @@ +import re +from .words import iter_words, WORD_MAP + +class Block(object): + """GCode block (effectively any gcode file line that defines any )""" + + def __init__(self, text): + """ + Block Constructor + :param A-Z: gcode parameter values + :param comment: comment text + """ + + self._raw_text = text # unaltered block content (before alteration) + + # clean up block string + text = re.sub(r'(^\s+|\s+$)', '', text) # remove whitespace padding + text = re.sub(r'\s+', ' ', text) # remove duplicate whitespace with ' ' + + self.text = text + + self.words = list(iter_words(self.text)) + + def __getattr__(self, k): + if k in WORD_MAP: + for w in self.words: + if w.letter == k: + return w + # if word is not in this block: + return None + + else: + raise AttributeError("'{cls}' object has no attribute '{key}'".format( + cls=self.__class__.__name__, + key=k + )) diff --git a/pygcode/comment.py b/pygcode/comment.py new file mode 100644 index 0000000..dfae3ed --- /dev/null +++ b/pygcode/comment.py @@ -0,0 +1,61 @@ +import re + + +class CommentBase(object): + ORDER = 0 + MULTICOMMENT_JOINER = ". " # joiner if multiple comments are found on the same line + def __init__(self, text): + self.text = text + + +class CommentSemicolon(CommentBase): + "Comments of the format: 'G00 X1 Y2 ; something profound'" + ORDER = 1 + AUTO_REGEX = re.compile(r'\s*;\s*(?P.*)$') + + def __str__(self): + return "; {text}".format(text=self.text) + + +class CommentBrackets(CommentBase): + "Comments of the format: 'G00 X1 Y2 (something profound)" + ORDER = 2 + AUTO_REGEX = re.compile(r'\((?P[^\)]*)\)') + + def __str__(self): + return "({text})".format(text=self.text) + + +Comment = CommentBrackets # default comment type + + +def split_line(line_text): + """ + Split functional block content from comments + :param line_text: line from gcode file + :return: tuple of (str(), CommentBase()) + """ + comments_class = None + + # Auto-detect comment type if I can + comments = [] + block_str = line_text.rstrip("\n") # to remove potential return carriage from comment body + + for cls in sorted(CommentBase.__subclasses__(), key=lambda c: c.ORDER): + matches = list(cls.AUTO_REGEX.finditer(block_str)) + if matches: + for match in reversed(matches): + # Build list of comment text + comments.insert(0, match.group('text')) # prepend + # Remove comments from given block_str + block_str = block_str[:match.start()] + block_str[match.end():] + comments_class = cls + break + + # Create comment instance if content was found + comment_obj = None + if comments_class: + comment_text = comments_class.MULTICOMMENT_JOINER.join(comments) + comment_obj = comments_class(comment_text) + + return (block_str, comment_obj) diff --git a/pygcode/exceptions.py b/pygcode/exceptions.py new file mode 100644 index 0000000..0249ff7 --- /dev/null +++ b/pygcode/exceptions.py @@ -0,0 +1,7 @@ + +# ===================== Parsing Exceptions ===================== +class GCodeBlockFormatError(Exception): + """Raised when errors encountered while parsing block text""" + pass + +# ===================== Parsing Exceptions ===================== diff --git a/pygcode/file.py b/pygcode/file.py new file mode 100644 index 0000000..db99d99 --- /dev/null +++ b/pygcode/file.py @@ -0,0 +1,23 @@ +from .line import Line + + +class GCodeFile(object): + def __init__(self, filename=None): + self.filename = filename + + # Initialize + self.lines = [] + + def append(self, line): + assert isinstance(line, Line), "invalid line type" + self.lines.append(line) + + +def parse(filename): + # FIXME: should be an iterator, and also not terrible + file = GCodeFile() + with open(filename, 'r') as fh: + for line in fh.readlines(): + line_obj = Line(line) + file.append(line_obj) + return file diff --git a/pygcode/line.py b/pygcode/line.py new file mode 100644 index 0000000..f12ebd6 --- /dev/null +++ b/pygcode/line.py @@ -0,0 +1,28 @@ +from .comment import split_line +from .block import Block + +class Line(object): + def __init__(self, text=None): + self._text = text + + # Initialize + self.block = None + self.comment = None + + # Split line into block text, and comments + if text is not None: + (block_str, comment) = split_line(text) + if block_str: + self.block = Block(block_str) + if comment: + self.comment = comment + + @property + def text(self): + if self._text is None: + return self.build_line_text() + return self._text + + + def build_line_text(self): + return ' '.join([str(x) for x in [self.block, self.comment] if x]) + '\n' diff --git a/pygcode/machine.py b/pygcode/machine.py new file mode 100644 index 0000000..39e49bc --- /dev/null +++ b/pygcode/machine.py @@ -0,0 +1,36 @@ + + +class MachineState(object): + def __init__(self, axes=('x', 'y', 'z')): + self.axes = axes + + # initialize + self.position = {} + for axis in self.axes: + self.position[axis] = 0 + + self.time = 0 + +class Machine(object): + """""" + def __init__(self, **kwargs): + self.axes = kwargs.get('axes', ('x', 'y', 'z')) + self.max_rate = kwargs.get('max_rate', { + 'x': 500, # mm/min + 'y': 500, # mm/min + 'z': 500, # mm/min + }) + self.max_travel = kwargs.get('max_travel', { + 'x': 200, # mm + 'y': 200, # mm + 'z': 200, # mm + }) + self.max_spindle_speed = kwargs.get('max_spindle_speed', 1000) # rpm + self.min_spindle_speed = kwargs.get('max_spindle_speed', 0) # rpm + + # initialize + self.state = MachineState(self.axes) + + def process_line(self, line): + """Change machine's state based on the given gcode line""" + pass # TODO diff --git a/pygcode/words.py b/pygcode/words.py new file mode 100644 index 0000000..cce53eb --- /dev/null +++ b/pygcode/words.py @@ -0,0 +1,321 @@ +import re +import itertools +import six + +from .exceptions import GCodeBlockFormatError + +FLOAT_REGEX = re.compile(r'^-?\d+(\.\d+)?') +INT_REGEX = re.compile(r'^-?\d+') +POSITIVEINT_REGEX = re.compile(r'^\d+') + +WORD_MAP = { + # Descriptions copied from wikipedia: + # https://en.wikipedia.org/wiki/G-code#Letter_addresses + + # Rotational Axes + 'A': { + 'class': float, + 'value_regex': FLOAT_REGEX, + 'description': "Absolute or incremental position of A axis (rotational axis around X axis)", + }, + 'B': { + 'class': float, + 'value_regex': FLOAT_REGEX, + 'description': "Absolute or incremental position of B axis (rotational axis around Y axis)", + }, + 'C': { + 'class': float, + 'value_regex': FLOAT_REGEX, + 'description': "Absolute or incremental position of C axis (rotational axis around Z axis)", + }, + 'D': { + 'class': float, + 'value_regex': FLOAT_REGEX, + 'description': "Defines diameter or radial offset used for cutter compensation. D is used for depth of cut on lathes. It is used for aperture selection and commands on photoplotters.", + }, + # Feed Rates + 'E': { + 'class': float, + 'value_regex': FLOAT_REGEX, + 'description': "Precision feedrate for threading on lathes", + }, + 'F': { + 'class': float, + 'value_regex': FLOAT_REGEX, + 'description': "Feedrate", + }, + # G-Codes + 'G': { + 'class': str, + 'value_regex': re.compile(r'^\d+(\.\d+)?'), + 'description': "Address for preparatory commands", + }, + # Tool Offsets + 'H': { + 'class': float, + 'value_regex': FLOAT_REGEX, + 'description': "Defines tool length offset; Incremental axis corresponding to C axis (e.g., on a turn-mill)", + }, + # Arc radius center coords + 'I': { + 'class': float, + 'value_regex': FLOAT_REGEX, + 'description': "Defines arc center in X axis for G02 or G03 arc commands. Also used as a parameter within some fixed cycles.", + }, + 'J': { + 'class': float, + 'value_regex': FLOAT_REGEX, + 'description': "Defines arc center in Y axis for G02 or G03 arc commands. Also used as a parameter within some fixed cycles.", + }, + 'K': { + 'class': float, + 'value_regex': FLOAT_REGEX, + 'description': "Defines arc center in Z axis for G02 or G03 arc commands. Also used as a parameter within some fixed cycles, equal to L address.", + }, + # Loop Count + 'L': { + 'class': int, + 'value_regex': POSITIVEINT_REGEX, + 'description': "Fixed cycle loop count; Specification of what register to edit using G10", + }, + # Miscellaneous Function + 'M': { + 'class': str, + 'value_regex': re.compile(r'^\d+(\.\d+)?'), + 'description': "Miscellaneous function", + }, + # Line Number + 'N': { + 'class': int, + 'value_regex': POSITIVEINT_REGEX, + 'description': "Line (block) number in program; System parameter number to change using G10", + }, + # Program Name + 'O': { + 'class': str, + 'value_regex': re.compile(r'^.+$'), # all the way to the end + 'description': "Program name", + }, + # Parameter (arbitrary parameter) + 'P': { + 'class': float, # parameter is often an integer, but can be a float + 'value_regex': FLOAT_REGEX, + 'description': "Serves as parameter address for various G and M codes", + }, + # Peck increment + 'Q': { + 'class': float, + 'value_regex': FLOAT_REGEX, + 'description': "Depth to increase on each peck; Peck increment in canned cycles", + }, + # Arc Radius + 'R': { + 'class': float, + 'value_regex': FLOAT_REGEX, + 'description': "Defines size of arc radius, or defines retract height in milling canned cycles", + }, + # Spindle speed + 'S': { + 'class': float, + 'value_regex': FLOAT_REGEX, + 'description': "Defines speed, either spindle speed or surface speed depending on mode", + }, + # Tool Selecton + 'T': { + 'class': str, + 'value_regex': POSITIVEINT_REGEX, # tool string may have leading '0's, but is effectively an index (integer) + 'description': "Tool selection", + }, + # Incremental axes + 'U': { + 'class': float, + 'value_regex': FLOAT_REGEX, + 'description': "Incremental axis corresponding to X axis (typically only lathe group A controls) Also defines dwell time on some machines (instead of 'P' or 'X').", + }, + 'V': { + 'class': float, + 'value_regex': FLOAT_REGEX, + 'description': "Incremental axis corresponding to Y axis", + }, + 'W': { + 'class': float, + 'value_regex': FLOAT_REGEX, + 'description': "Incremental axis corresponding to Z axis (typically only lathe group A controls)", + }, + # Linear Axes + 'X': { + 'class': float, + 'value_regex': FLOAT_REGEX, + 'description': "Absolute or incremental position of X axis.", + }, + 'Y': { + 'class': float, + 'value_regex': FLOAT_REGEX, + 'description': "Absolute or incremental position of Y axis.", + }, + 'Z': { + 'class': float, + 'value_regex': FLOAT_REGEX, + 'description': "Absolute or incremental position of Z axis.", + }, +} + + +ORDER_LINUXCNC_LETTER_MAP = { + 'O': 10, + 'F': 40, + 'S': 50, + 'T': 60, +} + +_v_csv = lambda v, ks: [(k, v) for k in ks.split(',')] + +ORDER_LINUXCNC_LETTERVALUE_MAP = dict(itertools.chain.from_iterable([ + _v_csv(30, 'G93,G94'), + _v_csv(70, 'M62,M63,M64,M65,M66,M67,M68'), + _v_csv(80, 'M6,M61'), + _v_csv(90, 'M3,M4,M5'), + _v_csv(100, 'M71,M73,M72,M71'), + _v_csv(110, 'M7,M8,M9'), + _v_csv(120, 'M48,M49,M50,M51,M52,M53'), + [('G4', 140)], + _v_csv(150, 'G17,G18,G19'), + _v_csv(160, 'G20,G21'), + _v_csv(170, 'G40,G41,G42'), + _v_csv(180, 'G43,G49'), + _v_csv(190, 'G54,G55,G56,G57,G58,G59,G59.1,G59.2,G59.3'), + _v_csv(200, 'G61,G61.1,G64'), + _v_csv(210, 'G90,G91'), + _v_csv(220, 'G98,G99'), + _v_csv(230, 'G28,G30,G10,G92,G92.1,G92.2,G94'), + _v_csv(240, 'G0,G1,G2,G3,G33,G73,G76,G80,G81,G82,G83,G84,G85,G86,G87,G88,G89'), + _v_csv(250, 'M0,M1,M2,M30,M60'), +])) + +def _word_order_linuxcnc(word): + ''' + Order taken http://linuxcnc.org/docs/html/gcode/overview.html#_g_code_order_of_execution + (as of 2017-07-03) + 010: O-word commands (optionally followed by a comment but no other words allowed on the same line) + N/A: Comment (including message) + 030: Set feed rate mode (G93, G94). + 040: Set feed rate (F). + 050: Set spindle speed (S). + 060: Select tool (T). + 070: HAL pin I/O (M62-M68). + 080: Change tool (M6) and Set Tool Number (M61). + 090: Spindle on or off (M3, M4, M5). + 100: Save State (M70, M73), Restore State (M72), Invalidate State (M71). + 110: Coolant on or off (M7, M8, M9). + 120: Enable or disable overrides (M48, M49,M50,M51,M52,M53). + 130: User-defined Commands (M100-M199). + 140: Dwell (G4). + 150: Set active plane (G17, G18, G19). + 160: Set length units (G20, G21). + 170: Cutter radius compensation on or off (G40, G41, G42) + 180: Cutter length compensation on or off (G43, G49) + 190: Coordinate system selection (G54, G55, G56, G57, G58, G59, G59.1, G59.2, G59.3). + 200: Set path control mode (G61, G61.1, G64) + 210: Set distance mode (G90, G91). + 220: Set retract mode (G98, G99). + 230: Go to reference location (G28, G30) or change coordinate system data (G10) or set axis offsets (G92, G92.1, G92.2, G94). + 240: Perform motion (G0 to G3, G33, G38.x, G73, G76, G80 to G89), as modified (possibly) by G53. + 250: Stop (M0, M1, M2, M30, M60). + 900 + letter val: (else) + ''' + if word.letter in ORDER_LINUXCNC_LETTER_MAP: + return ORDER_LINUXCNC_LETTER_MAP[word.letter] + letter_value = str(word) + if letter_value in ORDER_LINUXCNC_LETTERVALUE_MAP: + return ORDER_LINUXCNC_LETTERVALUE_MAP[letter_value] + + # special cases + if (word.letter == 'M') and (100 <= int(word.value) <= 199): + return 130 + if (word.letter == 'G') and (38 < float(word.value) < 39): + return 240 + + # otherwise, sort last, in alphabetic order + return (900 + (ord(word.letter) - ord('A'))) + +def by_linuxcnc_order(word): + return word.orderval_linuxcnc + + +class Word(object): + def __init__(self, letter, value): + self.letter = letter.upper() + + self._value_str = None + self._value = None + if isinstance(value, six.string_types): + self._value_str = value + else: + self._value = value + + # Sorting Order + self._order_linuxcnc = None + + def __str__(self): + return "{letter}{value}".format( + letter=self.letter, + value=self.value_str, + ) + + # Value Properties + @property + def value_str(self): + """Value string, or """ + if self._value_str is None: + return str(self._value) + return self._value_str + + @property + def value(self): + if self._value is None: + return WORD_MAP[self.letter]['class'](self._value_str) + return self._value + + # Order + @property + def orderval_linuxcnc(self): + if self._order_linuxcnc is None: + self._order_linuxcnc = _word_order_linuxcnc(self) + return self._order_linuxcnc + + @property + def description(self): + return WORD_MAP[self.letter]['description'] + +NEXT_WORD = re.compile(r'^.*?(?P[%s])' % ''.join(WORD_MAP.keys()), re.IGNORECASE) + +def iter_words(block_text): + """ + Iterate through block text yielding Word instances + :param block_text: text for given block with comments removed + """ + index = 0 + + while True: + letter_match = NEXT_WORD.search(block_text[index:]) + if letter_match: + # Letter + letter = letter_match.group('letter').upper() + index += letter_match.end() # propogate index to start of value + + # Value + value_regex = WORD_MAP[letter]['value_regex'] + value_match = value_regex.search(block_text[index:]) + if value_match is None: + raise GCodeBlockFormatError("word '%s' value invalid" % letter) + value = value_match.group() # matched text + + yield Word(letter, value) + + index += value_match.end() # propogate index to end of value + else: + break + + remainder = block_text[index:] + if remainder and re.search(r'\S', remainder): + raise GCodeBlockFormatError("block code remaining '%s'" % remainder) diff --git a/tests/runtests.sh b/tests/runtests.sh new file mode 100755 index 0000000..06ad089 --- /dev/null +++ b/tests/runtests.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +python -m unittest discover -s . -p 'test_*.py' --verbose diff --git a/tests/test-files/vertical-slot.ngc b/tests/test-files/vertical-slot.ngc new file mode 100644 index 0000000..5f8d4b8 --- /dev/null +++ b/tests/test-files/vertical-slot.ngc @@ -0,0 +1,26 @@ +T5 M06 G43 +G17 G90 G21 +G00 Z5 S7000 M03 +X9.60 Y48.74 +Z2 +G01 Z-0.5 F100 +G02 X10.75 Y47.44 I-0.11 J-1.26 F70 +G01 X10.75 Y-47.44 +G02 X9.51 Y-48.69 I-1.25 J0 +G02 X8.25 Y-47.44 I0 J1.25 +G01 X8.25 Y-47.44 +X8.25 Y47.44 +G02 X9.6 Y48.74 I1.25 J0.05 +G00 Z5 +Z1.5 +G01 Z-1 F100 +G02 X10.75 Y47.44 I-0.11 J-1.26 F70 +G01 X10.75 Y-47.44 +G02 X9.51 Y-48.69 I-1.25 J0 +G02 X8.25 Y-47.44 I0 J1.25 +G01 X8.25 Y-47.44 +X8.25 Y47.44 +G02 X9.60 Y48.74 I1.25 J0.05 +G00 Z5 +T0 M06 M02 + diff --git a/tests/test_file.py b/tests/test_file.py new file mode 100644 index 0000000..30f131e --- /dev/null +++ b/tests/test_file.py @@ -0,0 +1,20 @@ +import sys +import os +import inspect + +import unittest + +# Units Under Test +_this_path = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) +sys.path.insert(0, os.path.join(_this_path, '..')) +from pygcode.file import parse, GCodeFile + +class FileParseTest(unittest.TestCase): + FILENAME = 'test-files/vertical-slot.ngc' + + def test_parser(self): + file = parse(self.FILENAME) + self.assertEqual(len(file.lines), 26) + # FIXME: just verifying content visually + for line in file.lines: + print(' '.join(["%s%s" % (w.letter, w.value_str) for w in line.block.words])) diff --git a/tests/test_line.py b/tests/test_line.py new file mode 100644 index 0000000..2ad74a5 --- /dev/null +++ b/tests/test_line.py @@ -0,0 +1,27 @@ +import sys +import os +import inspect + +import unittest + +# Units Under Test +_this_path = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) +sys.path.insert(0, os.path.join(_this_path, '..')) +from pygcode.line import Line + + +class LineCommentTests(unittest.TestCase): + def test_line_comment_semicolon(self): + line = Line('G02 X10.75 Y47.44 I-0.11 J-1.26 F70 ; blah blah') + self.assertEqual(line.comment.text, 'blah blah') + self.assertEqual(len(line.block.words), 6) + + def test_line_comment_brackets(self): + line = Line('G02 X10.75 Y47.44 I-0.11 J-1.26 F70 (blah blah)') + self.assertEqual(line.comment.text, 'blah blah') + self.assertEqual(len(line.block.words), 6) + + def test_line_comment_brackets_multi(self): + line = Line('G02 X10.75 (x coord) Y47.44 (y coord) I-0.11 J-1.26 F70 (eol)') + self.assertEqual(line.comment.text, 'x coord. y coord. eol') + self.assertEqual(len(line.block.words), 6) diff --git a/tests/test_words.py b/tests/test_words.py new file mode 100644 index 0000000..b60ad23 --- /dev/null +++ b/tests/test_words.py @@ -0,0 +1,42 @@ +import sys +import os +import inspect + +import unittest + +# Units Under Test +_this_path = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) +sys.path.insert(0, os.path.join(_this_path, '..')) +import pygcode.words as gcode_words + +#words.iter_words + +class WordTests(unittest.TestCase): + def test_O(self): + pass # TODO + + + +class WordIterTests(unittest.TestCase): + def test_iter1(self): + block_str = 'G01 Z-0.5 F100' + w = list(gcode_words.iter_words(block_str)) + # word length + self.assertEqual(len(w), 3) + # word values + self.assertEqual([w[0].letter, w[0].value], ['G', '01']) + self.assertEqual([w[1].letter, w[1].value], ['Z', -0.5]) + self.assertEqual([w[2].letter, w[2].value], ['F', 100]) + + def test_iter2(self): + block_str = 'G02 X10.75 Y47.44 I-0.11 J-1.26 F70' + w = list(gcode_words.iter_words(block_str)) + # word length + self.assertEqual(len(w), 6) + # word values + self.assertEqual([w[0].letter, w[0].value], ['G', '02']) + self.assertEqual([w[1].letter, w[1].value], ['X', 10.75]) + self.assertEqual([w[2].letter, w[2].value], ['Y', 47.44]) + self.assertEqual([w[3].letter, w[3].value], ['I', -0.11]) + self.assertEqual([w[4].letter, w[4].value], ['J', -1.26]) + self.assertEqual([w[5].letter, w[5].value], ['F', 70])