Merge pull request #3 from fragmuffin/develop

0.1.1 release
This commit is contained in:
Peter Boin 2017-07-30 21:03:39 +10:00 committed by GitHub
commit 24e92a8bc3
27 changed files with 1128 additions and 707 deletions

View File

@ -7,227 +7,18 @@ GCODE Parser for Python
Currently in development, ``pygcode`` is a low-level GCode interpreter
for python.
Installation
============
Using `PyPi <https://pypi.python.org/pypi/pydemia>`__:
Install using ``pip``
``pip install pygcode``
Usage
=====
Just brainstorming here...
Writing GCode
-------------
Writing gcode from python object instances to text
::
>>> from pygcode import *
>>> gcodes = [
... GCodeRapidMove(Z=5),
... GCodeStartSpindleCW(),
... GCodeRapidMove(X=10, Y=20),
... GCodeFeedRate(200),
... GCodeLinearMove(Z=-1.5),
... GCodeRapidMove(Z=5),
... GCodeStopSpindle(),
... ]
>>> print('\n'.join(str(g) for g in gcodes))
G00 Z5
M03
G00 X10 Y20
F200
G01 Z-1.5
G00 Z5
M05
or `download directly from PyPi <https://pypi.python.org/pypi/pygcode>`__
To plot along a lines of vectors, you could write...
Documentation
=============
::
>>> from pygcode import *
>>> from euclid import Vector3
>>> vectors = [
... Vector3(0, 0, 0),
... Vector3(10, 0, 0),
... Vector3(10, 20, 0),
... Vector3(10, 20, 3),
... Vector3(0, 20, 3),
... Vector3(0, 0, 3),
... Vector3(0, 0, 0)
... ]
>>> to_coords = lambda v: {'X': v.x, 'Y': v.y, 'Z': v.z}
>>> for v in vectors:
... print("%s" % GCodeLinearMove(**to_coords(v)))
G01 X0 Y0 Z0
G01 X10 Y0 Z0
G01 X10 Y20 Z0
G01 X10 Y20 Z3
G01 X0 Y20 Z3
G01 X0 Y0 Z3
G01 X0 Y0 Z0
Reading / Interpreting GCode
----------------------------
To read gcode from a file, utilise the ``Line`` class.
Each ``Line`` instance contains a ``Block`` and an optional ``Comment``.
The ``Block`` contains a list of gcodes you're after.
::
from pygcode import Line
with open('part.gcode', 'r') as fh:
for line_text in fh.readlines():
line = Line(line_text)
print(line) # will print the line (with cosmetic changes)
line.block.gcodes # is your list of gcodes
line.block.modal_params # are all parameters not assigned to a gcode, assumed to be motion modal parameters
if line.comment:
line.comment.text # your comment text
To elaborate, here are some line examples
::
>>> from pygcode import Line
>>> line = Line('G01 x1 y2 f100 s1000 ; blah')
>>> print(line)
G01 X1 Y2 F100 S1000 ; blah
>>> print(line.block)
G01 X1 Y2 F100 S1000
>>> print(line.comment)
; blah
>>> line = Line('G0 x1 y2 (foo) f100 (bar) s1000')
>>> print(line)
G00 X1 Y2 F100 S1000 (foo. bar)
>>> print(line.comment)
(foo. bar)
Interpreting what a line of gcode does depends on the machine it's running on,
and also that machine's state (or 'mode')
The simple line of a rapid move to ``x=10, y=10`` may be ``G00 X10 Y10``.
However, if the machine in question is in "Incremental Motion" mode ``G91`` then
the machine will only end up at ``x=10, y=10`` if it started at ``x=0, y=0``
So, GCode interpretation is done via a virtual machine:
::
>>> from pygcode import Machine, GCodeRapidMove
>>> m = Machine()
>>> m.pos
<Position: X0 Y0 Z0>
>>> g = GCodeRapidMove(X=10, Y=20)
>>> m.process_gcodes(g)
>>> m.pos
<Position: X10 Y20 Z0>
>>> m.process_gcodes(g)
>>> m.pos
<Position: X10 Y20 Z0> # same position; machine in absolute mode
>>> m.mode.distance
<GCodeAbsoluteDistanceMode: G90> # see
>>> m.process_gcodes(GCodeIncrementalDistanceMode())
>>> m.process_gcodes(g) # same gcode as above
>>> m.pos
<Position: X20 Y40 Z0>
all valid ``m.mode`` attributes can be found with ``from pygcode.gcodes import MODAL_GROUP_MAP; MODAL_GROUP_MAP.keys()``
Also note that the order codes are interpreted is important.
For example, the following code is WRONG
::
from pygcode import Machine, Line
m = Machine()
line = Line('G0 x10 y10 G91')
m.process_gcodes(*line.block.gcodes) # WRONG!
This will process the movement to ``x=10, y=10``, and **then** it will change the
distance mode to *Incremental*... there are 2 ways to do this correctly.
- ``m.process_gcodes(*sorted(line.block.gcodes))``, or simply
- ``m.process_block(line.block)``
sorting a list of gcodes will sort them in execution order (as specified by
`LinuxCNC's order of execution <http://linuxcnc.org/docs/html/gcode/overview.html#_g_code_order_of_execution>`__).
``process_block`` does this automatically.
If you need to process & change one type of gcode (usually a movement),
you must split a list of gcodes into those executed before, and after the one
in question.
::
from pygcode import GCodeRapidMove, GCodeLinearMove
from pygcode import Machine, Line, split_gcodes
m = Machine()
line = Line('M0 G0 x10 y10 G91')
(befores, (g,), afters) = split_gcodes(line.block.gcodes, (GCodeRapidMove, GCodeLinearMove))
m.process_gcodes(*sorted(befores))
if g.X is not None:
g.X += 100 # shift linear movements (rapid or otherwise)
m.process_gcodes(g)
m.process_gcodes(*sorted(afters))
For a more practical use of machines & interpreting gcode, have a look at
`pygcode-normalize.py <https://github.com/fragmuffin/pygcode/blob/master/scripts/pygcode-normalize.py>`__
At the time of writing this, that script converts arcs to linear codes, and
expands drilling cycles to basic movements (so my
`GRBL <https://github.com/gnea/grbl>`__ machine can understand them)
Development
===========
This library came from my own needs to interpret and convert erroneous
arcs to linear segments, and to expand canned drilling cycles, but also
as a means to *learn* GCode.
As such there is no direct plan for further development, however I'm
interested in what you'd like to use it for, and cater for that.
Generally, in terms of what to support, I'm following the lead of:
- `GRBL <https://github.com/gnea/grbl>`__ and
- `LinuxCNC <http://linuxcnc.org/>`__
More support will come with increased interest.
So that is... if you don't like what it does, or how it's documented, make some
noise in the `issue section <https://github.com/fragmuffin/pygcode/issues>`__.
if you get in early, you may get some free labour out of me ;)
Supported G-Codes
-----------------
All GCodes supported by `LinuxCNC <http://linuxcnc.org>`__ can be written, and
parsed by ``pygcode``.
Few GCodes are accurately interpreted by a virtual CNC ``Machine`` instance.
Supported movements are currently;
- linear movements
- arc movements
- canned drilling cycles
`Check out the wiki <https://github.com/fragmuffin/pygcode/wiki>`__ for documentation.

View File

@ -1,102 +0,0 @@
# Notes on deployment
How I deployed this package (mainly just notes for myself)
Method based on the articles:
* http://peterdowns.com/posts/first-time-with-pypi.html and
* https://hynek.me/articles/sharing-your-labor-of-love-pypi-quick-and-dirty/
## Pre-requisites
```
pip install -U "pip>=1.4" "setuptools>=0.9" "wheel>=0.21" twine
```
## PyPi rc
`cat ~/.pypirc`
```
[distutils]
index-servers =
prod
test
[prod]
repository = https://upload.pypi.org/legacy/
username=FraggaMuffin
password=secret
[test]
repository=https://test.pypi.org/legacy/
username=FraggaMuffin
password=secret
```
`chmod 600 ~/.pypirc`
## Building
```
rm -rf build/
python setup.py sdist bdist_wheel
```
### Test Build (sdist)
**Python 2.x**
```
rmvirtualenv 27-test
mkvirtualenv 27-test
$WORKON_HOME/27-test/bin/pip install dist/pygcode-0.1.0.tar.gz
$WORKON_HOME/27-test/bin/python
>>> import pygcode
>>> pygcode.Line('g1 x2 y3 m3 s1000 f100').block.gcodes # or whatever
```
**Python 3.x**
```
rmvirtualenv 35-test
mkvirtualenv -p $(which python3) 35-test
$WORKON_HOME/35-test/bin/pip install dist/pygcode-0.1.0.tar.gz
$WORKON_HOME/35-test/bin/python
>>> import pygcode
>>> pygcode.Line('g1 x2 y3 m3 s1000 f100').block.gcodes # or whatever
```
### Test Build (wheel)
similar to above, but the `pip` call references `pygcode-0.1.0-py2.py3-none-any.whl` instead
make sure to `rmvirtualenv` to ensure `pygcode` is uninstalled from virtual environment
## Upload to PyPi Test server
`twine upload -r test dist/pygcode-0.1.0*`
Then another round of testing, where `pip` call is:
`$WORKON_HOME/<envname>/bin/pip install -i https://testpypi.python.org/pypi pygcode`
## Upload to PyPy server
all good!? sweet :+1: time to upload to 'production'
`twine upload -r prod dist/pygcode-0.1.0*`
and final tests with simply:
`$WORKON_HOME/<envname>/bin/pip install pygcode`

190
deployment/README.md Normal file
View File

@ -0,0 +1,190 @@
# Notes on deployment
How I deploy this package.
For anyone reading, this readme and all files in this folder are mainly just
notes for myself; they have little to do with pygcode itself.
However, if you're interested in deploying your own PyPi package, then hopefully
this can help.
Method based on the articles:
* http://peterdowns.com/posts/first-time-with-pypi.html and
* https://hynek.me/articles/sharing-your-labor-of-love-pypi-quick-and-dirty/
Deployment also heavily uses the `./deploy.sh` script in this folder.
At this time, running `./deploy.sh --help` displays:
```
Usage: ./deploy.sh {build|test|and so on ...}
This script is to maintain a consistent method of deployment and testing.
Deployment target: pygcode 0.1.1.dev0
Arguments:
Setup:
setup: Installs packages & sets up environment (requires sudo)
Compiling:
build: Execute setup to build packages (populates ../dist/)
creates both 'sdist' and 'wheel' distrobutions.
Virtual Environments:
rmenv py# Remove virtual environment
remkenv py# Remove, then create re-create virtual environment
envprereq py# install environment prerequisites (official PyPi)
Deploy:
deploy test Upload to PyPi test server
deploy prod Upload to PyPi (official)
Install:
install sdist py# Install from local sdist
install wheel py# Install from local wheel
install pypitest py# Install from PyPi test server
install pypi py# Install from PyPi (official)
Testing:
test dev py# Run tests on local dev in a virtual env
test installed py# Run tests on installed library in virtual env
Help:
-h | --help display this help message
py#: when referenced above means
'py2' for Python 2.7.12
'py3' for Python 3.5.2
```
# PyPi deployment
## Install Required Tools
`./deploy.sh setup`
## PyPi rc
`cat ~/.pypirc`
```
[distutils]
index-servers =
prod
test
[prod]
repository = https://upload.pypi.org/legacy/
username=FraggaMuffin
password=secret
[test]
repository=https://test.pypi.org/legacy/
username=FraggaMuffin
password=secret
```
`chmod 600 ~/.pypirc`
## Build and Test `sdist` and `wheel`
**Build**
```
./deploy.sh build
```
**Test `sdist`**
```
# Python 2.x
./deploy.sh remkenv py2
./deploy.sh envprereq py2
./deploy.sh install sdist py2
./deploy.sh test installed py2
# Python 3.x
./deploy.sh remkenv py3
./deploy.sh envprereq py3
./deploy.sh install sdist py3
./deploy.sh test installed py3
```
**Test `wheel`**
```
# Python 2.x
./deploy.sh remkenv py2
./deploy.sh install wheel py2
./deploy.sh test installed py2
# Python 3.x
./deploy.sh remkenv py3
./deploy.sh install wheel py3
./deploy.sh test installed py3
```
## Upload to PyPi Test server
```
./deploy.sh deploy test
```
**Test**
```
# Python 2.x
./deploy.sh remkenv py2
./deploy.sh envprereq py2
./deploy.sh install pypitest py2
./deploy.sh test installed py2
# Python 3.x
./deploy.sh remkenv py3
./deploy.sh envprereq py3
./deploy.sh install pypitest py3
./deploy.sh test installed py3
```
have a look at:
https://testpypi.python.org/pypi/pygcode
to make sure it's sane
## Upload to PyPy server
all good!? sweet :+1: time to upload to 'production'
```
./deploy.sh deploy prod
```
**Test**
```
# Python 2.x
./deploy.sh remkenv py2
./deploy.sh install pypi py2
./deploy.sh test installed py2
# Python 3.x
./deploy.sh remkenv py3
./deploy.sh install pypi py3
./deploy.sh test installed py3
```
have a look at:
https://pypi.python.org/pypi/pygcode
to make sure it's sane
# Deployment in Git
merge deployed branch to `master`
```
git tag ${version} -m "<change description>"
git push --tags origin master
```
have a look at the [releases page](https://github.com/fragmuffin/pygcode/releases) and it should be there.
tadaaaaaa!... go to sleep; it's probably late

227
deployment/deploy.sh Executable file
View File

@ -0,0 +1,227 @@
#!/usr/bin/env bash
source $(which virtualenvwrapper.sh)
function pyver() {
# write python version to stdout
$1 -c 'import sys; print("{}.{}.{}".format(*sys.version_info))'
}
function lib_ver() {
# Get library vesion number
pushd ../src > /dev/null
python -c 'import pygcode; print(pygcode.__version__)'
popd > /dev/null
}
function lib_prereq() {
pushd .. > /dev/null
python -c 'from setup import INSTALL_REQUIRES ; print(" ".join(INSTALL_REQUIRES))'
popd > /dev/null
}
# ============ Local Parameters
LIB_NAME=pygcode
LIB_VER=$(lib_ver)
# ============ Help Text
function show_help() {
[ "$@" ] && echo "$@"
cat << EOF
Usage: ./${0##*/} {build|test|and so on ...}
This script is to maintain a consistent method of deployment and testing.
Deployment target: ${LIB_NAME} ${LIB_VER}
Arguments:
Setup:
setup: Installs packages & sets up environment (requires sudo)
Compiling:
build: Execute setup to build packages (populates ../dist/)
creates both 'sdist' and 'wheel' distrobutions.
Virtual Environments:
rmenv py# Remove virtual environment
remkenv py# Remove, then create re-create virtual environment
envprereq py# install environment prerequisites (official PyPi)
Deploy:
deploy test Upload to PyPi test server
deploy prod Upload to PyPi (official)
Install:
install sdist py# Install from local sdist
install wheel py# Install from local wheel
install pypitest py# Install from PyPi test server
install pypi py# Install from PyPi (official)
Testing:
test dev py# Run tests on local dev in a virtual env
test installed py# Run tests on installed library in virtual env
Help:
-h | --help display this help message
py#: when referenced above means
'py2' for Python $(pyver python2)
'py3' for Python $(pyver python3)
EOF
}
# ============ Commands
function setup() {
# Install pre-requisite tooling
sudo pip install -U "pip>=1.4" "setuptools>=0.9" "wheel>=0.21" twine
}
function build() {
# Run setup.py to build sdist and wheel distrobutions
pushd ..
rm -rf build/
python setup.py sdist bdist_wheel
popd
}
function rmenv() {
# Remove virtual environment
set_venv_variables $1
rmvirtualenv ${env_name}
}
function remkenv() {
# Remove virtual environment, then re-create it from scratch
set_venv_variables $1
rmvirtualenv ${env_name}
mkvirtualenv --python=${python_bin} ${env_name}
}
function envprereq() {
set_venv_variables $1
workon ${env_name}
${env_pip_bin} install $(lib_prereq)
deactivate
}
function deploy() {
# Deploy compiled distributions to the test|prod server
_deployment_env=$1
pushd ..
twine upload -r ${_deployment_env} dist/${LIB_NAME}-${LIB_VER}*
popd
}
function install() {
# Install library from a variety of sources
_install_type=$1
_env_key=$2
set_venv_variables ${_env_key}
workon ${env_name}
case "${_install_type}" in
sdist)
# Install from locally compiled 'sdist' file
${env_pip_bin} install ../dist/${LIB_NAME}-${LIB_VER}.tar.gz
;;
wheel)
# Install from locally compiled 'wheel' file
${env_pip_bin} install ../dist/${LIB_NAME}-${LIB_VER}-py2.py3-none-any.whl
;;
pypitest)
# Install from PyPi test server
${env_pip_bin} install -i https://testpypi.python.org/pypi ${LIB_NAME}
;;
pypi)
# Install from official PyPi server
${env_pip_bin} install ${LIB_NAME}
;;
*)
echo invalid install type: \"${_install_type}\" >&2
exit 1
;;
esac
deactivate
}
function run_test() {
# Run tests
_test_scope=$1
_env_key=$2
set_venv_variables ${_env_key}
case "${_test_scope}" in
dev)
export PYGCODE_TESTSCOPE=local
;;
installed)
export PYGCODE_TESTSCOPE=installed
;;
*)
echo invalid test scope: \"${_test_scope}\" >&2
exit 1
;;
esac
pushd ../tests
workon ${env_name}
${env_python_bin} -m unittest discover -s . -p 'test_*.py'
deactivate
popd
}
# ============ Utilities
function set_venv_variables() {
# on successful exit, environment variables are set:
# env_name = virtual environment's name
# env_pip_bin = environment's pip binary
# env_python_bin = environment's python binary
# python_bin = python binary in host environment
_env_key=$1
env_name=${LIB_NAME}_${_env_key}
env_pip_bin=${WORKON_HOME}/${env_name}/bin/pip
env_python_bin=${WORKON_HOME}/${env_name}/bin/python
case "${_env_key}" in
py2)
python_bin=$(which python2)
;;
py3)
python_bin=$(which python3)
;;
*)
echo invalid environment key: \"${_env_key}\" >&2
exit 1
;;
esac
}
# ============ Option parsing
case "$1" in
# Show help on request
-h|--help)
show_help
exit 0
;;
# Valid Commands
setup) setup ;;
build) build ;;
rmenv) rmenv $2 ;;
remkenv) remkenv $2 ;;
envprereq) envprereq $2 ;;
deploy) deploy $2 ;;
install) install $2 $3 ;;
test) run_test $2 $3 ;;
# otherwise... show help
*)
show_help >&2
exit 1
;;
esac
echo ./${0##*/} completed successfully

Binary file not shown.

Binary file not shown.

BIN
dist/pygcode-0.1.1-py2.py3-none-any.whl vendored Normal file

Binary file not shown.

BIN
dist/pygcode-0.1.1.tar.gz vendored Normal file

Binary file not shown.

6
media/README.md Normal file
View File

@ -0,0 +1,6 @@
## `arc-linearize-method-*.png`
Screenshots of arc linearizing methods for the `pygcode-norm` script.
credit to _nraynaud_ for [this awesome online gcode interpreter](https://nraynaud.github.io/webgcode/)

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

245
scripts/pygcode-crop Executable file
View File

@ -0,0 +1,245 @@
#!/usr/bin/env python
# Script to remove commands of a gcode file before and after the given range.
# All gcodes before the given line will be replaced with equivalent rapid
# movements to get to the right position. Initial rapid movement will first move
# to the maximum Z value used in the removed portion, then move to XY,
# then move down to the correct Z.
import argparse
import re
from copy import copy
for pygcode_lib_type in ('installed_lib', 'relative_lib'):
try:
# pygcode
from pygcode import Machine, Mode
from pygcode import Line, Comment
from pygcode import GCodePlaneSelect, GCodeSelectXYPlane
from pygcode import GCodeRapidMove
except ImportError:
import sys, os, inspect
# 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, '..', 'src'))
if pygcode_lib_type == 'installed_lib':
continue # import was attempted before sys.path addition. retry import
raise # otherwise the raised ImportError is a genuine problem
break
# =================== Command Line Arguments ===================
# --- Types
def range_type(value):
"""
Return (is_first, is_last) such that is_first(n, pos) will return True if
the gcode's current line is the first to be cropped, and similarly for the
last line.
:param value: string given as argument
:return: (is_first, is_last) callables
"""
# All files are cropped from one line, to another, identifying these lines is
# done via the [first] and [last] cropping criteria.
# - Line numbers (parameters: n)
# - Machine position (parameters: a,b,c,x,y,z)
# Comparisons, all with <letter><comparison><number>
# - = (or ==) equal to
# - != not equal to
# - < less than
# - <= less than or equal to
# - > greater than
# - >= greater than or equal to
match = re.search(r'^(?P<first>[^:]*):(?P<last>[^:]*)$', value)
if not match:
raise argparse.ArgumentTypeError("'%s' range invalid format" % value)
def _cmp(cmp_str):
"""
Convert strings like
'x>10.3'
into a callable equivalent to:
lambda n, pos: pos.X > 10.3
where:
n is the file's line number
pos is the machine's position (Position) instance
:param cmp_str: comparison string of the form: '<param><cmp><value>'
:return: callable
"""
CMP_MAP = {
'=': lambda a, b: a == b,
'==': lambda a, b: a == b,
'!=': lambda a, b: a != b,
'<': lambda a, b: a < b,
'<=': lambda a, b: a <= b,
'>': lambda a, b: a > b,
'>=': lambda a, b: a >= b,
}
# split comparison into (param, cmp, value)
m = re.search(
r'''^\s*
(
(?P<param>[abcnxyz])?\s* # parameter
(?P<cmp>(==?|!=|<=?|>=?)) # comparison
)?\s* # parameter & comparison defaults to "n="
(?P<value>-?\d+(\.\d+)?)\s*
$''',
cmp_str, re.IGNORECASE | re.MULTILINE | re.VERBOSE
)
if not m:
raise argparse.ArgumentTypeError("'%s' range comparison invalid" % cmp_str)
(param, cmp, val) = (
(m.group('param') or 'N').upper(), # default to 'N'
m.group('cmp') or '=', # default to '='
m.group('value')
)
# convert to lambda
if param == 'N':
if float(val) % 1:
raise argparse.ArgumentTypeError("'%s' line number must be an integer" % cmp_str)
return lambda n, pos: CMP_MAP[cmp](n, float(val))
else:
return lambda n, pos: CMP_MAP[cmp](getattr(pos, param), float(val))
def _cmp_group(group_str, default):
"""
Split given group_str by ',' and return callable that will return True
only if all comparisons are true.
So if group_str is:
x>=10.4,z>1
return will be a callable equivalent to:
lambda n, pos: (pos.X >= 10.4) and (pos.Z > 1)
(see _cmp for more detail)
:param group_str: string of _cmp valid strings delimited by ','s
:param default: default callable if group_str is falsey
:return: callable that returns True if all cmp's are true
"""
if not group_str:
return default
cmp_list = []
for cmp_str in group_str.split(','):
cmp_list.append(_cmp(cmp_str))
return lambda n, pos: all(x(n, pos) for x in cmp_list)
is_first = _cmp_group(match.group('first'), lambda n, pos: True)
is_last = _cmp_group(match.group('last'), lambda n, pos: False)
return (is_first, is_last)
# --- Defaults
# --- Create Parser
parser = argparse.ArgumentParser(
description="Remove gcode before and after given 'from' and 'to' conditions.",
epilog="Range Format:"
"""
range must be of the format:
[condition[,condition...]]:[condition[,condition...]]
the first condition(s) are true for the first line included in the cropped area
the second set are true for the first line excluded after the cropped area
Conditions:
each condition is of the format:
{variable}{operation}{number}
or, more specifically:
[[{a,b,c,n,x,y,z}]{=,!=,<,<=,>,>=}]{number}
Condition Variables:
n - file's line number
a|b|c - machine's angular axes
x|y|z - machine's linear axes
Example Ranges:
"100:200" will crop lines 100-199 (inclusive)
"z<=-2:" will isolate everything after the machine crosses z=-2
"x>10,y>10:n>=123" starts cropped area where both x and y exceed 10,
but only before line 123
Limitations:
Only takes points from start and finish of a gcode operation, so a line
through a condition region, or an arc that crosses a barrier will NOT
trigger the start or stop of cropping.
Probe alignment operations will not change virtual machine's position.
""",
formatter_class=argparse.RawTextHelpFormatter,
)
parser.add_argument(
'infile', type=argparse.FileType('r'),
help="gcode file to crop",
)
parser.add_argument(
'range', type=range_type,
help="file range to crop, format [from]:[to] (details below)",
)
# --- Parse Arguments
args = parser.parse_args()
# =================== Cropping File ===================
# --- Machine
class NullMachine(Machine):
MODE_CLASS = type('NullMode', (Mode,), {'default_mode': ''})
machine = NullMachine()
pre_crop = True
post_crop = False
(is_first, is_last) = args.range
for (i, line_str) in enumerate(args.infile.readlines()):
line = Line(line_str)
# remember machine's state before processing the current line
old_machine = copy(machine)
machine.process_block(line.block)
if pre_crop:
if is_first(i + 1, machine.pos):
# First line inside cropping range
pre_crop = False
# Set machine's accumulated mode (from everything that's been cut)
mode_str = str(old_machine.mode)
if mode_str:
print(Comment("machine mode before cropping"))
print(mode_str)
# Getting machine's current (modal) selected plane
plane = old_machine.mode.plane_selection
if not isinstance(plane, GCodePlaneSelect):
plane = GCodeSelectXYPlane() # default to XY plane
# --- position machine before first cropped line
print(Comment("traverse into position, up, over, and down"))
# rapid move to Z (maximum Z the machine has experienced thus far)
print(GCodeRapidMove(**{
plane.normal_axis: getattr(old_machine.abs_range_max, plane.normal_axis),
}))
# rapid move to X,Y
print(GCodeRapidMove(**dict(
(k, v) for (k, v) in old_machine.pos.values.items()
if k in plane.plane_axes
)))
# rapid move to Z (machine.pos.Z)
print(GCodeRapidMove(**{
plane.normal_axis: getattr(old_machine.pos, plane.normal_axis),
}))
print('')
if (pre_crop, post_crop) == (False, False):
if is_last(i + 1, machine.pos):
# First line **outside** the area being cropped
# (ie: this line won't be output)
post_crop = True # although, irrelevant because...
break
else:
# inside cropping area
print(line)

View File

@ -23,13 +23,13 @@ for pygcode_lib_type in ('installed_lib', 'relative_lib'):
from pygcode.transform import linearize_arc, simplify_canned_cycle
from pygcode.transform import ArcLinearizeInside, ArcLinearizeOutside, ArcLinearizeMid
from pygcode.gcodes import _subclasses
from pygcode.utils import omit_redundant_modes
from pygcode import utils
except ImportError:
import sys, os, inspect
# 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, '..'))
sys.path.insert(0, os.path.join(_this_path, '..', 'src'))
if pygcode_lib_type == 'installed_lib':
continue # import was attempted before sys.path addition. retry import
raise # otherwise the raised ImportError is a genuine problem
@ -37,6 +37,43 @@ for pygcode_lib_type in ('installed_lib', 'relative_lib'):
# =================== Command Line Arguments ===================
# --- Types
def arc_lin_method_type(value):
"""
:return: {Word('G2'): <linearize method class>, ... }
"""
ARC_LIN_CLASS_MAP = {
'i': ArcLinearizeInside,
'o': ArcLinearizeOutside,
'm': ArcLinearizeMid,
}
value_dict = defaultdict(lambda: ArcLinearizeMid)
if value:
match = re.search(r'^(?P<g2>[iom])(,(?P<g3>[iom]))?$', value, re.IGNORECASE)
if not match:
raise argparse.ArgumentTypeError("invalid format '%s'" % value)
value_dict = {
Word('g2'): ARC_LIN_CLASS_MAP[match.group('g2')],
Word('g3'): ARC_LIN_CLASS_MAP[match.group('g2')],
}
if match.group('g3'):
value_dict[Word('g3')] = ARC_LIN_CLASS_MAP[match.group('g3')]
return value_dict
def word_list_type(value):
"""
:return: [Word('G73'), Word('G89'), ... ]
"""
canned_code_words = set()
for word_str in re.split(r'\s*,\s*', value):
canned_code_words.add(Word(word_str))
return canned_code_words
# --- Defaults
DEFAULT_PRECISION = 0.005 # mm
DEFAULT_MACHINE_MODE = 'G0 G54 G17 G21 G90 G94 M5 M9 T0 F0 S0'
@ -44,16 +81,24 @@ DEFAULT_ARC_LIN_METHOD = 'm'
DEFAULT_CANNED_CODES = ','.join(str(w) for w in sorted(c.word_key for c in _subclasses(GCodeCannedCycle) if c.word_key))
# --- Create Parser
parser = argparse.ArgumentParser(description='Normalize gcode for machine consistency using different CAM software')
parser = argparse.ArgumentParser(
description="Normalize gcode for machine consistency when using different CAM software"
)
parser.add_argument(
'infile', type=argparse.FileType('r'), nargs=1,
'infile', type=argparse.FileType('r'),
help="gcode file to normalize",
)
parser.add_argument(
'--precision', '-p', dest='precision', type=float, default=DEFAULT_PRECISION,
help="maximum positional error when generating gcodes (eg: arcs to lines) "
"(default: %g)" % DEFAULT_PRECISION,
'--singles', '-s', dest='singles',
action='store_const', const=True, default=False,
help="only output one command per gcode line",
)
parser.add_argument(
'--full', '-f', dest='full',
action='store_const', const=True, default=False,
help="output full commands, any modal parameters will be acompanied with "
"the fully qualified gcode command",
)
# Machine
@ -67,7 +112,7 @@ group = parser.add_argument_group(
"Arc Linearizing",
"Converting arcs (G2/G3 codes) into linear interpolations (G1 codes) to "
"aproximate the original arc. Indistinguishable from an original arc when "
"--precision is set low enough."
"--arc_precision is set low enough."
)
group.add_argument(
'--arc_linearize', '-al', dest='arc_linearize',
@ -75,12 +120,25 @@ group.add_argument(
help="convert G2,G3 commands to a series of linear interpolations (G1 codes)",
)
group.add_argument(
'--arc_lin_method', '-alm', dest='arc_lin_method', default=DEFAULT_ARC_LIN_METHOD,
'--arc_lin_method', '-alm', dest='arc_lin_method',
type=arc_lin_method_type, default=DEFAULT_ARC_LIN_METHOD,
help="Method of linearizing arcs, i=inner, o=outer, m=mid. List 2 "
"for <ccw>,<cw>, eg 'i,o'. 'i' is equivalent to 'i,i'. "
"for <cw>,<ccw>, eg 'i,o'. 'i' is equivalent to 'i,i'. "
"(default: '%s')" % DEFAULT_ARC_LIN_METHOD,
metavar='{i,o,m}[,{i,o,m}]',
)
group.add_argument(
'--arc_precision', '-alp', dest='arc_precision', type=float, default=DEFAULT_PRECISION,
help="maximum positional error when creating linear interpolation codes "
"(default: %g)" % DEFAULT_PRECISION,
)
#parser.add_argument(
# '--arc_alignment', '-aa', dest='arc_alignment', type=str, choices=('XYZ','IJK','R'),
# default=None,
# help="enforce precision on arcs, if XYZ the destination is altered to match the radius"
# "if IJK or R then the arc'c centre point is moved to assure precision",
#)
# Canned Cycles
group = parser.add_argument_group(
@ -94,56 +152,43 @@ group.add_argument(
help="Expand canned cycles into basic linear movements, and pauses",
)
group.add_argument(
'---canned_codes', '-cc', dest='canned_codes', default=DEFAULT_CANNED_CODES,
'--canned_codes', '-cc', dest='canned_codes',
type=word_list_type, default=DEFAULT_CANNED_CODES,
help="List of canned gcodes to expand, (default is '%s')" % DEFAULT_CANNED_CODES,
)
#parser.add_argument(
# '--arc_alignment', '-aa', dest='arc_alignment', type=str, choices=('XYZ','IJK','R'),
# default=None,
# help="enforce precision on arcs, if XYZ the destination is altered to match the radius"
# "if IJK or R then the arc'c centre point is moved to assure precision",
#)
# Removing non-functional content
group = parser.add_argument_group(
"Removing Content",
"options for the removal of content"
)
group.add_argument(
'--rm_comments', '-rc', dest='rm_comments',
action='store_const', const=True, default=False,
help="remove all comments (non-functional)",
)
group.add_argument(
'--rm_blanks', '-rb', dest='rm_blanks',
action='store_const', const=True, default=False,
help="remove all empty lines (non-functional)",
)
group.add_argument(
'--rm_whitespace', '-rws', dest='rm_whitespace',
action='store_const', const=True, default=False,
help="remove all whitespace from gcode blocks (non-functional)",
)
group.add_argument(
'--rm_gcodes', '-rmg', dest='rm_gcodes',
type=word_list_type, default=[],
help="remove gcode (and it's parameters) with words in the given list "
"(eg: M6,G43) (note: only works for modal params with --full)",
)
# --- Parse Arguments
args = parser.parse_args()
# --- Manually Parsing : Arc Linearizing Method
# args.arc_lin_method = {Word('G2'): <linearize method class>, ... }
ARC_LIN_CLASS_MAP = {
'i': ArcLinearizeInside,
'o': ArcLinearizeOutside,
'm': ArcLinearizeMid,
}
arc_lin_method_regex = re.compile(r'^(?P<g2>[iom])(,(?P<g3>[iom]))?$', re.I)
if args.arc_lin_method:
match = arc_lin_method_regex.search(args.arc_lin_method)
if not match:
raise RuntimeError("parameter for --arc_lin_method is invalid: '%s'" % args.arc_lin_method)
# changing args.arc_lin_method (because I'm a fiend)
args.arc_lin_method = {}
args.arc_lin_method[Word('g2')] = ARC_LIN_CLASS_MAP[match.group('g2')]
if match.group('g3'):
args.arc_lin_method[Word('g3')] = ARC_LIN_CLASS_MAP[match.group('g3')]
else:
args.arc_lin_method[Word('g3')] = args.arc_lin_method[Word('g2')]
else:
# FIXME: change default to ArcLinearizeMid (when it's working)
args.arc_lin_method = defaultdict(lambda: ArcLinearizeMid) # just to be sure
# --- Manually Parsing : Canned Codes
# args.canned_codes = [Word('G73'), Word('G89'), ... ]
canned_code_words = set()
for word_str in re.split(r'\s*,\s*', args.canned_codes):
canned_code_words.add(Word(word_str))
args.canned_codes = canned_code_words
# =================== Create Virtual CNC Machine ===================
class MyMode(Mode):
default_mode = args.machine_mode
@ -154,6 +199,46 @@ class MyMachine(Machine):
machine = MyMachine()
# =================== Utility Functions ===================
omit_redundant_modes = utils.omit_redundant_modes
if args.full:
omit_redundant_modes = lambda gcode_iter: gcode_iter # bypass
def write(gcodes, modal_params=tuple(), comment=None):
"""
Write to output, while enforcing the flags:
args.singles
args.rm_comments
args.rm_blanks
args.rm_whitespace
:param obj: Line, Block, GCode or Comment instance
"""
assert not(args.full and modal_params), "with full specified, this should never be called with modal_params"
if args.singles and len(gcodes) > 1:
for g in sorted(gcodes):
write([g], comment=comment)
else:
# remove comments
if args.rm_comments:
comment = None
# remove particular gcodes
if args.rm_gcodes:
gcodes = [g for g in gcodes if g.word not in args.rm_gcodes]
# Convert to string & write to file (or stdout)
block_str = ' '.join(str(x) for x in (list(gcodes) + list(modal_params)))
if args.rm_whitespace:
block_str = re.sub(r'\s', '', block_str)
line_list = []
if block_str:
line_list.append(block_str)
if comment:
line_list.append(str(comment))
line_str = ' '.join(line_list)
if line_str or not args.rm_blanks:
print(line_str)
def gcodes2str(gcodes):
return ' '.join("%s" % g for g in gcodes)
@ -167,25 +252,26 @@ def split_and_process(gcode_list, gcode_class, comment):
:param comment: Comment instance, or None
"""
(befores, (g,), afters) = split_gcodes(gcode_list, gcode_class)
# print & process those before gcode_class instance
# write & process those before gcode_class instance
if befores:
print(gcodes2str(befores))
write(befores)
machine.process_gcodes(*befores)
# yield, then process gcode_class instance
yield g
machine.process_gcodes(g)
# print & process those after gcode_class instance
# write & process those after gcode_class instance
if afters:
print(gcodes2str(afters))
write(afters)
machine.process_gcodes(*afters)
# print comment (if given)
# write comment (if given)
if comment:
print(str(line.comment))
write([], comment=line.comment)
# =================== Process File ===================
for line_str in args.infile[0].readlines():
for line_str in args.infile.readlines():
line = Line(line_str)
# Effective G-Codes:
@ -194,7 +280,7 @@ for line_str in args.infile[0].readlines():
if args.arc_linearize and any(isinstance(g, GCodeArcMove) for g in effective_gcodes):
with split_and_process(effective_gcodes, GCodeArcMove, line.comment) as arc:
print(Comment("linearized arc: %r" % arc))
write([], comment=Comment("linearized arc: %r" % arc))
linearize_params = {
'arc_gcode': arc,
'start_pos': machine.pos,
@ -202,15 +288,15 @@ for line_str in args.infile[0].readlines():
'method_class': args.arc_lin_method[arc.word],
'dist_mode': machine.mode.distance,
'arc_dist_mode': machine.mode.arc_ijk_distance,
'max_error': args.precision,
'max_error': args.arc_precision,
'decimal_places': 3,
}
for linear_gcode in omit_redundant_modes(linearize_arc(**linearize_params)):
print(linear_gcode)
write([linear_gcode])
elif args.canned_expand and any((g.word in args.canned_codes) for g in effective_gcodes):
with split_and_process(effective_gcodes, GCodeCannedCycle, line.comment) as canned:
print(Comment("expanded: %r" % canned))
write([], comment=Comment("expanded: %r" % canned))
simplify_canned_params = {
'canned_gcode': canned,
'start_pos': machine.pos,
@ -219,8 +305,11 @@ for line_str in args.infile[0].readlines():
'axes': machine.axes,
}
for simplified_gcode in omit_redundant_modes(simplify_canned_cycle(**simplify_canned_params)):
print(simplified_gcode)
write([simplified_gcode])
else:
print(str(line))
if args.full:
write(effective_gcodes, comment=line.comment)
else:
write(line.block.gcodes, modal_params=line.block.modal_params, comment=line.comment)
machine.process_block(line.block)

View File

@ -26,9 +26,14 @@ CLASSIFIERS = [
"Topic :: Scientific/Engineering",
]
INSTALL_REQUIRES = [
'argparse', # Python command-line parsing library
'euclid3', # 2D and 3D vector, matrix, quaternion and geometry module.
'six', # Python 2 and 3 compatibility utilities
]
SCRIPTS = [
'scripts/pygcode-norm',
'scripts/pygcode-crop',
]
###################################################################
@ -78,4 +83,5 @@ if __name__ == "__main__":
zip_safe=False,
classifiers=CLASSIFIERS,
install_requires=INSTALL_REQUIRES,
scripts=SCRIPTS,
)

View File

@ -1,6 +1,6 @@
Metadata-Version: 1.1
Name: pygcode
Version: 0.1.0
Version: 0.1.1
Summary: Basic g-code parser, interpreter, and encoder library.
Home-page: https://github.com/fragmuffin/pygcode
Author: Peter Boin
@ -15,230 +15,21 @@ Description: =======
Currently in development, ``pygcode`` is a low-level GCode interpreter
for python.
Installation
============
Using `PyPi <https://pypi.python.org/pypi/pydemia>`__:
Install using ``pip``
``pip install pygcode``
Usage
=====
Just brainstorming here...
Writing GCode
-------------
Writing gcode from python object instances to text
::
>>> from pygcode import *
>>> gcodes = [
... GCodeRapidMove(Z=5),
... GCodeStartSpindleCW(),
... GCodeRapidMove(X=10, Y=20),
... GCodeFeedRate(200),
... GCodeLinearMove(Z=-1.5),
... GCodeRapidMove(Z=5),
... GCodeStopSpindle(),
... ]
>>> print('\n'.join(str(g) for g in gcodes))
G00 Z5
M03
G00 X10 Y20
F200
G01 Z-1.5
G00 Z5
M05
or `download directly from PyPi <https://pypi.python.org/pypi/pygcode>`__
To plot along a lines of vectors, you could write...
Documentation
=============
::
>>> from pygcode import *
>>> from euclid import Vector3
>>> vectors = [
... Vector3(0, 0, 0),
... Vector3(10, 0, 0),
... Vector3(10, 20, 0),
... Vector3(10, 20, 3),
... Vector3(0, 20, 3),
... Vector3(0, 0, 3),
... Vector3(0, 0, 0)
... ]
>>> to_coords = lambda v: {'X': v.x, 'Y': v.y, 'Z': v.z}
>>> for v in vectors:
... print("%s" % GCodeLinearMove(**to_coords(v)))
G01 X0 Y0 Z0
G01 X10 Y0 Z0
G01 X10 Y20 Z0
G01 X10 Y20 Z3
G01 X0 Y20 Z3
G01 X0 Y0 Z3
G01 X0 Y0 Z0
Reading / Interpreting GCode
----------------------------
To read gcode from a file, utilise the ``Line`` class.
Each ``Line`` instance contains a ``Block`` and an optional ``Comment``.
The ``Block`` contains a list of gcodes you're after.
::
from pygcode import Line
with open('part.gcode', 'r') as fh:
for line_text in fh.readlines():
line = Line(line_text)
print(line) # will print the line (with cosmetic changes)
line.block.gcodes # is your list of gcodes
line.block.modal_params # are all parameters not assigned to a gcode, assumed to be motion modal parameters
if line.comment:
line.comment.text # your comment text
To elaborate, here are some line examples
::
>>> from pygcode import Line
>>> line = Line('G01 x1 y2 f100 s1000 ; blah')
>>> print(line)
G01 X1 Y2 F100 S1000 ; blah
>>> print(line.block)
G01 X1 Y2 F100 S1000
>>> print(line.comment)
; blah
>>> line = Line('G0 x1 y2 (foo) f100 (bar) s1000')
>>> print(line)
G00 X1 Y2 F100 S1000 (foo. bar)
>>> print(line.comment)
(foo. bar)
Interpreting what a line of gcode does depends on the machine it's running on,
and also that machine's state (or 'mode')
The simple line of a rapid move to ``x=10, y=10`` may be ``G00 X10 Y10``.
However, if the machine in question is in "Incremental Motion" mode ``G91`` then
the machine will only end up at ``x=10, y=10`` if it started at ``x=0, y=0``
So, GCode interpretation is done via a virtual machine:
::
>>> from pygcode import Machine, GCodeRapidMove
>>> m = Machine()
>>> m.pos
<Position: X0 Y0 Z0>
>>> g = GCodeRapidMove(X=10, Y=20)
>>> m.process_gcodes(g)
>>> m.pos
<Position: X10 Y20 Z0>
>>> m.process_gcodes(g)
>>> m.pos
<Position: X10 Y20 Z0> # same position; machine in absolute mode
>>> m.mode.distance
<GCodeAbsoluteDistanceMode: G90> # see
>>> m.process_gcodes(GCodeIncrementalDistanceMode())
>>> m.process_gcodes(g) # same gcode as above
>>> m.pos
<Position: X20 Y40 Z0>
all valid ``m.mode`` attributes can be found with ``from pygcode.gcodes import MODAL_GROUP_MAP; MODAL_GROUP_MAP.keys()``
Also note that the order codes are interpreted is important.
For example, the following code is WRONG
::
from pygcode import Machine, Line
m = Machine()
line = Line('G0 x10 y10 G91')
m.process_gcodes(*line.block.gcodes) # WRONG!
This will process the movement to ``x=10, y=10``, and **then** it will change the
distance mode to *Incremental*... there are 2 ways to do this correctly.
- ``m.process_gcodes(*sorted(line.block.gcodes))``, or simply
- ``m.process_block(line.block)``
sorting a list of gcodes will sort them in execution order (as specified by
`LinuxCNC's order of execution <http://linuxcnc.org/docs/html/gcode/overview.html#_g_code_order_of_execution>`__).
``process_block`` does this automatically.
If you need to process & change one type of gcode (usually a movement),
you must split a list of gcodes into those executed before, and after the one
in question.
::
from pygcode import GCodeRapidMove, GCodeLinearMove
from pygcode import Machine, Line, split_gcodes
m = Machine()
line = Line('M0 G0 x10 y10 G91')
(befores, (g,), afters) = split_gcodes(line.block.gcodes, (GCodeRapidMove, GCodeLinearMove))
m.process_gcodes(*sorted(befores))
if g.X is not None:
g.X += 100 # shift linear movements (rapid or otherwise)
m.process_gcodes(g)
m.process_gcodes(*sorted(afters))
For a more practical use of machines & interpreting gcode, have a look at
`pygcode-normalize.py <https://github.com/fragmuffin/pygcode/blob/master/scripts/pygcode-normalize.py>`__
At the time of writing this, that script converts arcs to linear codes, and
expands drilling cycles to basic movements (so my
`GRBL <https://github.com/gnea/grbl>`__ machine can understand them)
Development
===========
This library came from my own needs to interpret and convert erroneous
arcs to linear segments, and to expand canned drilling cycles, but also
as a means to *learn* GCode.
As such there is no direct plan for further development, however I'm
interested in what you'd like to use it for, and cater for that.
Generally, in terms of what to support, I'm following the lead of:
- `GRBL <https://github.com/gnea/grbl>`__ and
- `LinuxCNC <http://linuxcnc.org/>`__
More support will come with increased interest.
So that is... if you don't like what it does, or how it's documented, make some
noise in the `issue section <https://github.com/fragmuffin/pygcode/issues>`__.
if you get in early, you may get some free labour out of me ;)
Supported G-Codes
-----------------
All GCodes supported by `LinuxCNC <http://linuxcnc.org>`__ can be written, and
parsed by ``pygcode``.
Few GCodes are accurately interpreted by a virtual CNC ``Machine`` instance.
Supported movements are currently;
- linear movements
- arc movements
- canned drilling cycles
`Check out the wiki <https://github.com/fragmuffin/pygcode/wiki>`__ for documentation.
Keywords: gcode,cnc,parser,interpreter
Platform: UNKNOWN

View File

@ -1,6 +1,8 @@
README.rst
setup.cfg
setup.py
scripts/pygcode-crop
scripts/pygcode-norm
src/pygcode/__init__.py
src/pygcode/block.py
src/pygcode/comment.py

View File

@ -1,2 +1,3 @@
argparse
euclid3
six

View File

@ -6,7 +6,7 @@
# 1.x - Development Status :: 5 - Production/Stable
# <any above>.y - developments on that version (pre-release)
# <any above>*.dev* - development release (intended purely to test deployment)
__version__ = "0.1.0"
__version__ = "0.1.1"
__title__ = "pygcode"
__description__ = "Basic g-code parser, interpreter, and encoder library."
@ -53,7 +53,7 @@ __all__ = [
'GCodeCancelCannedCycle',
'GCodeCancelToolLengthOffset',
'GCodeCannedCycle',
'GCodeCannedCycleReturnLevel',
'GCodeCannedCycleReturnPrevLevel',
'GCodeCannedCycleReturnToR',
'GCodeCannedReturnMode',
'GCodeCoolant',
@ -199,7 +199,7 @@ from .gcodes import (
# G83 - GCodeDrillingCyclePeck: G83: Drilling Cycle, Peck
# G76 - GCodeThreadingCycle: G76: Threading Cycle
# - GCodeCannedReturnMode:
# G98 - GCodeCannedCycleReturnLevel: G98: Canned Cycle Return to the level set prior to cycle start
# G98 - GCodeCannedCycleReturnPrevLevel: G98: Canned Cycle Return to the level set prior to cycle start
# G99 - GCodeCannedCycleReturnToR: G99: Canned Cycle Return to the level set by R
# - GCodeCoolant:
# M08 - GCodeCoolantFloodOn: M8: turn flood coolant on
@ -330,7 +330,7 @@ from .gcodes import (
GCodeCancelCannedCycle,
GCodeCancelToolLengthOffset,
GCodeCannedCycle,
GCodeCannedCycleReturnLevel,
GCodeCannedCycleReturnPrevLevel,
GCodeCannedCycleReturnToR,
GCodeCannedReturnMode,
GCodeCoolant,

View File

@ -96,7 +96,7 @@ MODAL_GROUP_MAP = {
# Traditionally Non-grouped:
# Although these GCodes set the machine's mode, there are no other GCodes to
# group with them. So although they're modal, they doesn't have a defined
# group with them. So although they're modal, they don't have a defined
# modal group.
# However, having a modal group assists with:
# - validating gcode blocks for conflicting commands
@ -218,15 +218,29 @@ class GCode(object):
return copy(self.word_key)
raise AssertionError("class %r has no default word" % self.__class__)
# Comparisons
# Equality
def __eq__(self, other):
return (
(self.word == other.word) and
(self.params == other.params)
)
def __ne__(self, other):
return not self.__eq__(other)
# Sort by execution order
def __lt__(self, other):
"""Sort by execution order"""
return self.exec_order < other.exec_order
def __le__(self, other):
return self.exec_order <= other.exec_order
def __gt__(self, other):
"""Sort by execution order"""
return self.exec_order > other.exec_order
def __ge__(self, other):
return self.exec_order >= other.exec_order
# Parameters
def add_parameter(self, word):
"""
@ -483,9 +497,17 @@ class GCodeCannedCycle(GCode):
if isinstance(machine.mode.canned_cycles_return, GCodeCannedCycleReturnToR):
# canned return is to this.R, not this.Z (plane dependent)
moveto_coords.update({
machine.mode.plane_selection.normal_axis: this.R,
machine.mode.plane_selection.normal_axis: self.R,
})
else: # default: GCodeCannedCycleReturnPrevLevel
# Remove this.Z (plane dependent) value (ie: no machine movement on this axis)
moveto_coords.pop(machine.mode.plane_selection.normal_axis, None)
# Process action 'L' times
loop_count = self.L
if (loop_count is None) or (loop_count <= 0):
loop_count = 1
for i in range(loop_count):
machine.move_to(**moveto_coords)
@ -802,9 +824,11 @@ class GCodePlaneSelect(GCode):
# vectorYZ = GCodeSelectYZPlane.quat * (GCodeSelectZXPlane.quat.conjugate() * vectorZX)
quat = None # Quaternion
# -- Plane Normal
# -- Plane Axis Information
# Vector normal to plane (such that XYZ axes follow the right-hand rule)
normal_axis = None # Letter of normal axis (upper case)
# Axes of plane
plane_axes = set()
normal = None # Vector3
@ -813,6 +837,7 @@ class GCodeSelectXYPlane(GCodePlaneSelect):
word_key = Word('G', 17)
quat = Quaternion() # no effect
normal_axis = 'Z'
plane_axes = set('XY')
normal = Vector3(0., 0., 1.)
@ -824,6 +849,7 @@ class GCodeSelectZXPlane(GCodePlaneSelect):
Vector3(0., 0., 1.), Vector3(1., 0., 0.)
)
normal_axis = 'Y'
plane_axes = set('ZX')
normal = Vector3(0., 1., 0.)
@ -835,6 +861,7 @@ class GCodeSelectYZPlane(GCodePlaneSelect):
Vector3(0., 1., 0.), Vector3(0., 0., 1.)
)
normal_axis = 'X'
plane_axes = set('YZ')
normal = Vector3(1., 0., 0.)
@ -929,7 +956,7 @@ class GCodeCannedReturnMode(GCode):
exec_order = 220
class GCodeCannedCycleReturnLevel(GCodeCannedReturnMode):
class GCodeCannedCycleReturnPrevLevel(GCodeCannedReturnMode):
"""G98: Canned Cycle Return to the level set prior to cycle start"""
# "retract to the position that axis was in just before this series of one or more contiguous canned cycles was started"
word_key = Word('G', 98)

View File

@ -52,6 +52,9 @@ class Position(object):
self._value = defaultdict(lambda: 0.0, dict((k, 0.0) for k in self.axes))
self._value.update(kwargs)
def __copy__(self):
return self.__class__(axes=copy(self.axes), unit=self._unit, **self.values)
def update(self, **coords):
for (k, v) in coords.items():
setattr(self, k, v)
@ -74,10 +77,6 @@ class Position(object):
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:
@ -87,7 +86,7 @@ class Position(object):
return self._value == other._value
else:
x = copy(other)
x.set_unit(self._unit)
x.unit = self._unit
return self._value == x._value
def __ne__(self, other):
@ -125,14 +124,48 @@ class Position(object):
__truediv__ = __div__ # Python 3 division
# Conversion
def set_unit(self, unit):
if unit == self._unit:
return
factor = UNIT_MAP[self._unit]['conversion_factor'][unit]
@property
def unit(self):
return self._unit
@unit.setter
def unit(self, value):
if value != self._unit:
factor = UNIT_MAP[self._unit]['conversion_factor'][value]
for k in [k for (k, v) in self._value.items() if v is not None]:
self._value[k] *= factor
self._unit = unit
self._unit = value
# Min/Max
@classmethod
def _cmp(cls, p1, p2, key):
"""
Returns a position of the combined min/max values for each axis
(eg: key=min for)
note: the result is not necessarily equal to either p1 or p2.
:param p1: Position instance
:param p2: Position instance
:return: Position instance with the highest/lowest value per axis
"""
if p2.unit != p1.unit:
p2 = copy(p2)
p2.unit = p1.unit
return cls(
unit=p1.unit,
**dict(
(k, key(getattr(p1, k), getattr(p2, k))) for k in p1.values
)
)
@classmethod
def min(cls, a, b):
return cls._cmp(a, b, key=min)
@classmethod
def max(cls, a, b):
return cls._cmp(a, b, key=max)
# Words & Values
@property
def words(self):
return sorted(Word(k, self._value[k]) for k in self.axes)
@ -145,6 +178,7 @@ class Position(object):
def vector(self):
return Vector3(self._value['X'], self._value['Y'], self._value['Z'])
# String representation(s)
def __repr__(self):
return "<{class_name}: {coordinates}>".format(
class_name=self.__class__.__name__,
@ -153,12 +187,13 @@ class Position(object):
class CoordinateSystem(object):
def __init__(self, axes):
self.offset = Position(axes)
def __init__(self, axes=None):
self.offset = Position(axes=axes)
def __add__(self, other):
if isinstance(other, CoordinateSystem):
pass
def __copy__(self):
obj = self.__class__()
obj.offset = copy(self.offset)
return obj
def __repr__(self):
return "<{class_name}: offset={offset}>".format(
@ -174,6 +209,7 @@ class State(object):
# AFAIK: this is everything needed to remember a machine's state that isn't
# handled by modal gcodes.
def __init__(self, axes=None):
self._axes = axes
# Coordinate Systems
self.coord_systems = {}
for i in range(1, 10): # G54-G59.3
@ -202,7 +238,11 @@ class State(object):
# Coordinate System selection:
# - G54-G59: select coordinate system (offsets from machine coordinates set by G10 L2)
# TODO: Move this class into MachineState
def __copy__(self):
obj = self.__class__(axes=self._axes)
obj.coord_systems = [copy(cs) for cs in self.coord_systems]
obj.offset = copy(self.offset)
return obj
@property
def coord_sys(self):
@ -221,15 +261,6 @@ class State(object):
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.
# However, the machine for which this state is stored probably doesn't
# have all possible 6 axes.
# 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.
# Default Mode
# for a Grbl controller this can be obtained with the `$G` command, eg:
# > $G
@ -256,12 +287,18 @@ class Mode(object):
# Mode is defined by gcodes set by processed blocks:
# see modal_group in gcode.py module for details
def __init__(self):
def __init__(self, set_default=True):
self.modal_groups = defaultdict(lambda: None)
# Initialize
if set_default:
self.set_mode(*Line(self.default_mode).block.gcodes)
def __copy__(self):
obj = self.__class__(set_default=False)
obj.modal_groups = deepcopy(self.modal_groups)
return obj
def set_mode(self, *gcode_list):
"""
Set machine mode from given gcodes (will not be processed)
@ -337,11 +374,23 @@ class Machine(object):
units_mode = getattr(self.mode, 'units', None)
self.Position = type('Position', (Position,), {
'default_axes': self.axes,
'default_unit': units_mode.unit_id if units_mode else UNIT_METRIC,
'default_unit': units_mode.unit_id if units_mode else Position.default_unit,
})
# Absolute machine position
self.abs_pos = self.Position()
# Machine's motion range (min/max corners of a bounding box)
self.abs_range_min = copy(self.abs_pos)
self.abs_range_max = copy(self.abs_pos)
def __copy__(self):
obj = self.__class__()
obj.mode = copy(self.mode)
obj.state = copy(self.state)
obj.abs_pos = copy(self.abs_pos)
obj.abs_range_min = copy(self.abs_range_min)
obj.abs_range_max = copy(self.abs_range_max)
return obj
def set_mode(self, *gcode_list):
self.mode.set_mode(*gcode_list) # passthrough
@ -351,6 +400,8 @@ class Machine(object):
if coord_sys_mode:
self.state.cur_coord_sys = coord_sys_mode.coord_system_id
# TODO: convert coord systems between inches/mm, G20/G21 respectively
def modal_gcode(self, modal_params):
if not modal_params:
@ -359,7 +410,9 @@ class Machine(object):
raise MachineInvalidState("unable to assign modal parameters when no motion mode is set")
params = copy(self.mode.motion.params) # dict
params.update(dict((w.letter, w) for w in modal_params)) # override retained modal parameters
(modal_gcodes, unasigned_words) = words2gcodes([self.mode.motion.word] + params.values())
(modal_gcodes, unasigned_words) = words2gcodes(
[self.mode.motion.word] + list(params.values())
)
if unasigned_words:
raise MachineInvalidState("modal parameters '%s' cannot be assigned when in mode: %r" % (
' '.join(str(x) for x in unasigned_words), self.mode
@ -430,6 +483,11 @@ class Machine(object):
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
self._update_abs_range(self.abs_pos)
def _update_abs_range(self, pos):
self.abs_range_min = Position.min(pos, self.abs_range_min)
self.abs_range_max = Position.max(pos, self.abs_range_max)
# =================== Machine Actions ===================
def move_to(self, rapid=False, **coords):

View File

@ -7,7 +7,7 @@ from .gcodes import GCodeAbsoluteDistanceMode, GCodeIncrementalDistanceMode
from .gcodes import GCodeAbsoluteArcDistanceMode, GCodeIncrementalArcDistanceMode
from .gcodes import GCodeCannedCycle
from .gcodes import GCodeDrillingCyclePeck, GCodeDrillingCycleDwell, GCodeDrillingCycleChipBreaking
from .gcodes import GCodeCannedReturnMode, GCodeCannedCycleReturnLevel, GCodeCannedCycleReturnToR
from .gcodes import GCodeCannedReturnMode, GCodeCannedCycleReturnPrevLevel, GCodeCannedCycleReturnToR
from .gcodes import _gcodes_abs2rel
from .machine import Position
@ -381,7 +381,7 @@ def linearize_arc(arc_gcode, start_pos, plane=None, method_class=None,
DEFAULT_SCC_PLANE = GCodeSelectXYPlane
DEFAULT_SCC_DISTMODE = GCodeAbsoluteDistanceMode
DEFAULT_SCC_RETRACTMODE = GCodeCannedCycleReturnLevel
DEFAULT_SCC_RETRACTMODE = GCodeCannedCycleReturnPrevLevel
def simplify_canned_cycle(canned_gcode, start_pos,
plane=None, dist_mode=None, retract_mode=None,

View File

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

View File

@ -1,24 +0,0 @@
import sys
import os
import inspect
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.file import GCodeFile, GCodeParser
class GCodeParserTest(unittest.TestCase):
FILENAME = 'test-files/vertical-slot.ngc'
def test_parser(self):
parser = GCodeParser(self.FILENAME)
# count lines
line_count = 0
for line in parser.iterlines():
line_count += 1
self.assertEqual(line_count, 26)
parser.close()

View File

@ -8,6 +8,11 @@ add_pygcode_to_path()
from pygcode.machine import Position, Machine
from pygcode.line import Line
from pygcode.exceptions import MachineInvalidAxis
from pygcode.gcodes import (
GCodeAbsoluteDistanceMode, GCodeIncrementalDistanceMode,
GCodeAbsoluteArcDistanceMode, GCodeIncrementalArcDistanceMode,
GCodeCannedCycleReturnPrevLevel, GCodeCannedCycleReturnToR,
)
class PositionTests(unittest.TestCase):
@ -81,43 +86,146 @@ class PositionTests(unittest.TestCase):
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)
def assert_processed_lines(self, line_data, machine):
"""
Process lines & assert machine's position
:param line_data: list of tuples [('g1 x2', {'X':2}), ... ]
"""
for (i, (line_str, expected_pos)) in enumerate(line_data):
line = Line(line_str)
if line.block:
#print("\n%s" % line.block)
m.process_block(line.block)
machine.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))
if expected_pos is not None:
p1 = machine.pos
p2 = machine.Position(**expected_pos)
self.assertEqual(p1, p2, "index:%i '%s': %r != %r" % (i, line_str, p1, p2))
# Rapid Movement
def test_rapid_abs(self):
m = Machine()
m.process_gcodes(GCodeAbsoluteDistanceMode())
line_data = [
('', {}), # start @ 0,0,0
('g0 x0 y10', {'X':0, 'Y':10}),
(' x10 y10', {'X':10, 'Y':10}),
(' x10 y0', {'X':10, 'Y':0}),
(' x0 y0', {'X':0, 'Y':0}),
]
self.assert_processed_lines(line_data, m)
#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
def test_rapid_inc(self):
m = Machine()
m.process_gcodes(GCodeIncrementalDistanceMode())
line_data = [
('', {}), # start @ 0,0,0
('g0 y10', {'X':0, 'Y':10}),
(' x10', {'X':10, 'Y':10}),
(' y-10', {'X':10, 'Y':0}),
(' x-10', {'X':0, 'Y':0}),
]
self.assert_processed_lines(line_data, m)
# Linearly Interpolated Movement
def test_linear_abs(self):
m = Machine()
m.process_gcodes(GCodeAbsoluteDistanceMode())
line_data = [
('g1 x0 y10', {'X':0, 'Y':10}),
(' x10 y10', {'X':10, 'Y':10}),
(' x10 y0', {'X':10, 'Y':0}),
(' x0 y0', {'X':0, 'Y':0}),
]
self.assert_processed_lines(line_data, m)
def test_linear_inc(self):
m = Machine()
m.process_gcodes(GCodeIncrementalDistanceMode())
line_data = [
('g1 y10', {'X':0, 'Y':10}),
(' x10', {'X':10, 'Y':10}),
(' y-10', {'X':10, 'Y':0}),
(' x-10', {'X':0, 'Y':0}),
]
self.assert_processed_lines(line_data, m)
# Arc Movement
def test_arc_abs(self):
m = Machine()
m.process_gcodes(
GCodeAbsoluteDistanceMode(),
GCodeIncrementalArcDistanceMode(),
)
line_data = [
# Clockwise circle in 4 segments
('g2 x0 y10 i5 j5', {'X':0, 'Y':10}),
(' x10 y10 i5 j-5', {'X':10, 'Y':10}),
(' x10 y0 i-5 j-5', {'X':10, 'Y':0}),
(' x0 y0 i-5 j5', {'X':0, 'Y':0}),
# Counter-clockwise circle in 4 segments
('g3 x10 y0 i5 j5', {'X':10, 'Y':0}),
(' x10 y10 i-5 j5', {'X':10, 'Y':10}),
(' x0 y10 i-5 j-5', {'X':0, 'Y':10}),
(' x0 y0 i5 j-5', {'X':0, 'Y':0}),
]
self.assert_processed_lines(line_data, m)
def test_arc_inc(self):
m = Machine()
m.process_gcodes(
GCodeIncrementalDistanceMode(),
GCodeIncrementalArcDistanceMode(),
)
line_data = [
# Clockwise circle in 4 segments
('g2 y10 i5 j5', {'X':0, 'Y':10}),
(' x10 i5 j-5', {'X':10, 'Y':10}),
(' y-10 i-5 j-5', {'X':10, 'Y':0}),
(' x-10 i-5 j5', {'X':0, 'Y':0}),
# Counter-clockwise circle in 4 segments
('g3 x10 i5 j5', {'X':10, 'Y':0}),
(' y10 i-5 j5', {'X':10, 'Y':10}),
(' x-10 i-5 j-5', {'X':0, 'Y':10}),
(' y-10 i5 j-5', {'X':0, 'Y':0}),
]
self.assert_processed_lines(line_data, m)
# Canned Drilling Cycles
def test_canned_return2oldz(self):
m = Machine()
m.process_gcodes(
GCodeAbsoluteDistanceMode(),
GCodeCannedCycleReturnPrevLevel(),
)
line_data = [
('g0 z5', {'Z':5}),
('g81 x10 y20 z-2 r1', {'X':10, 'Y':20, 'Z':5}),
]
self.assert_processed_lines(line_data, m)
def test_canned_return2r(self):
m = Machine()
m.process_gcodes(
GCodeAbsoluteDistanceMode(),
GCodeCannedCycleReturnToR(),
)
line_data = [
('g0 z5', {'Z':5}),
('g81 x10 y20 z-2 r1', {'X':10, 'Y':20, 'Z':1}),
]
self.assert_processed_lines(line_data, m)
def test_canned_loops(self):
m = Machine()
m.process_gcodes(
GCodeAbsoluteDistanceMode(),
GCodeCannedCycleReturnPrevLevel(),
)
line_data = [
('g0 z5', None),
('g81 x10 y20 z-2 r1 l2', {'X':10, 'Y':20, 'Z':5}),
('g91', None), # switch to incremental mode
('g81 x10 y20 z-2 r1 l2', {'X':30, 'Y':60, 'Z':5}),
]
self.assert_processed_lines(line_data, m)

View File

@ -5,14 +5,20 @@ 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:
if os.environ.get('PYGCODE_TESTSCOPE', 'local') == 'installed':
# Run tests on the `pip install` installed libray
pass # nothing to be done
else:
# Run tests explicitly on files in ../src (ignore any installed libs)
# 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, '..'))
sys.path.insert(0, os.path.join(_this_path, '..', 'src'))
_pygcode_in_path = True