diff --git a/README.rst b/README.rst index fd5faa3..dd94b32 100644 --- a/README.rst +++ b/README.rst @@ -7,227 +7,18 @@ GCODE Parser for Python Currently in development, ``pygcode`` is a low-level GCode interpreter for python. + Installation ============ -Using `PyPi `__: +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 `__ -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 - - >>> g = GCodeRapidMove(X=10, Y=20) - >>> m.process_gcodes(g) - >>> m.pos - - >>> m.process_gcodes(g) - >>> m.pos - # same position; machine in absolute mode - >>> m.mode.distance - # see - - >>> m.process_gcodes(GCodeIncrementalDistanceMode()) - >>> m.process_gcodes(g) # same gcode as above - >>> m.pos - - -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 `__). -``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 `__ - -At the time of writing this, that script converts arcs to linear codes, and -expands drilling cycles to basic movements (so my -`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 `__ and -- `LinuxCNC `__ - -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 `__. -if you get in early, you may get some free labour out of me ;) - - -Supported G-Codes ------------------ - -All GCodes supported by `LinuxCNC `__ 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 `__ for documentation. diff --git a/deployment.md b/deployment.md deleted file mode 100644 index fb7b164..0000000 --- a/deployment.md +++ /dev/null @@ -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//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//bin/pip install pygcode` diff --git a/deployment/README.md b/deployment/README.md new file mode 100644 index 0000000..3bb02bb --- /dev/null +++ b/deployment/README.md @@ -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 "" +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 diff --git a/deployment/deploy.sh b/deployment/deploy.sh new file mode 100755 index 0000000..008bbee --- /dev/null +++ b/deployment/deploy.sh @@ -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 diff --git a/dist/pygcode-0.1.0-py2.py3-none-any.whl b/dist/pygcode-0.1.0-py2.py3-none-any.whl deleted file mode 100644 index 1fce84d..0000000 Binary files a/dist/pygcode-0.1.0-py2.py3-none-any.whl and /dev/null differ diff --git a/dist/pygcode-0.1.0.tar.gz b/dist/pygcode-0.1.0.tar.gz deleted file mode 100644 index 27203ae..0000000 Binary files a/dist/pygcode-0.1.0.tar.gz and /dev/null differ diff --git a/dist/pygcode-0.1.1-py2.py3-none-any.whl b/dist/pygcode-0.1.1-py2.py3-none-any.whl new file mode 100644 index 0000000..1a9aa6b Binary files /dev/null and b/dist/pygcode-0.1.1-py2.py3-none-any.whl differ diff --git a/dist/pygcode-0.1.1.tar.gz b/dist/pygcode-0.1.1.tar.gz new file mode 100644 index 0000000..3fc5658 Binary files /dev/null and b/dist/pygcode-0.1.1.tar.gz differ diff --git a/media/README.md b/media/README.md new file mode 100644 index 0000000..6086cb4 --- /dev/null +++ b/media/README.md @@ -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/) diff --git a/media/arc-linearize-method-inside.png b/media/arc-linearize-method-inside.png new file mode 100644 index 0000000..5953582 Binary files /dev/null and b/media/arc-linearize-method-inside.png differ diff --git a/media/arc-linearize-method-mid.png b/media/arc-linearize-method-mid.png new file mode 100644 index 0000000..5108ddb Binary files /dev/null and b/media/arc-linearize-method-mid.png differ diff --git a/media/arc-linearize-method-mixed.png b/media/arc-linearize-method-mixed.png new file mode 100644 index 0000000..cc9c9be Binary files /dev/null and b/media/arc-linearize-method-mixed.png differ diff --git a/media/arc-linearize-method-outside.png b/media/arc-linearize-method-outside.png new file mode 100644 index 0000000..caa139d Binary files /dev/null and b/media/arc-linearize-method-outside.png differ diff --git a/scripts/pygcode-crop b/scripts/pygcode-crop new file mode 100755 index 0000000..b5d1d66 --- /dev/null +++ b/scripts/pygcode-crop @@ -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 + # - = (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[^:]*):(?P[^:]*)$', 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: '' + :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[abcnxyz])?\s* # parameter + (?P(==?|!=|<=?|>=?)) # comparison + )?\s* # parameter & comparison defaults to "n=" + (?P-?\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) diff --git a/scripts/pygcode-normalize.py b/scripts/pygcode-norm similarity index 53% rename from scripts/pygcode-normalize.py rename to scripts/pygcode-norm index adecfd4..d9bd32f 100755 --- a/scripts/pygcode-normalize.py +++ b/scripts/pygcode-norm @@ -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'): , ... } + """ + ARC_LIN_CLASS_MAP = { + 'i': ArcLinearizeInside, + 'o': ArcLinearizeOutside, + 'm': ArcLinearizeMid, + } + + value_dict = defaultdict(lambda: ArcLinearizeMid) + if value: + match = re.search(r'^(?P[iom])(,(?P[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 ,, eg 'i,o'. 'i' is equivalent to 'i,i'. " + "for ,, 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'): , ... } -ARC_LIN_CLASS_MAP = { - 'i': ArcLinearizeInside, - 'o': ArcLinearizeOutside, - 'm': ArcLinearizeMid, -} - -arc_lin_method_regex = re.compile(r'^(?P[iom])(,(?P[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) diff --git a/setup.py b/setup.py index 90777a5..ca02036 100644 --- a/setup.py +++ b/setup.py @@ -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, ) diff --git a/src/pygcode.egg-info/PKG-INFO b/src/pygcode.egg-info/PKG-INFO index ae6dded..69db8c7 100644 --- a/src/pygcode.egg-info/PKG-INFO +++ b/src/pygcode.egg-info/PKG-INFO @@ -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 `__: + 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 `__ - 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 - - >>> g = GCodeRapidMove(X=10, Y=20) - >>> m.process_gcodes(g) - >>> m.pos - - >>> m.process_gcodes(g) - >>> m.pos - # same position; machine in absolute mode - >>> m.mode.distance - # see - - >>> m.process_gcodes(GCodeIncrementalDistanceMode()) - >>> m.process_gcodes(g) # same gcode as above - >>> m.pos - - - 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 `__). - ``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 `__ - - At the time of writing this, that script converts arcs to linear codes, and - expands drilling cycles to basic movements (so my - `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 `__ and - - `LinuxCNC `__ - - 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 `__. - if you get in early, you may get some free labour out of me ;) - - - Supported G-Codes - ----------------- - - All GCodes supported by `LinuxCNC `__ 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 `__ for documentation. Keywords: gcode,cnc,parser,interpreter Platform: UNKNOWN diff --git a/src/pygcode.egg-info/SOURCES.txt b/src/pygcode.egg-info/SOURCES.txt index 38deb60..3429030 100644 --- a/src/pygcode.egg-info/SOURCES.txt +++ b/src/pygcode.egg-info/SOURCES.txt @@ -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 diff --git a/src/pygcode.egg-info/requires.txt b/src/pygcode.egg-info/requires.txt index baa8aa3..e0978f0 100644 --- a/src/pygcode.egg-info/requires.txt +++ b/src/pygcode.egg-info/requires.txt @@ -1,2 +1,3 @@ +argparse euclid3 six diff --git a/src/pygcode/__init__.py b/src/pygcode/__init__.py index 9737e03..afb4bd2 100644 --- a/src/pygcode/__init__.py +++ b/src/pygcode/__init__.py @@ -6,7 +6,7 @@ # 1.x - Development Status :: 5 - Production/Stable # .y - developments on that version (pre-release) # *.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, diff --git a/src/pygcode/gcodes.py b/src/pygcode/gcodes.py index 4707aec..5013fb1 100644 --- a/src/pygcode/gcodes.py +++ b/src/pygcode/gcodes.py @@ -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,10 +497,18 @@ 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) - machine.move_to(**moveto_coords) + # 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) class GCodeDrillingCycle(GCodeCannedCycle): @@ -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) diff --git a/src/pygcode/machine.py b/src/pygcode/machine.py index 4f0e42b..ac19ace 100644 --- a/src/pygcode/machine.py +++ b/src/pygcode/machine.py @@ -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] - 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 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 = 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,11 +287,17 @@ 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 - self.set_mode(*Line(self.default_mode).block.gcodes) + 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): """ @@ -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): diff --git a/src/pygcode/transform.py b/src/pygcode/transform.py index 10a415b..da8a3c0 100644 --- a/src/pygcode/transform.py +++ b/src/pygcode/transform.py @@ -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, diff --git a/tests/runtests.sh b/tests/runtests.sh index 06ad089..a735a38 100755 --- a/tests/runtests.sh +++ b/tests/runtests.sh @@ -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' diff --git a/tests/test_file.py b/tests/test_file.py deleted file mode 100644 index e678b74..0000000 --- a/tests/test_file.py +++ /dev/null @@ -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() diff --git a/tests/test_machine.py b/tests/test_machine.py index 5f52aa6..17ccf07 100644 --- a/tests/test_machine.py +++ b/tests/test_machine.py @@ -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) - # 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)) + machine.process_block(line.block) + # Assert possition change correct + 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) diff --git a/tests/testutils.py b/tests/testutils.py index dcc494c..5046a63 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -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: - # 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, '..')) + 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, '..', 'src')) _pygcode_in_path = True