initial content (24hrs later)

This commit is contained in:
Peter Boin 2017-07-03 22:28:26 +10:00
parent 8220baa837
commit 2d125028b1
15 changed files with 652 additions and 11 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
# python cache / compilations
*__pycache__
*.pyc
# editor backups
*.swp

View File

@ -16,16 +16,19 @@ Just brainstorming here...
import math import math
import euclid import euclid
gfile_in = pygcode.Parser('part.gcode') gfile_in = pygcode.parse('part1.gcode') #
gfile_out = pygcode.Writer('part2.gcode') gfile_out = pygcode.GCodeFile('part2.gcode')
total_travel = 0 total_travel = 0
total_time = 0 total_time = 0
for (state, block) in gfile_in.iterstate(): machine = pygcode.Machine()
# where:
# state = CNC's state before the block is executed for line in gfile_in.iterlines():
# block = the gcode to be executed next
block = line.block
if block is None:
continue
# validation # validation
if isinstance(block, pygcode.GCodeArc): if isinstance(block, pygcode.GCodeArc):
@ -36,13 +39,13 @@ Just brainstorming here...
block.set_precision(0.0005, method=pygcode.GCodeArc.EFFECT_RADIUS) block.set_precision(0.0005, method=pygcode.GCodeArc.EFFECT_RADIUS)
# random metrics # 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() 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_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(feed_rate=machine.state.feed_rate) # doesn't consider the feedrate being changed in this block
total_time += block.time(state=state) total_time += block.time(state=machine.state)
# rotate : entire file 90deg CCW # rotate : entire file 90deg CCW
block.rotate(euclid.Quaternion.new_rotate_axis( block.rotate(euclid.Quaternion.new_rotate_axis(
@ -51,6 +54,8 @@ Just brainstorming here...
# translate : entire file x += 1, y += 2 mm (after rotation) # translate : entire file x += 1, y += 2 mm (after rotation)
block.translate(euclid.Vector3(1, 2, 0), unit=pygcode.UNIT_MM) block.translate(euclid.Vector3(1, 2, 0), unit=pygcode.UNIT_MM)
# TODO: then do something like write it to another file # TODO: then do something like write it to another file
gfile_out.write(block) gfile_out.write(block)
@ -59,7 +64,7 @@ Just brainstorming here...
## Supported G-Codes ## 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. But anything pre v1.0 will be a sub-set, focusing on the issues I'm having... I'm selfish that way.

0
pygcode/__init__.py Normal file
View File

36
pygcode/block.py Normal file
View File

@ -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 <word><value>)"""
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
))

61
pygcode/comment.py Normal file
View File

@ -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<text>.*)$')
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<text>[^\)]*)\)')
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(<functional block code>), CommentBase(<comment(s)>))
"""
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)

7
pygcode/exceptions.py Normal file
View File

@ -0,0 +1,7 @@
# ===================== Parsing Exceptions =====================
class GCodeBlockFormatError(Exception):
"""Raised when errors encountered while parsing block text"""
pass
# ===================== Parsing Exceptions =====================

23
pygcode/file.py Normal file
View File

@ -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

28
pygcode/line.py Normal file
View File

@ -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'

36
pygcode/machine.py Normal file
View File

@ -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

321
pygcode/words.py Normal file
View File

@ -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<letter>[%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)

3
tests/runtests.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
python -m unittest discover -s . -p 'test_*.py' --verbose

View File

@ -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

20
tests/test_file.py Normal file
View File

@ -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]))

27
tests/test_line.py Normal file
View File

@ -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)

42
tests/test_words.py Normal file
View File

@ -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])