mirror of
https://git.mirrors.martin98.com/https://github.com/petaflot/pygcode
synced 2025-10-10 04:46:29 +08:00
machine processing underway
This commit is contained in:
parent
3674f9d6aa
commit
ec6ebb2870
@ -1,39 +1,50 @@
|
||||
import re
|
||||
from .words import iter_words, WORD_MAP
|
||||
from .words import text2words, WORD_MAP
|
||||
from .gcodes import words2gcodes
|
||||
|
||||
class Block(object):
|
||||
"""GCode block (effectively any gcode file line that defines any <word><value>)"""
|
||||
|
||||
def __init__(self, text):
|
||||
def __init__(self, text=None, verify=True):
|
||||
"""
|
||||
Block Constructor
|
||||
:param A-Z: gcode parameter values
|
||||
:param comment: comment text
|
||||
"""
|
||||
|
||||
self._raw_text = text # unaltered block content (before alteration)
|
||||
self._raw_text = None
|
||||
self._text = None
|
||||
self.words = []
|
||||
self.gcodes = []
|
||||
self.modal_params = []
|
||||
|
||||
# clean up block string
|
||||
text = re.sub(r'(^\s+|\s+$)', '', text) # remove whitespace padding
|
||||
text = re.sub(r'\s+', ' ', text) # remove duplicate whitespace with ' '
|
||||
if text:
|
||||
self._raw_text = text # unaltered block content (before alteration)
|
||||
text = re.sub(r'(^\s+|\s+$)', '', text) # remove whitespace padding
|
||||
text = re.sub(r'\s+', ' ', text) # remove duplicate whitespace with ' '
|
||||
self._text = text # cleaned up block content
|
||||
|
||||
self.text = text
|
||||
# Get words from text, and group into gcodes
|
||||
self.words = list(text2words(self._text))
|
||||
(self.gcodes, self.modal_params) = words2gcodes(self.words)
|
||||
|
||||
self.words = list(iter_words(self.text))
|
||||
(self.gcodes, self.modal_params) = words2gcodes(self.words)
|
||||
# Verification
|
||||
if verify:
|
||||
self._assert_gcodes()
|
||||
|
||||
self._assert_gcodes()
|
||||
|
||||
# TODO: gcode verification
|
||||
# - gcodes in the same modal_group raises exception
|
||||
@property
|
||||
def text(self):
|
||||
if self._text:
|
||||
return self._text
|
||||
return str(self)
|
||||
|
||||
def _assert_gcodes(self):
|
||||
modal_groups = set()
|
||||
code_words = set()
|
||||
|
||||
for gc in self.gcodes:
|
||||
|
||||
|
||||
# Assert all gcodes are not repeated in the same block
|
||||
if gc.word in code_words:
|
||||
raise AssertionError("%s cannot be in the same block" % ([
|
||||
|
@ -7,6 +7,12 @@ class CommentBase(object):
|
||||
def __init__(self, text):
|
||||
self.text = text
|
||||
|
||||
def __repr__(self):
|
||||
return "<{class_name}: '{comment}'>".format(
|
||||
class_name=self.__class__.__name__,
|
||||
comment=str(self),
|
||||
)
|
||||
|
||||
|
||||
class CommentSemicolon(CommentBase):
|
||||
"Comments of the format: 'G00 X1 Y2 ; something profound'"
|
||||
|
@ -14,12 +14,15 @@ class GCodeFile(object):
|
||||
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)
|
||||
# FIXME: don't dump entire file into RAM; change to generator model
|
||||
file.append(line_obj)
|
||||
return file
|
||||
class GCodeParser(object):
|
||||
"""Parse a gocde file"""
|
||||
def __init__(self, filename):
|
||||
self.filename = filename
|
||||
self._fh = open(filename, 'r')
|
||||
|
||||
def iterlines(self):
|
||||
for line in self._fh.readlines():
|
||||
yield Line(line)
|
||||
|
||||
def close(self):
|
||||
self._fh.close()
|
||||
|
@ -40,7 +40,7 @@ from .words import Word
|
||||
#
|
||||
# Table 5. G-Code Modal Groups
|
||||
# MODAL GROUP MEANING MEMBER WORDS
|
||||
# Non-modal codes (Group 0) G4, G10 G28, G30, G53 G92, G92.1, G92.2, G92.3,
|
||||
# Non-modal codes (Group 0) G4, G10 G28, G30, G53, G92, G92.1, G92.2, G92.3,
|
||||
# Motion (Group 1) G0, G1, G2, G3, G33, G38.x, G73, G76, G80, G81
|
||||
# G82, G83, G84, G85, G86, G87, G88,G89
|
||||
# Plane selection (Group 2) G17, G18, G19, G17.1, G18.1, G19.1
|
||||
@ -132,6 +132,12 @@ MODAL_GROUP_MAP = {
|
||||
# 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).
|
||||
|
||||
class GCodeEffect(object):
|
||||
"""Effect a gcode has on a machine upon processing"""
|
||||
def __init__(self, **kwargs):
|
||||
self.mode = {}
|
||||
self.dt = 0.0
|
||||
|
||||
|
||||
class GCode(object):
|
||||
# Defining Word
|
||||
@ -149,6 +155,10 @@ class GCode(object):
|
||||
exec_order = 999 # if not otherwise specified, run last
|
||||
|
||||
def __init__(self, word, *params):
|
||||
"""
|
||||
:param word: Word instance defining gcode (eg: Word('G0') for rapid movement)
|
||||
:param params: list of Word instances (eg: Word('X-1.2') as x-coordinate)
|
||||
"""
|
||||
assert isinstance(word, Word), "invalid gcode word %r" % code_word
|
||||
self.word = word
|
||||
self.params = {}
|
||||
@ -188,6 +198,10 @@ class GCode(object):
|
||||
return self.exec_order < other.exec_order
|
||||
|
||||
def add_parameter(self, word):
|
||||
"""
|
||||
Add given word as a parameter for this gcode
|
||||
:param word: Word instance
|
||||
"""
|
||||
assert isinstance(word, Word), "invalid parameter class: %r" % word
|
||||
assert word.letter in self.param_letters, "invalid parameter for %s: %s" % (self.__class__.__name__, str(word))
|
||||
assert word.letter not in self.params, "parameter defined twice: %s -> %s" % (self.params[word.letter], word)
|
||||
@ -217,6 +231,44 @@ class GCode(object):
|
||||
if l in self.modal_param_letters
|
||||
])
|
||||
|
||||
def get_param_dict(self, letters=None):
|
||||
"""
|
||||
Get gcode parameters as a dict
|
||||
gcode parameter like "X3.1, Y-2" would return {'X': 3.1, 'Y': -2}
|
||||
:param letters: iterable whitelist of letters to include as dict keys
|
||||
:return: dict of gcode parameters' (letter, value) pairs
|
||||
"""
|
||||
return dict(
|
||||
(w.letter, w.value) for w in self.params.values()
|
||||
if (letters is None) or (w.letter in letters)
|
||||
)
|
||||
|
||||
# Process GCode
|
||||
def process(self, machine):
|
||||
"""
|
||||
Process a GCode on the given Machine
|
||||
:param machine: Machine instance, to change state
|
||||
:return: GCodeEffect instance; effect the gcode just had on machine
|
||||
"""
|
||||
from .machine import Machine # importing high-level state
|
||||
assert isinstance(machine, Machine), "invalid parameter"
|
||||
effect = GCodeEffect()
|
||||
|
||||
# Set mode
|
||||
self._process_mode(machine)
|
||||
|
||||
# GCode specific
|
||||
self._process(machine)
|
||||
|
||||
def _process_mode(self, machine):
|
||||
"""Set machine's state"""
|
||||
machine.set_mode(self)
|
||||
|
||||
def _process(self, machine):
|
||||
"""Process this GCode (to be overridden)"""
|
||||
pass
|
||||
|
||||
|
||||
|
||||
# ======================= Motion =======================
|
||||
# (X Y Z A B C U V W apply to all motions)
|
||||
@ -238,10 +290,17 @@ class GCodeMotion(GCode):
|
||||
modal_group = MODAL_GROUP_MAP['motion']
|
||||
exec_order = 241
|
||||
|
||||
def _process(self, machine):
|
||||
machine.move_to(**self.get_param_dict(letters=machine.axes))
|
||||
|
||||
|
||||
class GCodeRapidMove(GCodeMotion):
|
||||
"""G0: Rapid Move"""
|
||||
word_key = Word('G', 0)
|
||||
|
||||
def _process(self, machine):
|
||||
machine.move_to(rapid=True, **self.get_param_dict(letters=machine.axes))
|
||||
|
||||
|
||||
class GCodeLinearMove(GCodeMotion):
|
||||
"""G1: Linear Move"""
|
||||
@ -334,6 +393,7 @@ class GCodeCannedCycle(GCode):
|
||||
modal_group = MODAL_GROUP_MAP['motion']
|
||||
exec_order = 241
|
||||
|
||||
|
||||
class GCodeDrillingCycle(GCodeCannedCycle):
|
||||
"""G81: Drilling Cycle"""
|
||||
param_letters = GCodeCannedCycle.param_letters | set('RLP')
|
||||
@ -600,11 +660,13 @@ class GCodeUnit(GCode):
|
||||
class GCodeUseInches(GCodeUnit):
|
||||
"""G20: use inches for length units"""
|
||||
word_key = Word('G', 20)
|
||||
unit_id = 0
|
||||
|
||||
|
||||
class GCodeUseMillimeters(GCodeUnit):
|
||||
"""G21: use millimeters for length units"""
|
||||
word_key = Word('G', 21)
|
||||
unit_id = 1
|
||||
|
||||
|
||||
# ======================= Plane Selection =======================
|
||||
@ -815,22 +877,64 @@ class GCodeFeedStop(GCodeOtherModal):
|
||||
|
||||
|
||||
class GCodeSelectCoordinateSystem(GCodeOtherModal):
|
||||
"""
|
||||
G54 - select coordinate system 1
|
||||
G55 - select coordinate system 2
|
||||
G56 - select coordinate system 3
|
||||
G57 - select coordinate system 4
|
||||
G58 - select coordinate system 5
|
||||
G59 - select coordinate system 6
|
||||
G59.1 - select coordinate system 7
|
||||
G59.2 - select coordinate system 8
|
||||
G59.3 - select coordinate system 9
|
||||
"""
|
||||
@classmethod
|
||||
def word_matches(cls, w):
|
||||
return (w.letter == 'G') and (w.value in [54, 55, 56, 57, 58, 59, 59.1, 59.2, 59.3])
|
||||
"""Select Coordinate System"""
|
||||
modal_group = MODAL_GROUP_MAP['coordinate_system']
|
||||
exec_order = 190
|
||||
coord_system_id = None # overridden in inheriting classes
|
||||
|
||||
|
||||
class GCodeSelectCoordinateSystem1(GCodeSelectCoordinateSystem):
|
||||
"""Select Coordinate System 1"""
|
||||
word_key = Word('G', 54)
|
||||
coord_system_id = 1
|
||||
|
||||
|
||||
class GCodeSelectCoordinateSystem2(GCodeSelectCoordinateSystem):
|
||||
"""Select Coordinate System 2"""
|
||||
word_key = Word('G', 55)
|
||||
coord_system_id = 2
|
||||
|
||||
|
||||
class GCodeSelectCoordinateSystem3(GCodeSelectCoordinateSystem):
|
||||
"""Select Coordinate System 3"""
|
||||
word_key = Word('G', 56)
|
||||
coord_system_id = 3
|
||||
|
||||
|
||||
class GCodeSelectCoordinateSystem4(GCodeSelectCoordinateSystem):
|
||||
"""Select Coordinate System 4"""
|
||||
word_key = Word('G', 57)
|
||||
coord_system_id = 4
|
||||
|
||||
|
||||
class GCodeSelectCoordinateSystem5(GCodeSelectCoordinateSystem):
|
||||
"""Select Coordinate System 5"""
|
||||
word_key = Word('G', 58)
|
||||
coord_system_id = 5
|
||||
|
||||
|
||||
class GCodeSelectCoordinateSystem6(GCodeSelectCoordinateSystem):
|
||||
"""Select Coordinate System 6"""
|
||||
word_key = Word('G', 59)
|
||||
coord_system_id = 6
|
||||
|
||||
|
||||
class GCodeSelectCoordinateSystem7(GCodeSelectCoordinateSystem):
|
||||
"""Select Coordinate System 7"""
|
||||
coord_system_id = 7
|
||||
word_key = Word('G', 59.1)
|
||||
|
||||
|
||||
class GCodeSelectCoordinateSystem8(GCodeSelectCoordinateSystem):
|
||||
"""Select Coordinate System 8"""
|
||||
word_key = Word('G', 59.2)
|
||||
coord_system_id = 8
|
||||
|
||||
|
||||
class GCodeSelectCoordinateSystem9(GCodeSelectCoordinateSystem):
|
||||
"""Select Coordinate System 9"""
|
||||
word_key = Word('G', 59.3)
|
||||
coord_system_id = 9
|
||||
|
||||
|
||||
# ======================= Flow-control Codes =======================
|
||||
@ -981,6 +1085,8 @@ class GCodeResetCoordSystemOffset(GCodeNonModal):
|
||||
return (w.letter == 'G') and (w.value in [92.1, 92.2])
|
||||
exec_order = 230
|
||||
|
||||
# TODO: machine.state.offset *= 0
|
||||
|
||||
|
||||
class GCodeRestoreCoordSystemOffset(GCodeNonModal):
|
||||
"""G92.3: Restore Coordinate System Offset"""
|
||||
@ -1009,7 +1115,7 @@ def _subclasses_level(root_class, recursion_level=0):
|
||||
:param
|
||||
"""
|
||||
yield (root_class, recursion_level)
|
||||
for cls in root_class.__subclasses__():
|
||||
for cls in sorted(root_class.__subclasses__(), key=lambda c: c.__name__):
|
||||
for (sub, level) in _subclasses_level(cls, recursion_level+1):
|
||||
yield (sub, level)
|
||||
|
||||
@ -1028,7 +1134,11 @@ def _gcode_class_infostr(base_class=GCode):
|
||||
"""
|
||||
info_str = ""
|
||||
for (cls, level) in _subclasses_level(base_class):
|
||||
info_str += "{indent}- {name}: {description}\n".format(
|
||||
word_str = ''
|
||||
if cls.word_key:
|
||||
word_str = cls.word_key.clean_str
|
||||
info_str += "{word} {indent}- {name}: {description}\n".format(
|
||||
word="%-5s" % word_str,
|
||||
indent=(level * " "),
|
||||
name=cls.__name__,
|
||||
description=cls.__doc__ or "",
|
||||
|
@ -1,12 +1,211 @@
|
||||
|
||||
from copy import copy, deepcopy
|
||||
from collections import defaultdict
|
||||
|
||||
from .gcodes import MODAL_GROUP_MAP, GCode
|
||||
from .gcodes import (
|
||||
MODAL_GROUP_MAP, GCode,
|
||||
# Modal GCodes
|
||||
GCodeIncrementalDistanceMode,
|
||||
GCodeUseInches, GCodeUseMillimeters,
|
||||
)
|
||||
from .block import Block
|
||||
from .line import Line
|
||||
from .words import Word
|
||||
|
||||
|
||||
UNIT_IMPERIAL = GCodeUseInches.unit_id # G20
|
||||
UNIT_METRIC = GCodeUseMillimeters.unit_id # G21
|
||||
UNIT_MAP = {
|
||||
UNIT_IMPERIAL: {
|
||||
'name': 'inches',
|
||||
'conversion_factor': { UNIT_METRIC: 25.4 },
|
||||
},
|
||||
UNIT_METRIC: {
|
||||
'name': 'millimeters',
|
||||
'conversion_factor': { UNIT_IMPERIAL: 1. / 25.4 },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Position(object):
|
||||
default_axes = 'XYZABCUVW'
|
||||
default_unit = UNIT_METRIC
|
||||
POSSIBLE_AXES = set('XYZABCUVW')
|
||||
|
||||
def __init__(self, axes=None, **kwargs):
|
||||
# Set axes (note: usage in __getattr__ and __setattr__)
|
||||
if axes is None:
|
||||
axes = self.__class__.default_axes
|
||||
else:
|
||||
invalid_axes = set(axes) - self.POSSIBLE_AXES
|
||||
assert not invalid_axes, "invalid axes proposed %s" % invalid_axes
|
||||
self.__dict__['axes'] = set(axes) & self.POSSIBLE_AXES
|
||||
|
||||
# Unit
|
||||
self._unit = kwargs.pop('unit', self.default_unit)
|
||||
|
||||
# Initial Values
|
||||
self._value = defaultdict(lambda: 0.0, dict((k, 0.0) for k in self.axes))
|
||||
self._value.update(kwargs)
|
||||
|
||||
# Attributes Get/Set
|
||||
def __getattr__(self, key):
|
||||
if key in self.axes:
|
||||
return self._value[key]
|
||||
|
||||
raise AttributeError("'{cls}' object has no attribute '{key}'".format(
|
||||
cls=self.__class__.__name__,
|
||||
key=key
|
||||
))
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
if key in self.axes:
|
||||
self._value[key] = value
|
||||
elif key in self.POSSIBLE_AXES:
|
||||
raise AssertionError("'%s' axis is not defined to be set" % key)
|
||||
else:
|
||||
self.__dict__[key] = value
|
||||
|
||||
# Copy
|
||||
def __copy__(self):
|
||||
return self.__class__(axes=copy(self.axes), unit=self._unit, **self.values)
|
||||
|
||||
# Equality
|
||||
def __eq__(self, other):
|
||||
if self.axes ^ other.axes:
|
||||
return False
|
||||
else:
|
||||
if self._unit == other._unit:
|
||||
return self._value == other._value
|
||||
else:
|
||||
x = copy(other)
|
||||
x.set_unit(self._unit)
|
||||
return self._value == x._value
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
# Arithmetic
|
||||
def __add__(self, other):
|
||||
assert not (self.axes ^ other.axes), "axes: %r != %r" % (self.axes, other.axes)
|
||||
new_obj = copy(self)
|
||||
for k in new_obj._value:
|
||||
new_obj._value[k] += other._value[k]
|
||||
return new_obj
|
||||
|
||||
def __sub__(self, other):
|
||||
assert not (other.axes - self.axes), "for a - b: axes in b, that are not in a: %r" % (other.axes - self.axes)
|
||||
new_obj = copy(self)
|
||||
for k in other._value:
|
||||
new_obj._value[k] -= other._value[k]
|
||||
return new_obj
|
||||
|
||||
def __mul__(self, scalar):
|
||||
new_obj = copy(self)
|
||||
for k in self._value:
|
||||
new_obj._value[k] = self._value[k] * scalar
|
||||
return new_obj
|
||||
|
||||
def __div__(self, scalar):
|
||||
new_obj = copy(self)
|
||||
for k in self._value:
|
||||
new_obj._value[k] = self._value[k] / scalar
|
||||
return new_obj
|
||||
|
||||
__truediv__ = __div__ # Python 3 division
|
||||
|
||||
# Conversion
|
||||
def set_unit(self, unit):
|
||||
if unit == self._unit:
|
||||
return
|
||||
factor = UNIT_MAP[self._unit]['conversion_factor'][unit]
|
||||
for k in [k for (k, v) in self._value.items() if v is not None]:
|
||||
self._value[k] *= factor
|
||||
self._unit = unit
|
||||
|
||||
@property
|
||||
def words(self):
|
||||
return sorted(Word(k, self._value[k]) for k in self.axes)
|
||||
|
||||
@property
|
||||
def values(self):
|
||||
return dict(self._value)
|
||||
|
||||
def __repr__(self):
|
||||
return "<{class_name}: {coordinates}>".format(
|
||||
class_name=self.__class__.__name__,
|
||||
coordinates=' '.join(str(w) for w in self.words)
|
||||
)
|
||||
|
||||
|
||||
class CoordinateSystem(object):
|
||||
def __init__(self, axes):
|
||||
self.offset = Position(axes)
|
||||
|
||||
def __add__(self, other):
|
||||
if isinstance(other, CoordinateSystem):
|
||||
pass
|
||||
|
||||
def __repr__(self):
|
||||
return "<{class_name}: offset={offset}>".format(
|
||||
class_name=self.__class__.__name__,
|
||||
offset=repr(self.offset),
|
||||
)
|
||||
|
||||
|
||||
class State(object):
|
||||
"""State of a Machine"""
|
||||
# LinuxCNC documentation lists parameters for a machine's state:
|
||||
# http://linuxcnc.org/docs/html/gcode/overview.html#sub:numbered-parameters
|
||||
# AFAIK: this is everything needed to remember a machine's state that isn't
|
||||
# handled by modal gcodes.
|
||||
def __init__(self, axes=None):
|
||||
# Coordinate Systems
|
||||
self.coord_systems = {}
|
||||
for i in range(1, 10): # G54-G59.3
|
||||
self.coord_systems[i] = CoordinateSystem(axes)
|
||||
|
||||
self.cur_coord_sys = 1 # default to coord system 1 (G54)
|
||||
|
||||
# Temporary Offset
|
||||
self.offset = Position(axes) # G92 offset (reset by G92.x)
|
||||
|
||||
# Missing from state (according to LinuxCNC's state variables):
|
||||
# - G38.2 probe result (Position())
|
||||
# - G38 probe result (bool)
|
||||
# - M66: result (bool)
|
||||
# - Tool offsets (Position())
|
||||
# - Tool info (number, diameter, front angle, back angle, orientation)
|
||||
|
||||
#self.work_offset = defaultdict(lambda: 0.0)
|
||||
|
||||
# TODO: how to manage work offsets? (probs not like the above)
|
||||
# read up on:
|
||||
# Coordinate System config:
|
||||
# - G92: set machine coordinate system value (no movement, effects all coordinate systems)
|
||||
# - G10 L2: offsets the origin of the axes in the coordinate system specified to the value of the axis word
|
||||
# - G10 L20: makes the current machine coordinates the coordinate system's offset
|
||||
# Coordinate System selection:
|
||||
# - G54-G59: select coordinate system (offsets from machine coordinates set by G10 L2)
|
||||
|
||||
# TODO: Move this class into MachineState
|
||||
|
||||
@property
|
||||
def coord_sys(self):
|
||||
"""Current equivalent coordinate system, including all """
|
||||
if self.cur_coord_sys in self.coord_systems:
|
||||
return self.coord_systems[self.cur_coord_sys]
|
||||
return None
|
||||
|
||||
def __repr__(self):
|
||||
return "<{class_name}: coord_sys[{coord_index}]; offset={offset}>".format(
|
||||
class_name=self.__class__.__name__,
|
||||
coord_index=self.cur_coord_sys,
|
||||
offset=repr(self.offset),
|
||||
)
|
||||
|
||||
|
||||
class Mode(object):
|
||||
"""Machine's mode"""
|
||||
# State is very forgiving:
|
||||
# Anything possible in a machine's state may be changed & fetched.
|
||||
# For example: x, y, z, a, b, c may all be set & requested.
|
||||
@ -15,36 +214,29 @@ class State(object):
|
||||
# It is also possible to set an axis to an impossibly large distance.
|
||||
# It is the responsibility of the Machine using this class to be
|
||||
# discerning in these respects.
|
||||
def __init__(self):
|
||||
self.axes = defaultdict(lambda: 0.0) # aka: "machine coordinates"
|
||||
|
||||
self.work_offset = defaultdict(lambda: 0.0)
|
||||
# TODO: how to manage work offsets? (probs not like the above)
|
||||
# read up on:
|
||||
# - G92: coordinate system offset
|
||||
# - G54-G59: select coordinate system (offsets from machine coordinates set by G10 L2)
|
||||
|
||||
# TODO: Move this class into MachineState
|
||||
|
||||
|
||||
class MachineState(object):
|
||||
"""Machine's state, and mode"""
|
||||
# Default Mode
|
||||
# for a Grbl controller this can be obtained with the `$G` command, eg:
|
||||
# > $G
|
||||
# > [GC:G0 G54 G17 G21 G90 G94 M5 M9 T0 F0 S0]
|
||||
# ref: https://github.com/gnea/grbl/wiki/Grbl-v1.1-Commands#g---view-gcode-parser-state
|
||||
default_mode = '''
|
||||
G17 (plane_selection: X/Y plane)
|
||||
G90 (distance: absolute position. ie: not "turtle" mode)
|
||||
G91.1 (arc_ijk_distance: IJK sets arc center vertex relative to current position)
|
||||
G94 (feed_rate_mode: feed-rate defined in units/min)
|
||||
G21 (units: mm)
|
||||
G40 (cutter_diameter_comp: no compensation)
|
||||
G49 (tool_length_offset: no offset)
|
||||
G61 (control_mode: exact path mode)
|
||||
G97 (spindle_speed_mode: RPM Mode)
|
||||
M0 (stopping: program paused)
|
||||
M5 (spindle: off)
|
||||
M9 (coolant: off)
|
||||
F100 (feed_rate: 100 mm/min)
|
||||
S1000 (tool: 1000 rpm, when it's switched on)
|
||||
G0 (movement: rapid)
|
||||
G17 (plane_selection: X/Y plane)
|
||||
G90 (distance: absolute position. ie: not "turtle" mode)
|
||||
G91.1 (arc_ijk_distance: IJK sets arc center vertex relative to current position)
|
||||
G94 (feed_rate_mode: feed-rate defined in units/min)
|
||||
G21 (units: mm)
|
||||
G40 (cutter_diameter_comp: no compensation)
|
||||
G49 (tool_length_offset: no offset)
|
||||
G54 (coordinate_system: 1)
|
||||
G61 (control_mode: exact path mode)
|
||||
G97 (spindle_speed_mode: RPM Mode)
|
||||
M5 (spindle: off)
|
||||
M9 (coolant: off)
|
||||
F0 (feed_rate: 0)
|
||||
S0 (spindle_speed: 0)
|
||||
T0 (tool: 0)
|
||||
'''
|
||||
|
||||
# Mode is defined by gcodes set by processed blocks:
|
||||
@ -56,9 +248,18 @@ class MachineState(object):
|
||||
self.set_mode(*Line(self.default_mode).block.gcodes)
|
||||
|
||||
def set_mode(self, *gcode_list):
|
||||
"""
|
||||
Set machine mode from given gcodes
|
||||
:param gcode_list: list of GCode instances (given as individual parameters)
|
||||
:return: dict of form: {<modal group>: <new mode GCode>, ...}
|
||||
"""
|
||||
modal_gcodes = {}
|
||||
for g in sorted(gcode_list): # sorted by execution order
|
||||
if g.modal_group is not None:
|
||||
self.modal_groups[g.modal_group] = g.modal_copy()
|
||||
modal_gcodes[g.modal_group] = self.modal_groups[g.modal_group]
|
||||
# assumption: no 2 gcodes are in the same modal_group
|
||||
return modal_gcodes
|
||||
|
||||
def __getattr__(self, key):
|
||||
if key in MODAL_GROUP_MAP:
|
||||
@ -99,8 +300,34 @@ class MachineState(object):
|
||||
|
||||
|
||||
class Machine(object):
|
||||
"""Machine to process gcodes, enforce axis limits, keep track of time, etc"""
|
||||
|
||||
# Class types
|
||||
MODE_CLASS = Mode
|
||||
STATE_CLASS = State
|
||||
|
||||
axes = set('XYZ')
|
||||
|
||||
def __init__(self):
|
||||
self.state = MachineState()
|
||||
self.mode = self.MODE_CLASS()
|
||||
self.state = self.STATE_CLASS(axes=self.axes)
|
||||
|
||||
# Position type (with default axes the same as this machine)
|
||||
self.Position = type('Position', (Position,), {
|
||||
'default_axes': self.axes,
|
||||
'default_unit': self.mode.units.unit_id,
|
||||
})
|
||||
|
||||
# Absolute machine position
|
||||
self.abs_pos = self.Position()
|
||||
|
||||
def set_mode(self, *gcode_list):
|
||||
self.mode.set_mode(*gcode_list) # passthrough
|
||||
|
||||
# Act on mode changes
|
||||
coord_sys_mode = self.mode.coordinate_system
|
||||
if coord_sys_mode:
|
||||
self.state.cur_coord_sys = coord_sys_mode.coord_system_id
|
||||
|
||||
def process(self, *gcode_list, **kwargs):
|
||||
"""
|
||||
@ -108,16 +335,52 @@ class Machine(object):
|
||||
:param gcode_list: list of GCode instances
|
||||
:param modal_params: list of Word instances to be applied to current movement mode
|
||||
"""
|
||||
# Add modal gcode to list of given gcodes
|
||||
modal_params = kwargs.get('modal_params', [])
|
||||
|
||||
#process_gcodes =
|
||||
if modal_params:
|
||||
assert self.mode.motion is not None, "unable to assign modal parameters when no motion mode is set"
|
||||
(modal_gcodes, unasigned_words) = words2gcodes([self.mode.motion.word] + modal_params)
|
||||
assert unasigned_words == [], "modal parameters '%s' unknown when in mode: %r" % (
|
||||
' '.join(str(x) for x in unasigned_words), self.mode
|
||||
)
|
||||
gcode_list += modal_gcodes
|
||||
|
||||
for gcode in sorted(gcode_list):
|
||||
self.state.set_mode(gcode) # if gcode is not modal, it's ignored
|
||||
gcode.process(self) # shifts ownership of what happens now to GCode class
|
||||
|
||||
gcode.process(self.state)
|
||||
# TODO: gcode instance to change machine's state
|
||||
# Questions to drive design:
|
||||
# - how much time did the command take?
|
||||
# - what was the tool's distance / displacement
|
||||
# - did the tool travel outside machine boundaries?
|
||||
# Use-cases
|
||||
# - Transform / rotate coordinate system in given gcode
|
||||
# - Convert arcs to linear segments (visa versa?)
|
||||
# - Correct precision errors
|
||||
# - Crop a file (eg: resume half way through)
|
||||
|
||||
def process_block(self, block):
|
||||
self.process(*block.gcodes, modal_params=block.modal_params)
|
||||
|
||||
@property
|
||||
def pos(self):
|
||||
"""Return current position in current coordinate system"""
|
||||
coord_sys_offset = getattr(self.state.coord_sys, 'offset', Position(axes=self.axes))
|
||||
temp_offset = self.state.offset
|
||||
return (self.abs_pos - coord_sys_offset) - temp_offset
|
||||
|
||||
@pos.setter
|
||||
def pos(self, value):
|
||||
"""Set absolute position given current position and coordinate system"""
|
||||
coord_sys_offset = getattr(self.state.coord_sys, 'offset', Position(axes=self.axes))
|
||||
temp_offset = self.state.offset
|
||||
self.abs_pos = (value + temp_offset) + coord_sys_offset
|
||||
|
||||
# =================== Machine Actions ===================
|
||||
def move_to(self, rapid=False, **coords):
|
||||
"""Move machine to given position"""
|
||||
given_position = Position(axes=self.axes, **coords)
|
||||
if isinstance(self.mode.distance, GCodeIncrementalDistanceMode):
|
||||
self.pos += given_position
|
||||
else: # assumed: GCodeAbsoluteDistanceMode
|
||||
self.pos = given_position
|
||||
|
@ -281,7 +281,7 @@ class Word(object):
|
||||
return "%s: %s" % (self.letter, WORD_MAP[self.letter]['description'])
|
||||
|
||||
|
||||
def iter_words(block_text):
|
||||
def text2words(block_text):
|
||||
"""
|
||||
Iterate through block text yielding Word instances
|
||||
:param block_text: text for given block with comments removed
|
||||
@ -315,8 +315,21 @@ def iter_words(block_text):
|
||||
|
||||
|
||||
def str2word(word_str):
|
||||
words = list(iter_words(word_str))
|
||||
words = list(text2words(word_str))
|
||||
if words:
|
||||
assert len(words) <= 1, "more than one word given"
|
||||
return words[0]
|
||||
return None
|
||||
|
||||
|
||||
def words2dict(word_list, limit_word_letters=None):
|
||||
"""
|
||||
Represent a list of words as a dict
|
||||
:param limit_word_letters: iterable containing a white-list of word letters (None allows all)
|
||||
:return: dict of the form: {<letter>: <value>, ... }
|
||||
"""
|
||||
# Remember: duplicate word letters cannot be represented as a dict
|
||||
return dict(
|
||||
(w.letter, w.value) for w in word_list
|
||||
if (limit_word_letters is None) or (w.letter in limit_word_letters)
|
||||
)
|
||||
|
@ -4,17 +4,21 @@ 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
|
||||
# Add relative pygcode to path
|
||||
from testutils import add_pygcode_to_path, str_lines
|
||||
add_pygcode_to_path()
|
||||
|
||||
class FileParseTest(unittest.TestCase):
|
||||
# Units under test
|
||||
from pygcode.file import GCodeFile, GCodeParser
|
||||
|
||||
class GCodeParserTest(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(line)
|
||||
parser = GCodeParser(self.FILENAME)
|
||||
# count lines
|
||||
line_count = 0
|
||||
for line in parser.iterlines():
|
||||
line_count += 1
|
||||
self.assertEqual(line_count, 26)
|
||||
parser.close()
|
||||
|
@ -4,9 +4,11 @@ import inspect
|
||||
import re
|
||||
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, '..'))
|
||||
# Add relative pygcode to path
|
||||
from testutils import add_pygcode_to_path, str_lines
|
||||
add_pygcode_to_path()
|
||||
|
||||
# Units under test
|
||||
from pygcode import gcodes
|
||||
from pygcode import words
|
||||
class TestGCodeWordMapping(unittest.TestCase):
|
||||
@ -60,7 +62,7 @@ class TestGCodeModalGroups(unittest.TestCase):
|
||||
match = re.search(r'^\s*(?P<title>.*)\s*\(Group (?P<group>\d+)\)\s*(?P<words>.*)$', row, re.I)
|
||||
if match:
|
||||
for word_str in re.split(r'\s*,\s*', match.group('words')):
|
||||
word = list(words.iter_words(word_str))[0]
|
||||
word = list(words.text2words(word_str))[0]
|
||||
gcode_class = gcodes.word_gcode_class(word)
|
||||
# GCode class found for each word in the table
|
||||
self.assertIsNotNone(gcode_class)
|
||||
@ -81,7 +83,7 @@ class TestGCodeModalGroups(unittest.TestCase):
|
||||
class TestWordsToGCodes(unittest.TestCase):
|
||||
def test_stuff(self): # FIXME: function name
|
||||
line = 'G1 X82.6892 Y-38.6339 F1500'
|
||||
word_list = list(words.iter_words(line))
|
||||
word_list = list(words.text2words(line))
|
||||
result = gcodes.words2gcodes(word_list)
|
||||
# result form
|
||||
self.assertIsInstance(result, tuple)
|
||||
|
@ -4,9 +4,11 @@ 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, '..'))
|
||||
# Add relative pygcode to path
|
||||
from testutils import add_pygcode_to_path, str_lines
|
||||
add_pygcode_to_path()
|
||||
|
||||
# Units under test
|
||||
from pygcode.line import Line
|
||||
|
||||
|
||||
|
122
tests/test_machine.py
Normal file
122
tests/test_machine.py
Normal file
@ -0,0 +1,122 @@
|
||||
import unittest
|
||||
|
||||
# Add relative pygcode to path
|
||||
from testutils import add_pygcode_to_path, str_lines
|
||||
add_pygcode_to_path()
|
||||
|
||||
# Units under test
|
||||
from pygcode.machine import Position, Machine
|
||||
from pygcode.line import Line
|
||||
|
||||
|
||||
class PositionTests(unittest.TestCase):
|
||||
def test_basics(self):
|
||||
p = Position()
|
||||
#
|
||||
|
||||
def test_default_axes(self):
|
||||
p = Position() # no instantiation parameters
|
||||
# all initialized to zero
|
||||
for axis in 'XYZABCUVW':
|
||||
self.assertEqual(getattr(p, axis), 0)
|
||||
|
||||
for axis in 'XYZABCUVW':
|
||||
# set to 100
|
||||
setattr(p, axis, 100)
|
||||
self.assertEqual(getattr(p, axis), 100)
|
||||
for inner_axis in set('XYZABCUVW') - {axis}: # no other axis has changed
|
||||
self.assertEqual(getattr(p, inner_axis), 0), "axis '%s'" % inner_axis
|
||||
# revert back to zero
|
||||
setattr(p, axis, 0)
|
||||
self.assertEqual(getattr(p, axis), 0)
|
||||
|
||||
# Equality
|
||||
def test_equality(self):
|
||||
p1 = Position(axes='XYZ', X=1, Y=2)
|
||||
p2 = Position(axes='XYZ', X=1, Y=2, Z=0)
|
||||
p3 = Position(axes='XYZ', X=1, Y=2, Z=1000)
|
||||
p4 = Position(axes='XYZA', X=1, Y=2, Z=0)
|
||||
|
||||
# p1 <--> p2
|
||||
self.assertTrue(p1 == p2)
|
||||
self.assertFalse(p1 != p2) # negative case
|
||||
|
||||
# p2 <--> p3
|
||||
self.assertTrue(p2 != p3)
|
||||
self.assertFalse(p2 == p3) # negative case
|
||||
|
||||
# p2 <--> p4
|
||||
self.assertTrue(p2 != p4)
|
||||
self.assertFalse(p2 == p4) # negative case
|
||||
|
||||
# Arithmetic
|
||||
def test_arithmetic_add(self):
|
||||
p1 = Position(axes='XYZ', X=1, Y=2)
|
||||
p2 = Position(axes='XYZ', Y=10, Z=-20)
|
||||
self.assertEqual(p1 + p2, Position(axes='XYZ', X=1, Y=12, Z=-20))
|
||||
|
||||
p3 = Position(axes='XYZA')
|
||||
with self.assertRaises(AssertionError):
|
||||
p1 + p3 # mismatched axes
|
||||
with self.assertRaises(AssertionError):
|
||||
p3 + p1 # mismatched axes
|
||||
|
||||
def test_arithmetic_sub(self):
|
||||
p1 = Position(axes='XYZ', X=1, Y=2)
|
||||
p2 = Position(axes='XYZ', Y=10, Z=-20)
|
||||
self.assertEqual(p1 - p2, Position(axes='XYZ', X=1, Y=-8, Z=20))
|
||||
|
||||
p3 = Position(axes='XYZA')
|
||||
p3 - p1 # fine
|
||||
with self.assertRaises(AssertionError):
|
||||
p1 - p3 # mismatched axes
|
||||
|
||||
def test_arithmetic_multiply(self):
|
||||
p = Position(axes='XYZ', X=2, Y=10)
|
||||
self.assertEqual(p * 2, Position(axes='XYZ', X=4, Y=20))
|
||||
|
||||
def test_arithmetic_divide(self):
|
||||
p = Position(axes='XYZ', X=2, Y=10)
|
||||
self.assertEqual(p / 2, Position(axes='XYZ', X=1, Y=5))
|
||||
|
||||
|
||||
|
||||
class MachineGCodeProcessingTests(unittest.TestCase):
|
||||
def test_linear_movement(self):
|
||||
m = Machine()
|
||||
test_str = '''; move in a 10mm square
|
||||
F100 M3 S1000 ; 0
|
||||
g1 x0 y10 ; 1
|
||||
g1 x10 y10 ; 2
|
||||
g1 x10 y0 ; 3
|
||||
g1 x0 y0 ; 4
|
||||
'''
|
||||
expected_pos = {
|
||||
'0': m.Position(),
|
||||
'1': m.Position(X=0, Y=10),
|
||||
'2': m.Position(X=10, Y=10),
|
||||
'3': m.Position(X=10, Y=0),
|
||||
'4': m.Position(X=0, Y=0),
|
||||
}
|
||||
#print("\n%r\n%r" % (m.mode, m.state))
|
||||
for line_text in str_lines(test_str):
|
||||
line = Line(line_text)
|
||||
if line.block:
|
||||
#print("\n%s" % line.block)
|
||||
m.process_block(line.block)
|
||||
# Assert possition change correct
|
||||
comment = line.comment.text
|
||||
if comment in expected_pos:
|
||||
self.assertEqual(m.pos, expected_pos[comment])
|
||||
#print("%r\n%r\npos=%r" % (m.mode, m.state, m.pos))
|
||||
|
||||
|
||||
#m = Machine()
|
||||
#
|
||||
#file = GCodeParser('part1.gcode')
|
||||
#for line in file.iterlines():
|
||||
# for (i, gcode) in enumerate(line.block.gcode):
|
||||
# if isinstance(gcode, GCodeArcMove):
|
||||
# arc = gcode
|
||||
# line_params = arc.line_segments(precision=0.0005)
|
||||
# for
|
@ -4,23 +4,18 @@ 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, '..'))
|
||||
# Add relative pygcode to path
|
||||
from testutils import add_pygcode_to_path, str_lines
|
||||
add_pygcode_to_path()
|
||||
|
||||
# Units under test
|
||||
from pygcode import 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(words.iter_words(block_str))
|
||||
w = list(words.text2words(block_str))
|
||||
# word length
|
||||
self.assertEqual(len(w), 3)
|
||||
# word values
|
||||
@ -30,7 +25,7 @@ class WordIterTests(unittest.TestCase):
|
||||
|
||||
def test_iter2(self):
|
||||
block_str = 'G02 X10.75 Y47.44 I-0.11 J-1.26 F70'
|
||||
w = list(words.iter_words(block_str))
|
||||
w = list(words.text2words(block_str))
|
||||
# word length
|
||||
self.assertEqual(len(w), 6)
|
||||
# word values
|
||||
|
25
tests/testutils.py
Normal file
25
tests/testutils.py
Normal file
@ -0,0 +1,25 @@
|
||||
import sys
|
||||
import os
|
||||
import inspect
|
||||
|
||||
import re
|
||||
|
||||
# Units Under Test
|
||||
_pygcode_in_path = False
|
||||
def add_pygcode_to_path():
|
||||
global _pygcode_in_path
|
||||
if not _pygcode_in_path:
|
||||
# Add pygcode (relative to this test-path) to the system path
|
||||
_this_path = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
|
||||
sys.path.insert(0, os.path.join(_this_path, '..'))
|
||||
|
||||
_pygcode_in_path = True
|
||||
|
||||
add_pygcode_to_path()
|
||||
|
||||
|
||||
# String Utilities
|
||||
def str_lines(text):
|
||||
"""Split given string into lines (ignore blank lines, and automagically strip)"""
|
||||
for match in re.finditer(r'\s*(?P<content>.*?)\s*\n', text):
|
||||
yield match.group('content')
|
Loading…
x
Reference in New Issue
Block a user