Merge pull request #1 from fragmuffin/develop

v0.1.0
This commit is contained in:
Peter Boin 2017-07-18 23:09:45 +10:00 committed by GitHub
commit 35a1dab19f
24 changed files with 4405 additions and 19 deletions

6
.gitignore vendored Normal file
View File

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

View File

@ -1,31 +1,44 @@
# pygcode
pygcode
=======
GCODE Parser for Python
Currently in development, this is planned to be a pythonic interpreter and encoder for g-code.
I'll be learning along the way, but the plan is to follow the lead of [GRBL](https://github.com/gnea/grbl).
Currently in development, this is planned to be a pythonic interpreter
and encoder for g-code. I'll be learning along the way, but the plan is
to follow the lead of `GRBL <https://github.com/gnea/grbl>`__.
## Installation
Installation
------------
`pip install pygcode`
``pip install pygcode``
## Usage
FIXME: well, that's the plan... give me some time to get it going
though.
Usage
-----
Just brainstorming here...
::
import pygcode
import math
import euclid
gfile_in = pygcode.Parser('part.gcode')
gfile_out = pygcode.Writer('part2.gcode')
gfile_in = pygcode.parse('part1.gcode') #
gfile_out = pygcode.GCodeFile('part2.gcode')
total_travel = 0
total_time = 0
for (state, block) in gfile_in.iterstate():
# where:
# state = CNC's state before the block is executed
# block = the gcode to be executed next
machine = pygcode.Machine()
for line in gfile_in.iterlines():
block = line.block
if block is None:
continue
# validation
if isinstance(block, pygcode.GCodeArc):
@ -36,13 +49,13 @@ Just brainstorming here...
block.set_precision(0.0005, method=pygcode.GCodeArc.EFFECT_RADIUS)
# random metrics
travel_vector = block.position - state.position # euclid.Vector3 instance
travel_vector = block.position - machine.state.position # euclid.Vector3 instance
distance = travel_vector.magnitude()
travel = block.travel_distance(position=state.position) # eg: distance != travel for G02 & G03
travel = block.travel_distance(position=machine.state.position) # eg: distance != travel for G02 & G03
total_travel += travel
#total_time += block.time(feed_rate=state.feed_rate) # doesn't consider the feedrate being changed in this block
total_time += block.time(state=state)
#total_time += block.time(feed_rate=machine.state.feed_rate) # doesn't consider the feedrate being changed in this block
total_time += block.time(state=machine.state)
# rotate : entire file 90deg CCW
block.rotate(euclid.Quaternion.new_rotate_axis(
@ -51,16 +64,23 @@ Just brainstorming here...
# translate : entire file x += 1, y += 2 mm (after rotation)
block.translate(euclid.Vector3(1, 2, 0), unit=pygcode.UNIT_MM)
# TODO: then do something like write it to another file
gfile_out.write(block)
gfile_in.close()
gfile_out.close()
Supported G-Codes
-----------------
## Supported G-Codes
GCode support is planned to follow that of [GRBL](https://github.com/gnea/grbl).
GCode support is planned to follow that of
`GRBL <https://github.com/gnea/grbl>`__ which follows
`LinuxCNC <http://linuxcnc.org>`__ (list of gcodes documented
`here <http://linuxcnc.org/docs/html/gcode.html>`__).
But anything pre v1.0 will be a sub-set, focusing on the issues I'm having... I'm selfish that way.
But anything pre v1.0 will be a sub-set, focusing on the issues I'm
having... I'm selfish that way.
TBD: list of gcodes (also as a TODO list)

226
scripts/pygcode-normalize.py Executable file
View File

@ -0,0 +1,226 @@
#!/usr/bin/env python
# Script to take (theoretically) any g-code file as input, and output a
# normalized version of it.
#
# Script outcome can have cursory verification with:
# https://nraynaud.github.io/webgcode/
import argparse
import re
from collections import defaultdict
from contextlib import contextmanager
for pygcode_lib_type in ('installed_lib', 'relative_lib'):
try:
# pygcode
from pygcode import Word
from pygcode import Machine, Mode, Line
from pygcode import GCodeArcMove, GCodeArcMoveCW, GCodeArcMoveCCW
from pygcode import GCodeCannedCycle
from pygcode import split_gcodes
from pygcode import Comment
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
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, '..'))
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 ===================
# --- Defaults
DEFAULT_PRECISION = 0.005 # mm
DEFAULT_MACHINE_MODE = 'G0 G54 G17 G21 G90 G94 M5 M9 T0 F0 S0'
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.add_argument(
'infile', type=argparse.FileType('r'), nargs=1,
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,
)
# Machine
parser.add_argument(
'--machine_mode', '-mm', dest='machine_mode', default=DEFAULT_MACHINE_MODE,
help="Machine's startup mode as gcode (default: '%s')" % DEFAULT_MACHINE_MODE,
)
# Arc Linearizing
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."
)
group.add_argument(
'--arc_linearize', '-al', dest='arc_linearize',
action='store_const', const=True, default=False,
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,
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'. "
"(default: '%s')" % DEFAULT_ARC_LIN_METHOD,
metavar='{i,o,m}[,{i,o,m}]',
)
# Canned Cycles
group = parser.add_argument_group(
"Canned Cycle Simplification",
"Convert canned cycles into basic linear or scalar codes, such as linear "
"interpolation (G1), and pauses (or 'dwells', G4)"
)
group.add_argument(
'--canned_expand', '-ce', dest='canned_expand',
action='store_const', const=True, default=False,
help="Expand canned cycles into basic linear movements, and pauses",
)
group.add_argument(
'---canned_codes', '-cc', dest='canned_codes', 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",
#)
# --- 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
class MyMachine(Machine):
MODE_CLASS = MyMode
machine = MyMachine()
# =================== Utility Functions ===================
def gcodes2str(gcodes):
return ' '.join("%s" % g for g in gcodes)
@contextmanager
def split_and_process(gcode_list, gcode_class, comment):
"""
Split gcodes by given class, yields given class instance
:param gcode_list: list of GCode instances
:param gcode_class: class inheriting from GCode (directly, or indirectly)
:param comment: Comment instance, or None
"""
(befores, (g,), afters) = split_gcodes(gcode_list, gcode_class)
# print & process those before gcode_class instance
if befores:
print(gcodes2str(befores))
machine.process_gcodes(*befores)
# yield, then process gcode_class instance
yield g
machine.process_gcodes(g)
# print & process those after gcode_class instance
if afters:
print(gcodes2str(afters))
machine.process_gcodes(*afters)
# print comment (if given)
if comment:
print(str(line.comment))
# =================== Process File ===================
for line_str in args.infile[0].readlines():
line = Line(line_str)
# Effective G-Codes:
# fills in missing motion modal gcodes (using machine's current motion mode).
effective_gcodes = machine.block_modal_gcodes(line.block)
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))
linearize_params = {
'arc_gcode': arc,
'start_pos': machine.pos,
'plane': machine.mode.plane_selection,
'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,
'decimal_places': 3,
}
for linear_gcode in omit_redundant_modes(linearize_arc(**linearize_params)):
print(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))
simplify_canned_params = {
'canned_gcode': canned,
'start_pos': machine.pos,
'plane': machine.mode.plane_selection,
'dist_mode': machine.mode.distance,
'axes': machine.axes,
}
for simplified_gcode in omit_redundant_modes(simplify_canned_cycle(**simplify_canned_params)):
print(simplified_gcode)
else:
print(str(line))
machine.process_block(line.block)

6
setup.cfg Normal file
View File

@ -0,0 +1,6 @@
[bdist_wheel]
universal = 1
[metadata]
description-file = README.md
license_file = LICENSE

96
setup.py Normal file
View File

@ -0,0 +1,96 @@
import codecs
import os
import re
from setuptools import setup, find_packages
###################################################################
NAME = "attrs"
PACKAGES = find_packages(where="src")
META_PATH = os.path.join("src", "attr", "__init__.py")
KEYWORDS = ["class", "attribute", "boilerplate"]
CLASSIFIERS = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Natural Language :: English",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 2",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.3",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
"Topic :: Software Development :: Libraries :: Python Modules",
]
INSTALL_REQUIRES = []
###################################################################
HERE = os.path.abspath(os.path.dirname(__file__))
def read(*parts):
"""
Build an absolute path from *parts* and and return the contents of the
resulting file. Assume UTF-8 encoding.
"""
with codecs.open(os.path.join(HERE, *parts), "rb", "utf-8") as f:
return f.read()
META_FILE = read(META_PATH)
def find_meta(meta):
"""
Extract __*meta*__ from META_FILE.
"""
meta_match = re.search(
r"^(?P<name>__{meta}__)\s*=\s*['\"](?P<value>[^'\"]*)['\"]".format(meta=meta),
META_FILE, re.M
)
if meta_match:
return meta_match.group('value')
raise RuntimeError("Unable to find __{meta}__ string.".format(meta=meta))
if __name__ == "__main__":
setup(
name=NAME,
description=find_meta("description"),
license=find_meta("license"),
url=find_meta("uri"),
version=find_meta("version"),
author=find_meta("author"),
author_email=find_meta("email"),
maintainer=find_meta("author"),
maintainer_email=find_meta("email"),
keywords=KEYWORDS,
long_description=read("README.rst"),
packages=PACKAGES,
package_dir={"": "src"},
zip_safe=False,
classifiers=CLASSIFIERS,
install_requires=INSTALL_REQUIRES,
)
#VERSION = '0.1.dev' # *.dev = release candidate
#
#setup(
# name = 'pygcode',
# packages = ['pygcode'],
# version = VERSION,
# description = 'basic g-code parser, interpreter, and writer library',
# author = 'Peter Boin',
# author_email = 'peter.boin@gmail.com',
# url = 'https://github.com/fragmuffin/pygcode',
# download_url = 'https://github.com/fragmuffin/pygcode/archive/%s.tar.gz' % VERSION,
# keywords = ['gcode', 'cnc', 'parser', 'interpreter'],
# classifiers = [],
#)

413
src/pygcode/__init__.py Normal file
View File

@ -0,0 +1,413 @@
__all__ = [
# Machine
'Machine', 'Position', 'CoordinateSystem', 'State', 'Mode',
# Line
'Line',
# Block
'Block',
# Comment
'Comment', 'split_line',
# Word
'Word', 'text2words', 'str2word', 'words2dict',
# GCodes
'words2gcodes', 'text2gcodes', 'split_gcodes',
# $ python -c "from pygcode.gcodes import GCode, _subclasses as sc; print(',\\n '.join(sorted('\\'%s\\'' % g.__name__ for g in sc(GCode))))"python -c "from pygcode.gcodes import GCode, _subclasses as sc; print(',\\n '.join(sorted('\\'%s\\'' % g.__name__ for g in sc(GCode))))"
'GCode',
'GCodeAbsoluteArcDistanceMode',
'GCodeAbsoluteDistanceMode',
'GCodeAdaptiveFeed',
'GCodeAddToolLengthOffset',
'GCodeAnalogOutput',
'GCodeAnalogOutputImmediate',
'GCodeAnalogOutputSyncd',
'GCodeArcMove',
'GCodeArcMoveCCW',
'GCodeArcMoveCW',
'GCodeBoringCycleDwellFeedOut',
'GCodeBoringCycleFeedOut',
'GCodeCancelCannedCycle',
'GCodeCancelToolLengthOffset',
'GCodeCannedCycle',
'GCodeCannedCycleReturnLevel',
'GCodeCannedReturnMode',
'GCodeCoolant',
'GCodeCoolantFloodOn',
'GCodeCoolantMistOn',
'GCodeCoolantOff',
'GCodeCoordSystemOffset',
'GCodeCublcSpline',
'GCodeCutterCompLeft',
'GCodeCutterCompRight',
'GCodeCutterRadiusComp',
'GCodeCutterRadiusCompOff',
'GCodeDigitalOutput',
'GCodeDigitalOutputOff',
'GCodeDigitalOutputOffSyncd',
'GCodeDigitalOutputOn',
'GCodeDigitalOutputOnSyncd',
'GCodeDistanceMode',
'GCodeDrillingCycle',
'GCodeDrillingCycleChipBreaking',
'GCodeDrillingCycleDwell',
'GCodeDrillingCyclePeck',
'GCodeDwell',
'GCodeDynamicCutterCompLeft',
'GCodeDynamicCutterCompRight',
'GCodeDynamicToolLengthOffset',
'GCodeEndProgram',
'GCodeEndProgramPalletShuttle',
'GCodeExactPathMode',
'GCodeExactStopMode',
'GCodeFeedOverride',
'GCodeFeedRate',
'GCodeFeedRateMode',
'GCodeFeedStop',
'GCodeGotoPredefinedPosition',
'GCodeIO',
'GCodeIncrementalArcDistanceMode',
'GCodeIncrementalDistanceMode',
'GCodeInverseTimeMode',
'GCodeLatheDiameterMode',
'GCodeLatheRadiusMode',
'GCodeLinearMove',
'GCodeMotion',
'GCodeMoveInMachineCoords',
'GCodeNURBS',
'GCodeNURBSEnd',
'GCodeNonModal',
'GCodeOrientSpindle',
'GCodeOtherModal',
'GCodePalletChangePause',
'GCodePathBlendingMode',
'GCodePathControlMode',
'GCodePauseProgram',
'GCodePauseProgramOptional',
'GCodePlaneSelect',
'GCodeProgramControl',
'GCodeQuadraticSpline',
'GCodeRapidMove',
'GCodeResetCoordSystemOffset',
'GCodeRestoreCoordSystemOffset',
'GCodeRigidTapping',
'GCodeSelectCoordinateSystem',
'GCodeSelectCoordinateSystem1',
'GCodeSelectCoordinateSystem2',
'GCodeSelectCoordinateSystem3',
'GCodeSelectCoordinateSystem4',
'GCodeSelectCoordinateSystem5',
'GCodeSelectCoordinateSystem6',
'GCodeSelectCoordinateSystem7',
'GCodeSelectCoordinateSystem8',
'GCodeSelectCoordinateSystem9',
'GCodeSelectTool',
'GCodeSelectUVPlane',
'GCodeSelectVWPlane',
'GCodeSelectWUPlane',
'GCodeSelectXYPlane',
'GCodeSelectYZPlane',
'GCodeSelectZXPlane',
'GCodeSet',
'GCodeSetPredefinedPosition',
'GCodeSpeedAndFeedOverrideOff',
'GCodeSpeedAndFeedOverrideOn',
'GCodeSpindle',
'GCodeSpindleConstantSurfaceSpeedMode',
'GCodeSpindleRPMMode',
'GCodeSpindleSpeed',
'GCodeSpindleSpeedMode',
'GCodeSpindleSpeedOverride',
'GCodeSpindleSyncMotion',
'GCodeStartSpindle',
'GCodeStartSpindleCCW',
'GCodeStartSpindleCW',
'GCodeStopSpindle',
'GCodeStraightProbe',
'GCodeThreadingCycle',
'GCodeToolChange',
'GCodeToolLength',
'GCodeToolLengthOffset',
'GCodeToolSetCurrent',
'GCodeUnit',
'GCodeUnitsPerMinuteMode',
'GCodeUnitsPerRevolution',
'GCodeUseInches',
'GCodeUseMillimeters',
'GCodeUserDefined',
'GCodeWaitOnInput'
]
# Machine
from .machine import (
Position, CoordinateSystem,
State, Mode,
Machine,
)
# Line
from .line import Line
# Block
from .block import Block
# Comment
from .comment import Comment, split_line
# Word
from .words import (
Word,
text2words, str2word, words2dict,
)
# GCode
from .gcodes import (
words2gcodes, text2gcodes, split_gcodes,
# $ python -c "from pygcode.gcodes import _gcode_class_infostr as x; print(x(prefix=' # '))"
# - GCode:
# - GCodeCannedCycle:
# G89 - GCodeBoringCycleDwellFeedOut: G89: Boring Cycle, Dwell, Feed Out
# G85 - GCodeBoringCycleFeedOut: G85: Boring Cycle, Feed Out
# G81 - GCodeDrillingCycle: G81: Drilling Cycle
# G73 - GCodeDrillingCycleChipBreaking: G73: Drilling Cycle, ChipBreaking
# G82 - GCodeDrillingCycleDwell: G82: Drilling Cycle, Dwell
# G83 - GCodeDrillingCyclePeck: G83: Drilling Cycle, Peck
# G76 - GCodeThreadingCycle: G76: Threading Cycle
# - GCodeCannedReturnMode:
# G98 - GCodeCannedCycleReturnLevel: G98: Canned Cycle Return Level
# - GCodeCoolant:
# M08 - GCodeCoolantFloodOn: M8: turn flood coolant on
# M07 - GCodeCoolantMistOn: M7: turn mist coolant on
# M09 - GCodeCoolantOff: M9: turn all coolant off
# - GCodeCutterRadiusComp:
# G41 - GCodeCutterCompLeft: G41: Cutter Radius Compensation (left)
# G42 - GCodeCutterCompRight: G42: Cutter Radius Compensation (right)
# G40 - GCodeCutterRadiusCompOff: G40: Cutter Radius Compensation Off
# G41.1 - GCodeDynamicCutterCompLeft: G41.1: Dynamic Cutter Radius Compensation (left)
# G42.1 - GCodeDynamicCutterCompRight: G42.1: Dynamic Cutter Radius Compensation (right)
# - GCodeDistanceMode:
# G90.1 - GCodeAbsoluteArcDistanceMode: G90.1: Absolute Distance Mode for Arc IJK Parameters
# G90 - GCodeAbsoluteDistanceMode: G90: Absolute Distance Mode
# G91.1 - GCodeIncrementalArcDistanceMode: G91.1: Incremental Distance Mode for Arc IJK Parameters
# G91 - GCodeIncrementalDistanceMode: G91: Incremental Distance Mode
# G07 - GCodeLatheDiameterMode: G7: Lathe Diameter Mode
# G08 - GCodeLatheRadiusMode: G8: Lathe Radius Mode
# - GCodeFeedRateMode:
# G93 - GCodeInverseTimeMode: G93: Inverse Time Mode
# G94 - GCodeUnitsPerMinuteMode: G94: Units Per MinuteMode
# G95 - GCodeUnitsPerRevolution: G95: Units Per Revolution
# - GCodeIO:
# - GCodeAnalogOutput: Analog Output
# M68 - GCodeAnalogOutputImmediate: M68: Analog Output, Immediate
# M67 - GCodeAnalogOutputSyncd: M67: Analog Output, Synchronized
# - GCodeDigitalOutput: Digital Output Control
# M65 - GCodeDigitalOutputOff: M65: turn off digital output immediately
# M63 - GCodeDigitalOutputOffSyncd: M63: turn off digital output synchronized with motion
# M64 - GCodeDigitalOutputOn: M64: turn on digital output immediately
# M62 - GCodeDigitalOutputOnSyncd: M62: turn on digital output synchronized with motion
# M66 - GCodeWaitOnInput: M66: Wait on Input
# - GCodeMotion:
# - GCodeArcMove: Arc Move
# G03 - GCodeArcMoveCCW: G3: Arc Move (counter-clockwise)
# G02 - GCodeArcMoveCW: G2: Arc Move (clockwise)
# G80 - GCodeCancelCannedCycle: G80: Cancel Canned Cycle
# G05 - GCodeCublcSpline: G5: Cubic Spline
# G04 - GCodeDwell: G4: Dwell
# G01 - GCodeLinearMove: G1: Linear Move
# G05.2 - GCodeNURBS: G5.2: Non-uniform rational basis spline (NURBS)
# G05.3 - GCodeNURBSEnd: G5.3: end NURBS mode
# G05.1 - GCodeQuadraticSpline: G5.1: Quadratic Spline
# G00 - GCodeRapidMove: G0: Rapid Move
# G33.1 - GCodeRigidTapping: G33.1: Rigid Tapping
# G33 - GCodeSpindleSyncMotion: G33: Spindle Synchronized Motion
# - GCodeStraightProbe: G38.2-G38.5: Straight Probe
# - GCodeNonModal:
# G92 - GCodeCoordSystemOffset: G92: Coordinate System Offset
# - GCodeGotoPredefinedPosition: G28,G30: Goto Predefined Position (rapid movement)
# G53 - GCodeMoveInMachineCoords: G53: Move in Machine Coordinates
# - GCodeResetCoordSystemOffset: G92.1,G92.2: Reset Coordinate System Offset
# G92.3 - GCodeRestoreCoordSystemOffset: G92.3: Restore Coordinate System Offset
# G10 - GCodeSet: G10: Set stuff
# - GCodeSetPredefinedPosition: G28.1,G30.1: Set Predefined Position
# M06 - GCodeToolChange: M6: Tool Change
# M61 - GCodeToolSetCurrent: M61: Set Current Tool
# - GCodeUserDefined: M101-M199: User Defined Commands
# - GCodeOtherModal:
# M52 - GCodeAdaptiveFeed: M52: Adaptive Feed Control
# M50 - GCodeFeedOverride: M50: Feed Override Control
# - GCodeFeedRate: F: Set Feed Rate
# M53 - GCodeFeedStop: M53: Feed Stop Control
# - GCodeSelectCoordinateSystem: Select Coordinate System
# G54 - GCodeSelectCoordinateSystem1: Select Coordinate System 1
# G55 - GCodeSelectCoordinateSystem2: Select Coordinate System 2
# G56 - GCodeSelectCoordinateSystem3: Select Coordinate System 3
# G57 - GCodeSelectCoordinateSystem4: Select Coordinate System 4
# G58 - GCodeSelectCoordinateSystem5: Select Coordinate System 5
# G59 - GCodeSelectCoordinateSystem6: Select Coordinate System 6
# G59.1 - GCodeSelectCoordinateSystem7: Select Coordinate System 7
# G59.2 - GCodeSelectCoordinateSystem8: Select Coordinate System 8
# G59.3 - GCodeSelectCoordinateSystem9: Select Coordinate System 9
# - GCodeSelectTool: T: Select Tool
# M49 - GCodeSpeedAndFeedOverrideOff: M49: Speed and Feed Override Control Off
# M48 - GCodeSpeedAndFeedOverrideOn: M48: Speed and Feed Override Control On
# - GCodeSpindleSpeed: S: Set Spindle Speed
# M51 - GCodeSpindleSpeedOverride: M51: Spindle Speed Override Control
# - GCodePathControlMode:
# G61 - GCodeExactPathMode: G61: Exact path mode
# G61.1 - GCodeExactStopMode: G61.1: Exact stop mode
# G64 - GCodePathBlendingMode: G64: Path Blending
# - GCodePlaneSelect:
# G17.1 - GCodeSelectUVPlane: G17.1: select UV plane
# G19.1 - GCodeSelectVWPlane: G19.1: select VW plane
# G18.1 - GCodeSelectWUPlane: G18.1: select WU plane
# G17 - GCodeSelectXYPlane: G17: select XY plane (default)
# G19 - GCodeSelectYZPlane: G19: select YZ plane
# G18 - GCodeSelectZXPlane: G18: select ZX plane
# - GCodeProgramControl:
# M02 - GCodeEndProgram: M2: Program End
# M30 - GCodeEndProgramPalletShuttle: M30: exchange pallet shuttles and end the program
# M60 - GCodePalletChangePause: M60: Pallet Change Pause
# M00 - GCodePauseProgram: M0: Program Pause
# M01 - GCodePauseProgramOptional: M1: Program Pause (optional)
# - GCodeSpindle:
# M19 - GCodeOrientSpindle: M19: Orient Spindle
# - GCodeSpindleSpeedMode:
# G96 - GCodeSpindleConstantSurfaceSpeedMode: G96: Spindle Constant Surface Speed
# G97 - GCodeSpindleRPMMode: G97: Spindle RPM Speed
# - GCodeStartSpindle: M3,M4: Start Spindle Clockwise
# M04 - GCodeStartSpindleCCW: M4: Start Spindle Counter-Clockwise
# M03 - GCodeStartSpindleCW: M3: Start Spindle Clockwise
# M05 - GCodeStopSpindle: M5: Stop Spindle
# - GCodeToolLength:
# G43.2 - GCodeAddToolLengthOffset: G43.2: Appkly Additional Tool Length Offset
# G49 - GCodeCancelToolLengthOffset: G49: Cancel Tool Length Compensation
# G43.1 - GCodeDynamicToolLengthOffset: G43.1: Dynamic Tool Length Offset
# G43 - GCodeToolLengthOffset: G43: Tool Length Offset
# - GCodeUnit:
# G20 - GCodeUseInches: G20: use inches for length units
# G21 - GCodeUseMillimeters: G21: use millimeters for length units
# $ python -c "from pygcode.gcodes import GCode, _subclasses as sc; print(',\\n '.join(sorted(g.__name__ for g in sc(GCode))))"
GCode,
GCodeAbsoluteArcDistanceMode,
GCodeAbsoluteDistanceMode,
GCodeAdaptiveFeed,
GCodeAddToolLengthOffset,
GCodeAnalogOutput,
GCodeAnalogOutputImmediate,
GCodeAnalogOutputSyncd,
GCodeArcMove,
GCodeArcMoveCCW,
GCodeArcMoveCW,
GCodeBoringCycleDwellFeedOut,
GCodeBoringCycleFeedOut,
GCodeCancelCannedCycle,
GCodeCancelToolLengthOffset,
GCodeCannedCycle,
GCodeCannedCycleReturnLevel,
GCodeCannedReturnMode,
GCodeCoolant,
GCodeCoolantFloodOn,
GCodeCoolantMistOn,
GCodeCoolantOff,
GCodeCoordSystemOffset,
GCodeCublcSpline,
GCodeCutterCompLeft,
GCodeCutterCompRight,
GCodeCutterRadiusComp,
GCodeCutterRadiusCompOff,
GCodeDigitalOutput,
GCodeDigitalOutputOff,
GCodeDigitalOutputOffSyncd,
GCodeDigitalOutputOn,
GCodeDigitalOutputOnSyncd,
GCodeDistanceMode,
GCodeDrillingCycle,
GCodeDrillingCycleChipBreaking,
GCodeDrillingCycleDwell,
GCodeDrillingCyclePeck,
GCodeDwell,
GCodeDynamicCutterCompLeft,
GCodeDynamicCutterCompRight,
GCodeDynamicToolLengthOffset,
GCodeEndProgram,
GCodeEndProgramPalletShuttle,
GCodeExactPathMode,
GCodeExactStopMode,
GCodeFeedOverride,
GCodeFeedRate,
GCodeFeedRateMode,
GCodeFeedStop,
GCodeGotoPredefinedPosition,
GCodeIO,
GCodeIncrementalArcDistanceMode,
GCodeIncrementalDistanceMode,
GCodeInverseTimeMode,
GCodeLatheDiameterMode,
GCodeLatheRadiusMode,
GCodeLinearMove,
GCodeMotion,
GCodeMoveInMachineCoords,
GCodeNURBS,
GCodeNURBSEnd,
GCodeNonModal,
GCodeOrientSpindle,
GCodeOtherModal,
GCodePalletChangePause,
GCodePathBlendingMode,
GCodePathControlMode,
GCodePauseProgram,
GCodePauseProgramOptional,
GCodePlaneSelect,
GCodeProgramControl,
GCodeQuadraticSpline,
GCodeRapidMove,
GCodeResetCoordSystemOffset,
GCodeRestoreCoordSystemOffset,
GCodeRigidTapping,
GCodeSelectCoordinateSystem,
GCodeSelectCoordinateSystem1,
GCodeSelectCoordinateSystem2,
GCodeSelectCoordinateSystem3,
GCodeSelectCoordinateSystem4,
GCodeSelectCoordinateSystem5,
GCodeSelectCoordinateSystem6,
GCodeSelectCoordinateSystem7,
GCodeSelectCoordinateSystem8,
GCodeSelectCoordinateSystem9,
GCodeSelectTool,
GCodeSelectUVPlane,
GCodeSelectVWPlane,
GCodeSelectWUPlane,
GCodeSelectXYPlane,
GCodeSelectYZPlane,
GCodeSelectZXPlane,
GCodeSet,
GCodeSetPredefinedPosition,
GCodeSpeedAndFeedOverrideOff,
GCodeSpeedAndFeedOverrideOn,
GCodeSpindle,
GCodeSpindleConstantSurfaceSpeedMode,
GCodeSpindleRPMMode,
GCodeSpindleSpeed,
GCodeSpindleSpeedMode,
GCodeSpindleSpeedOverride,
GCodeSpindleSyncMotion,
GCodeStartSpindle,
GCodeStartSpindleCCW,
GCodeStartSpindleCW,
GCodeStopSpindle,
GCodeStraightProbe,
GCodeThreadingCycle,
GCodeToolChange,
GCodeToolLength,
GCodeToolLengthOffset,
GCodeToolSetCurrent,
GCodeUnit,
GCodeUnitsPerMinuteMode,
GCodeUnitsPerRevolution,
GCodeUseInches,
GCodeUseMillimeters,
GCodeUserDefined,
GCodeWaitOnInput
)

85
src/pygcode/block.py Normal file
View File

@ -0,0 +1,85 @@
import re
from .words import text2words, WORD_MAP
from .gcodes import words2gcodes
class Block(object):
"""GCode block (effectively any gcode file line that defines any <word><value>)"""
def __init__(self, text=None, verify=True):
"""
Block Constructor
:param A-Z: gcode parameter values
:param comment: comment text
"""
self._raw_text = None
self._text = None
self.words = []
self.gcodes = []
self.modal_params = []
# clean up block string
if text:
self._raw_text = text # unaltered block content (before alteration)
text = re.sub(r'(^\s+|\s+$)', '', text) # remove whitespace padding
text = re.sub(r'\s+', ' ', text) # remove duplicate whitespace with ' '
self._text = text # cleaned up block content
# Get words from text, and group into gcodes
self.words = list(text2words(self._text))
(self.gcodes, self.modal_params) = words2gcodes(self.words)
# Verification
if verify:
self._assert_gcodes()
@property
def text(self):
if self._text:
return self._text
return str(self)
def _assert_gcodes(self):
modal_groups = set()
code_words = set()
for gc in self.gcodes:
# Assert all gcodes are not repeated in the same block
if gc.word in code_words:
raise AssertionError("%s cannot be in the same block" % ([
x for x in self.gcodes
if x.modal_group == gc.modal_group
]))
code_words.add(gc.word)
# Assert all gcodes are from different modal groups
if gc.modal_group is not None:
if gc.modal_group in modal_groups:
raise AssertionError("%s cannot be in the same block" % ([
x for x in self.gcodes
if x.modal_group == gc.modal_group
]))
modal_groups.add(gc.modal_group)
def __getattr__(self, k):
if k in WORD_MAP:
for w in self.words:
if w.letter == k:
return w
# if word is not in this block:
return None
else:
raise AttributeError("'{cls}' object has no attribute '{key}'".format(
cls=self.__class__.__name__,
key=k
))
def __bool__(self):
return bool(self.words)
__nonzero__ = __bool__ # python < 3 compatability
def __str__(self):
return ' '.join(str(x) for x in (self.gcodes + self.modal_params))

67
src/pygcode/comment.py Normal file
View File

@ -0,0 +1,67 @@
import re
class CommentBase(object):
ORDER = 0
MULTICOMMENT_JOINER = ". " # joiner if multiple comments are found on the same line
def __init__(self, text):
self.text = text
def __repr__(self):
return "<{class_name}: '{comment}'>".format(
class_name=self.__class__.__name__,
comment=str(self),
)
class CommentSemicolon(CommentBase):
"Comments of the format: 'G00 X1 Y2 ; something profound'"
ORDER = 1
AUTO_REGEX = re.compile(r'\s*;\s*(?P<text>.*)$')
def __str__(self):
return "; {text}".format(text=self.text)
class CommentBrackets(CommentBase):
"Comments of the format: 'G00 X1 Y2 (something profound)"
ORDER = 2
AUTO_REGEX = re.compile(r'\((?P<text>[^\)]*)\)')
def __str__(self):
return "({text})".format(text=self.text)
Comment = CommentBrackets # default comment type
def split_line(line_text):
"""
Split functional block content from comments
:param line_text: line from gcode file
:return: tuple of (str(<functional block code>), CommentBase(<comment(s)>))
"""
comments_class = None
# Auto-detect comment type if I can
comments = []
block_str = line_text.rstrip("\n") # to remove potential return carriage from comment body
for cls in sorted(CommentBase.__subclasses__(), key=lambda c: c.ORDER):
matches = list(cls.AUTO_REGEX.finditer(block_str))
if matches:
for match in reversed(matches):
# Build list of comment text
comments.insert(0, match.group('text')) # prepend
# Remove comments from given block_str
block_str = block_str[:match.start()] + block_str[match.end():]
comments_class = cls
break
# Create comment instance if content was found
comment_obj = None
if comments_class:
comment_text = comments_class.MULTICOMMENT_JOINER.join(comments)
comment_obj = comments_class(comment_text)
return (block_str, comment_obj)

18
src/pygcode/exceptions.py Normal file
View File

@ -0,0 +1,18 @@
# ===================== Parsing Exceptions =====================
class GCodeBlockFormatError(Exception):
"""Raised when errors encountered while parsing block text"""
class GCodeParameterError(Exception):
"""Raised for conflicting / invalid / badly formed parameters"""
class GCodeWordStrError(Exception):
"""Raised when issues found while parsing a word string"""
# ===================== Machine Exceptions =====================
class MachineInvalidAxis(Exception):
"""Raised if an axis is invalid"""
# For example: for axes X/Y/Z, set the value of "Q"; wtf?
class MachineInvalidState(Exception):
"""Raised if a machine state is set incorrectly, or in conflict"""

1533
src/pygcode/gcodes.py Normal file

File diff suppressed because it is too large Load Diff

31
src/pygcode/line.py Normal file
View File

@ -0,0 +1,31 @@
from .comment import split_line
from .block import Block
class Line(object):
def __init__(self, text=None):
self._text = text
# Initialize
self.block = None
self.comment = None
# Split line into block text, and comments
if text is not None:
(block_str, comment) = split_line(text)
self.block = Block(block_str)
if comment:
self.comment = comment
@property
def text(self):
if self._text is None:
return str(self)
return self._text
@property
def gcodes(self):
"""self.block.gcodes passthrough"""
return self.block.gcodes
def __str__(self):
return ' '.join([str(x) for x in [self.block, self.comment] if x])

443
src/pygcode/machine.py Normal file
View File

@ -0,0 +1,443 @@
from copy import copy, deepcopy
from collections import defaultdict
from .gcodes import (
MODAL_GROUP_MAP, GCode,
# Modal GCodes
GCodeIncrementalDistanceMode,
GCodeUseInches, GCodeUseMillimeters,
# Utilities
words2gcodes,
)
from .block import Block
from .line import Line
from .words import Word
from .utils import Vector3, Quaternion
from .exceptions import MachineInvalidAxis, MachineInvalidState
UNIT_IMPERIAL = GCodeUseInches.unit_id # G20
UNIT_METRIC = GCodeUseMillimeters.unit_id # G21
UNIT_MAP = {
UNIT_IMPERIAL: {
'name': 'inches',
'conversion_factor': { UNIT_METRIC: 25.4 },
},
UNIT_METRIC: {
'name': 'millimeters',
'conversion_factor': { UNIT_IMPERIAL: 1. / 25.4 },
},
}
class Position(object):
default_axes = 'XYZABCUVW'
default_unit = UNIT_METRIC
POSSIBLE_AXES = set('XYZABCUVW')
def __init__(self, axes=None, **kwargs):
# Set axes (note: usage in __getattr__ and __setattr__)
if axes is None:
axes = self.__class__.default_axes
else:
invalid_axes = set(axes) - self.POSSIBLE_AXES
if invalid_axes:
raise MachineInvalidAxis("invalid axes proposed %s" % invalid_axes)
self.__dict__['axes'] = set(axes) & self.POSSIBLE_AXES
# Unit
self._unit = kwargs.pop('unit', self.default_unit)
# Initial Values
self._value = defaultdict(lambda: 0.0, dict((k, 0.0) for k in self.axes))
self._value.update(kwargs)
def update(self, **coords):
for (k, v) in coords.items():
setattr(self, k, v)
# Attributes Get/Set
def __getattr__(self, key):
if key in self.axes:
return self._value[key]
raise AttributeError("'{cls}' object has no attribute '{key}'".format(
cls=self.__class__.__name__,
key=key
))
def __setattr__(self, key, value):
if key in self.axes:
self._value[key] = value
elif key in self.POSSIBLE_AXES:
raise MachineInvalidAxis("'%s' axis is not defined to be set" % key)
else:
self.__dict__[key] = value
# Copy
def __copy__(self):
return self.__class__(axes=copy(self.axes), unit=self._unit, **self.values)
# Equality
def __eq__(self, other):
if self.axes ^ other.axes:
return False
else:
if self._unit == other._unit:
return self._value == other._value
else:
x = copy(other)
x.set_unit(self._unit)
return self._value == x._value
def __ne__(self, other):
return not self.__eq__(other)
# Arithmetic
def __add__(self, other):
if self.axes ^ other.axes:
raise MachineInvalidAxis("axes: %r != %r" % (self.axes, other.axes))
new_obj = copy(self)
for k in new_obj._value:
new_obj._value[k] += other._value[k]
return new_obj
def __sub__(self, other):
if other.axes - self.axes:
raise MachineInvalidAxis("for a - b: axes in b, that are not in a: %r" % (other.axes - self.axes))
new_obj = copy(self)
for k in other._value:
new_obj._value[k] -= other._value[k]
return new_obj
def __mul__(self, scalar):
new_obj = copy(self)
for k in self._value:
new_obj._value[k] = self._value[k] * scalar
return new_obj
def __div__(self, scalar):
new_obj = copy(self)
for k in self._value:
new_obj._value[k] = self._value[k] / scalar
return new_obj
__truediv__ = __div__ # Python 3 division
# Conversion
def set_unit(self, unit):
if unit == self._unit:
return
factor = UNIT_MAP[self._unit]['conversion_factor'][unit]
for k in [k for (k, v) in self._value.items() if v is not None]:
self._value[k] *= factor
self._unit = unit
@property
def words(self):
return sorted(Word(k, self._value[k]) for k in self.axes)
@property
def values(self):
return dict(self._value)
@property
def vector(self):
return Vector3(self._value['X'], self._value['Y'], self._value['Z'])
def __repr__(self):
return "<{class_name}: {coordinates}>".format(
class_name=self.__class__.__name__,
coordinates=' '.join(str(w) for w in self.words)
)
class CoordinateSystem(object):
def __init__(self, axes):
self.offset = Position(axes)
def __add__(self, other):
if isinstance(other, CoordinateSystem):
pass
def __repr__(self):
return "<{class_name}: offset={offset}>".format(
class_name=self.__class__.__name__,
offset=repr(self.offset),
)
class State(object):
"""State of a Machine"""
# LinuxCNC documentation lists parameters for a machine's state:
# http://linuxcnc.org/docs/html/gcode/overview.html#sub:numbered-parameters
# AFAIK: this is everything needed to remember a machine's state that isn't
# handled by modal gcodes.
def __init__(self, axes=None):
# Coordinate Systems
self.coord_systems = {}
for i in range(1, 10): # G54-G59.3
self.coord_systems[i] = CoordinateSystem(axes)
self.cur_coord_sys = 1 # default to coord system 1 (G54)
# Temporary Offset
self.offset = Position(axes) # G92 offset (reset by G92.x)
# Missing from state (according to LinuxCNC's state variables):
# - G38.2 probe result (Position())
# - G38 probe result (bool)
# - M66: result (bool)
# - Tool offsets (Position())
# - Tool info (number, diameter, front angle, back angle, orientation)
#self.work_offset = defaultdict(lambda: 0.0)
# TODO: how to manage work offsets? (probs not like the above)
# read up on:
# Coordinate System config:
# - G92: set machine coordinate system value (no movement, effects all coordinate systems)
# - G10 L2: offsets the origin of the axes in the coordinate system specified to the value of the axis word
# - G10 L20: makes the current machine coordinates the coordinate system's offset
# Coordinate System selection:
# - G54-G59: select coordinate system (offsets from machine coordinates set by G10 L2)
# TODO: Move this class into MachineState
@property
def coord_sys(self):
"""Current equivalent coordinate system, including all """
if self.cur_coord_sys in self.coord_systems:
return self.coord_systems[self.cur_coord_sys]
return None
def __repr__(self):
return "<{class_name}: coord_sys[{coord_index}]; offset={offset}>".format(
class_name=self.__class__.__name__,
coord_index=self.cur_coord_sys,
offset=repr(self.offset),
)
class Mode(object):
"""Machine's mode"""
# State is very forgiving:
# Anything possible in a machine's state may be changed & fetched.
# For example: x, y, z, a, b, c may all be set & requested.
# 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
# > [GC:G0 G54 G17 G21 G90 G94 M5 M9 T0 F0 S0]
# ref: https://github.com/gnea/grbl/wiki/Grbl-v1.1-Commands#g---view-gcode-parser-state
default_mode = '''
G0 (movement: rapid)
G17 (plane_selection: X/Y plane)
G90 (distance: absolute position. ie: not "turtle" mode)
G91.1 (arc_ijk_distance: IJK sets arc center vertex relative to current position)
G94 (feed_rate_mode: feed-rate defined in units/min)
G21 (units: mm)
G40 (cutter_diameter_comp: no compensation)
G49 (tool_length_offset: no offset)
G54 (coordinate_system: 1)
G61 (control_mode: exact path mode)
G97 (spindle_speed_mode: RPM Mode)
M5 (spindle: off)
M9 (coolant: off)
F0 (feed_rate: 0)
S0 (spindle_speed: 0)
T0 (tool: 0)
'''
# Mode is defined by gcodes set by processed blocks:
# see modal_group in gcode.py module for details
def __init__(self):
self.modal_groups = defaultdict(lambda: None)
# Initialize
self.set_mode(*Line(self.default_mode).block.gcodes)
def set_mode(self, *gcode_list):
"""
Set machine mode from given gcodes (will not be processed)
:param gcode_list: list of GCode instances (given as individual parameters)
:return: dict of form: {<modal group>: <new mode GCode>, ...}
"""
modal_gcodes = {}
for g in sorted(gcode_list): # sorted by execution order
if g.modal_group is not None:
self.modal_groups[g.modal_group] = g.modal_copy()
modal_gcodes[g.modal_group] = self.modal_groups[g.modal_group]
# assumption: no 2 gcodes are in the same modal_group
return modal_gcodes
def __getattr__(self, key):
if key in MODAL_GROUP_MAP:
return self.modal_groups[MODAL_GROUP_MAP[key]]
raise AttributeError("'{cls}' object has no attribute '{key}'".format(
cls=self.__class__.__name__,
key=key
))
def __setattr__(self, key, value):
if key in MODAL_GROUP_MAP:
# Set/Clear modal group gcode
if value is None:
# clear mode group
self.modal_groups[MODAL_GROUP_MAP[key]] = None
else:
# set mode group explicitly, not advisable
# (recommended to use self.set_mode(value) instead)
if not isinstance(value, GCode):
raise MachineInvalidState("invalid mode value: %r" % value)
if value.modal_group != MODAL_GROUP_MAP[key]:
raise MachineInvalidState("cannot set '%s' mode as %r, wrong group" % (key, value))
self.modal_groups[MODAL_GROUP_MAP[key]] = value.modal_copy()
else:
self.__dict__[key] = value
@property
def gcodes(self):
"""List of modal gcodes"""
gcode_list = []
for modal_group in sorted(MODAL_GROUP_MAP.values()):
if self.modal_groups[modal_group]:
gcode_list.append(self.modal_groups[modal_group])
return gcode_list
def __str__(self):
return ' '.join(str(g) for g in self.gcodes)
def __repr__(self):
return "<{class_name}: {gcodes}>".format(
class_name=self.__class__.__name__, gcodes=str(self)
)
class Machine(object):
"""Machine to process gcodes, enforce axis limits, keep track of time, etc"""
# Class types
MODE_CLASS = Mode
STATE_CLASS = State
axes = set('XYZ')
def __init__(self):
self.mode = self.MODE_CLASS()
self.state = self.STATE_CLASS(axes=self.axes)
# Position type (with default axes the same as this machine)
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,
})
# Absolute machine position
self.abs_pos = self.Position()
def set_mode(self, *gcode_list):
self.mode.set_mode(*gcode_list) # passthrough
# Act on mode changes
coord_sys_mode = self.mode.coordinate_system
if coord_sys_mode:
self.state.cur_coord_sys = coord_sys_mode.coord_system_id
def modal_gcode(self, modal_params):
if not modal_params:
return None
if self.mode.motion is None:
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())
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
))
if modal_gcodes:
assert len(modal_gcodes) == 1, "more than 1 modal code found"
return modal_gcodes[0]
return None
def block_modal_gcodes(self, block):
"""
Block's GCode list in current machine mode
:param block: Block instance
:return: list of gcodes, block.gcodes + <modal gcode, if there is one>
"""
assert isinstance(block, Block), "invalid parameter"
gcodes = copy(block.gcodes)
modal_gcode = self.modal_gcode(block.modal_params)
if modal_gcode:
gcodes.append(modal_gcode)
return sorted(gcodes)
def process_gcodes(self, *gcode_list, **kwargs):
"""
Process gcodes
:param gcode_list: list of GCode instances
:param modal_params: list of Word instances to be applied to current movement mode
"""
gcode_list = list(gcode_list) # make appendable
# Add modal gcode to list of given gcodes
modal_params = kwargs.get('modal_params', [])
if modal_params:
modal_gcode = self.modal_gcode(modal_params)
if modal_gcode:
gcode_list.append(modal_gcode)
for gcode in sorted(gcode_list):
gcode.process(self) # shifts ownership of what happens now to GCode class
# TODO: gcode instance to change machine's state
# Questions to drive design:
# - how much time did the command take?
# - what was the tool's distance / displacement
# - did the tool travel outside machine boundaries?
# Use-cases
# - Transform / rotate coordinate system in given gcode
# - Convert arcs to linear segments (visa versa?)
# - Correct precision errors
# - Crop a file (eg: resume half way through)
def process_block(self, block):
self.process_gcodes(*block.gcodes, modal_params=block.modal_params)
def process_str(self, block_str):
line = Line(block_str)
self.process_block(line.block)
@property
def pos(self):
"""Return current position in current coordinate system"""
coord_sys_offset = getattr(self.state.coord_sys, 'offset', Position(axes=self.axes))
temp_offset = self.state.offset
return (self.abs_pos - coord_sys_offset) - temp_offset
@pos.setter
def pos(self, value):
"""Set absolute position given current position and coordinate system"""
coord_sys_offset = getattr(self.state.coord_sys, 'offset', Position(axes=self.axes))
temp_offset = self.state.offset
self.abs_pos = (value + temp_offset) + coord_sys_offset
# =================== Machine Actions ===================
def move_to(self, rapid=False, **coords):
"""Move machine to given position"""
if isinstance(self.mode.distance, GCodeIncrementalDistanceMode):
pos_delta = Position(axes=self.axes, **coords)
self.pos += pos_delta
else: # assumed: GCodeAbsoluteDistanceMode
new_pos = self.pos
new_pos.update(**coords) # only change given coordinates
self.pos = new_pos

485
src/pygcode/transform.py Normal file
View File

@ -0,0 +1,485 @@
from math import sin, cos, tan, asin, acos, atan2, pi, sqrt, ceil
from .gcodes import GCodeLinearMove, GCodeRapidMove
from .gcodes import GCodeArcMove, GCodeArcMoveCW, GCodeArcMoveCCW
from .gcodes import GCodePlaneSelect, GCodeSelectXYPlane, GCodeSelectYZPlane, GCodeSelectZXPlane
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 _gcodes_abs2rel
from .machine import Position
from .exceptions import GCodeParameterError
from .utils import Vector3, Quaternion, plane_projection
# ==================== Arcs (G2,G3) --> Linear Motion (G1) ====================
class ArcLinearizeMethod(object):
# Chord Phase Offest:
# False : each line will span an equal portion of the arc
# True : the first & last chord will span 1/2 the angular distance of all other chords
chord_phase_offset = False
def __init__(self, max_error, plane_normal,
arc_p_start, arc_p_end, arc_p_center,
arc_radius, arc_angle, helical_start, helical_end):
self.max_error = max_error
self.plane_normal = plane_normal
self.arc_p_start = arc_p_start
self.arc_p_end = arc_p_end
self.arc_p_center = arc_p_center
self.arc_radius = arc_radius
self.arc_angle = arc_angle
self.helical_start = helical_start
self.helical_end = helical_end
if self.max_error > self.arc_radius:
self.max_error = self.arc_radius
# Initializing
self._max_wedge_angle = None
self._wedge_count = None
self._wedge_angle = None
self._inner_radius = None
self._outer_radius = None
# Overridden Functions
def get_max_wedge_angle(self):
"""Calculate angular coverage of a single line reaching maximum allowable error"""
raise NotImplementedError("not overridden")
def get_inner_radius(self):
"""Radius each line is tangential to"""
# IMPORTANT: when overriding, calculate this using self.wedge_angle,
# (self.wedge_angle will almost always be < self.max_wedge_angle)
raise NotImplementedError("not overridden")
def get_outer_radius(self):
"""Radius from which each line forms a chord"""
# IMPORTANT: when overriding, calculate this using self.wedge_angle,
# (self.wedge_angle will almost always be < self.max_wedge_angle)
raise NotImplementedError("not overridden")
# Properties
@property
def max_wedge_angle(self):
if self._max_wedge_angle is None:
self._max_wedge_angle = self.get_max_wedge_angle()
return self._max_wedge_angle
@property
def wedge_count(self):
"""
Number of full wedges covered across the arc.
NB: if there is phase offset, then the actual number of linearized lines
is this + 1, because the first and last are considered to be the
same 'wedge'.
"""
if self._wedge_count is None:
self._wedge_count = int(ceil(abs(self.arc_angle) / self.max_wedge_angle))
return self._wedge_count
@property
def wedge_angle(self):
"""Angle each major chord stretches across the original arc"""
if self._wedge_angle is None:
self._wedge_angle = self.arc_angle / self.wedge_count
return self._wedge_angle
@property
def inner_radius(self):
if self._inner_radius is None:
self._inner_radius = self.get_inner_radius()
return self._inner_radius
@property
def outer_radius(self):
if self._outer_radius is None:
self._outer_radius = self.get_outer_radius()
return self._outer_radius
# Vertex Generator
def iter_vertices(self):
"""Yield absolute (<start vertex>, <end vertex>) for each line for the arc"""
start_vertex = self.arc_p_start - self.arc_p_center
outer_vertex = start_vertex.normalized() * self.outer_radius
d_helical = self.helical_end - self.helical_start
l_p_start = self.arc_p_center + start_vertex
l_start = l_p_start + self.helical_start
for i in range(self.wedge_count):
wedge_number = i + 1
# Current angle
cur_angle = self.wedge_angle * wedge_number
if self.chord_phase_offset:
cur_angle -= self.wedge_angle / 2.
elif wedge_number >= self.wedge_count:
break # stop 1 iteration short
# alow last arc to simply span across:
# <the end of the last line> -> <circle's end point>
# Next end point as projected on selected plane
q_end = Quaternion.new_rotate_axis(angle=cur_angle, axis=-self.plane_normal)
l_p_end = (q_end * outer_vertex) + self.arc_p_center
# += helical displacement (difference along plane's normal)
helical_displacement = self.helical_start + (d_helical * (cur_angle / self.arc_angle))
l_end = l_p_end + helical_displacement
yield (l_start, l_end)
# start of next line is the end of this one
l_start = l_end
# Last line always ends at the circle's end
yield (l_start, self.arc_p_end + self.helical_end)
class ArcLinearizeInside(ArcLinearizeMethod):
"""Start and end points of each line are on the original arc"""
# Attributes / Trade-offs:
# - Errors cause arc to be slightly smaller
# - pocket milling action will remove less material
# - perimeter milling action will remove more material
# - Each line is the same length
# - Simplest maths, easiest to explain & visually verify
chord_phase_offset = False
def get_max_wedge_angle(self):
"""Calculate angular coverage of a single line reaching maximum allowable error"""
return abs(2 * acos((self.arc_radius - self.max_error) / self.arc_radius))
def get_inner_radius(self):
"""Radius each line is tangential to"""
return abs(cos(self.wedge_angle / 2.) * self.arc_radius)
def get_outer_radius(self):
"""Radius from which each line forms a chord"""
return self.arc_radius
class ArcLinearizeOutside(ArcLinearizeMethod):
"""Mid-points of each line are on the original arc, first and last lines are 1/2 length"""
# Attributes / Trade-offs:
# - Errors cause arc to be slightly larger
# - pocket milling action will remove more material
# - perimeter milling action will remove less material
# - 1st and last lines are 1/2 length of the others
chord_phase_offset = True
def get_max_wedge_angle(self):
"""Calculate angular coverage of a single line reaching maximum allowable error"""
return abs(2 * acos(self.arc_radius / (self.arc_radius + self.max_error)))
def get_inner_radius(self):
"""Radius each line is tangential to"""
return self.arc_radius
def get_outer_radius(self):
"""Radius from which each line forms a chord"""
return abs(self.arc_radius / cos(self.wedge_angle / 2.))
class ArcLinearizeMid(ArcLinearizeMethod):
"""Lines cross original arc from tangent of arc radius - precision/2, until it reaches arc radius + precision/2"""
# Attributes / Trade-offs:
# - Closest to original arc (error distances are equal inside and outside the arc)
# - Most complex to calculate (but who cares, that's only done once)
# - default linearizing method as it's probably the best
chord_phase_offset = True
def get_max_wedge_angle(self):
"""Calculate angular coverage of a single line reaching maximum allowable error"""
d_radius = self.max_error / 2.
return abs(2. * acos((self.arc_radius - d_radius) / (self.arc_radius + d_radius)))
def get_inner_radius(self):
"""Radius each line is tangential to"""
d_radius = self.arc_radius * (tan(self.wedge_angle / 4.) ** 2)
return self.arc_radius - d_radius
def get_outer_radius(self):
"""Radius from which each line forms a chord"""
d_radius = self.arc_radius * (tan(self.wedge_angle / 4.) ** 2)
return self.arc_radius + d_radius
DEFAULT_LA_METHOD = ArcLinearizeMid
DEFAULT_LA_PLANE = GCodeSelectXYPlane
DEFAULT_LA_DISTMODE = GCodeAbsoluteDistanceMode
DEFAULT_LA_ARCDISTMODE = GCodeIncrementalArcDistanceMode
def linearize_arc(arc_gcode, start_pos, plane=None, method_class=None,
dist_mode=None, arc_dist_mode=None,
max_error=0.01, decimal_places=3):
"""
Convert a G2,G3 arc into a series of approsimation G1 codes
:param arc_gcode: arc gcode to approximate (GCodeArcMove)
:param start_pos: current machine position (Position)
:param plane: machine's active plane (GCodePlaneSelect)
:param method_class: method of linear approximation (ArcLinearizeMethod)
:param dist_mode: machine's distance mode (GCodeAbsoluteDistanceMode or GCodeIncrementalDistanceMode)
:param arc_dist_mode: machine's arc distance mode (GCodeAbsoluteArcDistanceMode or GCodeIncrementalArcDistanceMode)
:param max_error: maximum distance approximation arcs can stray from original arc (float)
:param decimal_places: number of decimal places gocde will be rounded to, used to mitigate risks of accumulated eror when in incremental distance mode (int)
"""
# set defaults
if method_class is None:
method_class = DEFAULT_LA_method_class
if plane is None:
plane = DEFAULT_LA_PLANE()
if dist_mode is None:
dist_mode = DEFAULT_LA_DISTMODE()
if arc_dist_mode is None:
arc_dist_mode = DEFAULT_LA_ARCDISTMODE()
# Parameter Type Assertions
assert isinstance(arc_gcode, GCodeArcMove), "bad arc_gcode type: %r" % arc_gcode
assert isinstance(start_pos, Position), "bad start_pos type: %r" % start_pos
assert isinstance(plane, GCodePlaneSelect), "bad plane type: %r" % plane
assert issubclass(method_class, ArcLinearizeMethod), "bad method_class type: %r" % method_class
assert isinstance(dist_mode, (GCodeAbsoluteDistanceMode, GCodeIncrementalDistanceMode)), "bad dist_mode type: %r" % dist_mode
assert isinstance(arc_dist_mode, (GCodeAbsoluteArcDistanceMode, GCodeIncrementalArcDistanceMode)), "bad arc_dist_mode type: %r" % arc_dist_mode
assert max_error > 0, "max_error must be > 0"
# Arc Start
arc_start = start_pos.vector
# Arc End
if isinstance(dist_mode, GCodeAbsoluteDistanceMode):
# given coordinates override those already defined
arc_end_coords = dict(zip('xyz', arc_start.xyz))
arc_end_coords.update(arc_gcode.get_param_dict('XYZ', lc=True))
arc_end = Vector3(**arc_end_coords)
else:
# given coordinates are += to arc's start coords
arc_end = arc_start + Vector3(**arc_gcode.get_param_dict('XYZ', lc=True))
# Planar Projections
arc_p_start = plane_projection(arc_start, plane.normal)
arc_p_end = plane_projection(arc_end, plane.normal)
# Arc radius, calcualted one of 2 ways:
# - R: arc radius is provided
# - IJK: arc's center-point is given, errors mitigated
arc_gcode.assert_params()
if 'R' in arc_gcode.params:
# R: radius magnitude specified
if abs(arc_p_start - arc_p_end) < max_error:
raise GCodeParameterError(
"arc starts and finishes in the same spot; cannot "
"speculate where circle's center is: %r" % arc_gcode
)
arc_radius = abs(arc_gcode.R) # arc radius (magnitude)
else:
# IJK: radius vertex specified
arc_center_ijk = dict((l, 0.) for l in 'IJK')
arc_center_ijk.update(arc_gcode.get_param_dict('IJK'))
arc_center_coords = dict(({'I':'x','J':'y','K':'z'}[k], v) for (k, v) in arc_center_ijk.items())
arc_center = Vector3(**arc_center_coords)
if isinstance(arc_dist_mode, GCodeIncrementalArcDistanceMode):
arc_center += start_pos.vector
# planar projection
arc_p_center = plane_projection(arc_center, plane.normal)
# Radii
r1 = arc_p_start - arc_p_center
r2 = arc_p_end - arc_p_center
# average the 2 radii to get the most accurate radius
arc_radius = (abs(r1) + abs(r2)) / 2.
# Find Circle's Center (given radius)
arc_span = arc_p_end - arc_p_start # vector spanning from start -> end
arc_span_mid = arc_span * 0.5 # arc_span's midpoint
if arc_radius < abs(arc_span_mid):
raise GCodeParameterError("circle cannot reach endpoint at this radius: %r" % arc_gcode)
# vector from arc_span midpoint -> circle's centre
radius_mid_vect = arc_span_mid.normalized().cross(plane.normal) * sqrt(arc_radius**2 - abs(arc_span_mid)**2)
if 'R' in arc_gcode.params:
# R: radius magnitude specified
if isinstance(arc_gcode, GCodeArcMoveCW) == (arc_gcode.R < 0):
arc_p_center = arc_p_start + arc_span_mid - radius_mid_vect
else:
arc_p_center = arc_p_start + arc_span_mid + radius_mid_vect
else:
# IJK: radius vertex specified
# arc_p_center is defined as per IJK params, this is an adjustment
arc_p_center_options = [
arc_p_start + arc_span_mid - radius_mid_vect,
arc_p_start + arc_span_mid + radius_mid_vect
]
if abs(arc_p_center_options[0] - arc_p_center) < abs(arc_p_center_options[1] - arc_p_center):
arc_p_center = arc_p_center_options[0]
else:
arc_p_center = arc_p_center_options[1]
# Arc's angle (first rotated back to xy plane)
xy_c2start = plane.quat * (arc_p_start - arc_p_center)
xy_c2end = plane.quat * (arc_p_end - arc_p_center)
(a1, a2) = (atan2(*xy_c2start.yx), atan2(*xy_c2end.yx))
if isinstance(arc_gcode, GCodeArcMoveCW):
arc_angle = (a1 - a2) % (2 * pi)
else:
arc_angle = -((a2 - a1) % (2 * pi))
# Helical interpolation
helical_start = plane.normal * arc_start.dot(plane.normal)
helical_end = plane.normal * arc_end.dot(plane.normal)
# Parameters determined above:
# - arc_p_start arc start point
# - arc_p_end arc end point
# - arc_p_center arc center
# - arc_angle angle between start & end (>0 is ccw, <0 is cw) (radians)
# - helical_start distance along plane.normal of arc start
# - helical_disp distance along plane.normal of arc end
method_class_params = {
'max_error': max_error,
'plane_normal': plane.normal,
'arc_p_start': arc_p_start,
'arc_p_end': arc_p_end,
'arc_p_center': arc_p_center,
'arc_radius': arc_radius,
'arc_angle': arc_angle,
'helical_start': helical_start,
'helical_end': helical_end,
}
method = method_class(**method_class_params)
# Iterate & yield each linear line (start, end) vertices
if isinstance(dist_mode, GCodeAbsoluteDistanceMode):
# Absolute coordinates
for line_vertices in method.iter_vertices():
(l_start, l_end) = line_vertices
yield GCodeLinearMove(**dict(zip('XYZ', l_end.xyz)))
else:
# Incremental coordinates (beware cumulative errors)
cur_pos = arc_start
for line_vertices in method.iter_vertices():
(l_start, l_end) = line_vertices
l_delta = l_end - cur_pos
# round delta coordinates (introduces errors)
for axis in 'xyz':
setattr(l_delta, axis, round(getattr(l_delta, axis), decimal_places))
yield GCodeLinearMove(**dict(zip('XYZ', l_delta.xyz)))
cur_pos += l_delta # mitigate errors by also adding them the accumulated cur_pos
# ==================== Un-Canning ====================
DEFAULT_SCC_PLANE = GCodeSelectXYPlane
DEFAULT_SCC_DISTMODE = GCodeAbsoluteDistanceMode
DEFAULT_SCC_RETRACTMODE = GCodeCannedCycleReturnLevel
def simplify_canned_cycle(canned_gcode, start_pos,
plane=None, dist_mode=None, retract_mode=None,
axes='XYZ'):
"""
Simplify canned cycle into it's basic linear components
:param canned_gcode: canned gcode to be simplified (GCodeCannedCycle)
:param start_pos: current machine position (Position)
:param plane: machine's active plane (GCodePlaneSelect)
:param dist_mode: machine's distance mode (GCodeAbsoluteDistanceMode or GCodeIncrementalDistanceMode)
:param axes: axes machine accepts (set)
"""
# set defaults
if plane is None:
plane = DEFAULT_SCC_PLANE()
if dist_mode is None:
dist_mode = DEFAULT_SCC_DISTMODE()
if retract_mode is None:
retract_mode = DEFAULT_SCC_RETRACTMODE()
# Parameter Type Assertions
assert isinstance(canned_gcode, GCodeCannedCycle), "bad canned_gcode type: %r" % canned_gcode
assert isinstance(start_pos, Position), "bad start_pos type: %r" % start_pos
assert isinstance(plane, GCodePlaneSelect), "bad plane type: %r" % plane
assert isinstance(dist_mode, (GCodeAbsoluteDistanceMode, GCodeIncrementalDistanceMode)), "bad dist_mode type: %r" % dist_mode
assert isinstance(retract_mode, GCodeCannedReturnMode), "bad retract_mode type: %r" % retract_mode
# TODO: implement for planes other than XY
if not isinstance(plane, GCodeSelectXYPlane):
raise NotImplementedError("simplifying canned cycles for planes other than X/Y has not been implemented")
@_gcodes_abs2rel(start_pos=start_pos, dist_mode=dist_mode, axes=axes)
def inner():
cycle_count = 1 if (canned_gcode.L is None) else canned_gcode.L
cur_hole_p_axis = start_pos.vector
for i in range(cycle_count):
# Calculate Depths
if isinstance(dist_mode, GCodeAbsoluteDistanceMode):
retract_depth = canned_gcode.R
drill_depth = canned_gcode.Z
cur_hole_p_axis = Vector3(x=canned_gcode.X, y=canned_gcode.Y)
else: # incremental
retract_depth = start_pos.Z + canned_gcode.R
drill_depth = retract_depth + canned_gcode.Z
cur_hole_p_axis += Vector3(x=canned_gcode.X, y=canned_gcode.Y)
if retract_depth < drill_depth:
raise NotImplementedError("drilling upward is not supported")
if isinstance(retract_mode, GCodeCannedCycleReturnToR):
final_depth = retract_depth
else:
final_depth = start_pos.Z
# Move above hole (height of retract_depth)
if retract_depth > start_pos.Z:
yield GCodeRapidMove(Z=retract_depth)
yield GCodeRapidMove(X=cur_hole_p_axis.x, Y=cur_hole_p_axis.y)
if retract_depth < start_pos.Z:
yield GCodeRapidMove(Z=retract_depth)
# Drill hole
delta = drill_depth - retract_depth # full depth
if isinstance(canned_gcode, (GCodeDrillingCyclePeck, GCodeDrillingCycleChipBreaking)):
delta = -abs(canned_gcode.Q)
cur_depth = retract_depth
last_depth = cur_depth
while True:
# Determine new depth
cur_depth += delta
if cur_depth < drill_depth:
cur_depth = drill_depth
# Rapid to just above, then slowly drill through delta
just_above_base = last_depth + 0.1
if just_above_base < retract_depth:
yield GCodeRapidMove(Z=just_above_base)
yield GCodeLinearMove(Z=cur_depth)
if cur_depth <= drill_depth:
break # loop stops at the bottom of the hole
else:
# back up
if isinstance(canned_gcode, GCodeDrillingCycleChipBreaking):
# retract "a bit"
yield GCodeRapidMove(Z=cur_depth + 0.5) # TODO: configurable retraction
else:
# default behaviour: GCodeDrillingCyclePeck
yield GCodeRapidMove(Z=retract_depth)
last_depth = cur_depth
# Dwell
if isinstance(canned_gcode, GCodeDrillingCycleDwell):
yield GCodeDwell(P=0.5) # TODO: configurable pause
# Return
yield GCodeRapidMove(Z=final_depth)
return inner()

96
src/pygcode/utils.py Normal file
View File

@ -0,0 +1,96 @@
import sys
from copy import copy, deepcopy
if sys.version_info < (3, 0):
from euclid import Vector3, Quaternion
else:
from euclid3 import Vector3, Quaternion
# ==================== Geometric Utilities ====================
def quat2align(to_align, with_this, normalize=True):
"""
Calculate Quaternion that will rotate a given vector to align with another
can accumulate with perpendicular alignemnt vectors like so:
(x_axis, z_axis) = (Vector3(1, 0, 0), Vector3(0, 0, 1))
q1 = quat2align(v1, z_axis)
q2 = quat2align(q1 * v2, x_axis)
# assuming v1 is perpendicular to v2
q3 = q2 * q1 # application of q3 to any vector will re-orient it to the
# coordinate system defined by (v1,v2), as (z,x) respectively.
:param to_align: Vector3 instance to be rotated
:param with_this: Vector3 instance as target for alignment
:result: Quaternion such that: q * to_align == with_this
"""
# Normalize Vectors
if normalize:
to_align = to_align.normalized()
with_this = with_this.normalized()
# Calculate Quaternion
return Quaternion.new_rotate_axis(
angle=to_align.angle(with_this),
axis=to_align.cross(with_this),
)
def quat2coord_system(origin1, origin2, align1, align2):
"""
Calculate Quaternion to apply to any vector to re-orientate it to another
(target) coordinate system.
(note: both origin and align coordinate systems must use right-hand-rule)
:param origin1: origin(1|2) are perpendicular vectors in the original coordinate system
:param origin2: see origin1
:param align1: align(1|2) are 2 perpendicular vectors in the target coordinate system
:param align2: see align1
:return: Quaternion such that q * origin1 = align1, and q * origin2 = align2
"""
# Normalize Vectors
origin1 = origin1.normalized()
origin2 = origin2.normalized()
align1 = align1.normalized()
align2 = align2.normalized()
# Calculate Quaternion
q1 = quat2align(origin1, align1, normalize=False)
q2 = quat2align(q1 * origin2, align2, normalize=False)
return q2 * q1
def plane_projection(vect, normal):
"""
Project vect to a plane represented by normal
:param vect: vector to be projected (Vector3)
:param normal: normal of plane to project on to (Vector3)
:return: vect projected onto plane represented by normal
"""
# ref: https://en.wikipedia.org/wiki/Vector_projection
n = normal.normalized()
return vect - (n * vect.dot(n))
# ==================== GCode Utilities ====================
def omit_redundant_modes(gcode_iter):
"""
Replace redundant machine motion modes with whitespace,
:param gcode_iter: iterable to return with modifications
"""
from .machine import Machine, Mode
from .gcodes import MODAL_GROUP_MAP
class NullModeMachine(Machine):
MODE_CLASS = type('NullMode', (Mode,), {'default_mode': ''})
m = NullModeMachine()
for g in gcode_iter:
if (g.modal_group is not None) and (m.mode.modal_groups[g.modal_group] is not None):
# g-code has a modal groups, and the machine's mode
# (of the same modal group) is not None
if m.mode.modal_groups[g.modal_group].word == g.word:
# machine's mode & g-code's mode match (no machine change)
if g.modal_group == MODAL_GROUP_MAP['motion']:
# finally: g-code sets a motion mode in the machine
g = copy(g) # duplicate gcode object
# stop redundant g-code word from being printed
g._whitespace_prefix = True
m.process_gcodes(g)
yield g

330
src/pygcode/words.py Normal file
View File

@ -0,0 +1,330 @@
import re
import itertools
import six
from .exceptions import GCodeBlockFormatError, GCodeWordStrError
REGEX_FLOAT = re.compile(r'^-?(\d+\.?\d*|\.\d+)') # testcase: ..tests.test_words.WordValueMatchTests.test_float
REGEX_INT = re.compile(r'^-?\d+')
REGEX_POSITIVEINT = re.compile(r'^\d+')
REGEX_CODE = re.compile(r'^\d+(\.\d)?') # similar
# Value cleaning functions
def _clean_codestr(value):
if value < 10:
return "0%g" % value
return "%g" % value
CLEAN_NONE = lambda v: v
CLEAN_FLOAT = lambda v: "{0:g}".format(round(v, 3))
CLEAN_CODE = _clean_codestr
CLEAN_INT = lambda v: "%g" % v
WORD_MAP = {
# Descriptions copied from wikipedia:
# https://en.wikipedia.org/wiki/G-code#Letter_addresses
# Rotational Axes
'A': {
'class': float,
'value_regex': REGEX_FLOAT,
'description': "Absolute or incremental position of A axis (rotational axis around X axis)",
'clean_value': CLEAN_FLOAT,
},
'B': {
'class': float,
'value_regex': REGEX_FLOAT,
'description': "Absolute or incremental position of B axis (rotational axis around Y axis)",
'clean_value': CLEAN_FLOAT,
},
'C': {
'class': float,
'value_regex': REGEX_FLOAT,
'description': "Absolute or incremental position of C axis (rotational axis around Z axis)",
'clean_value': CLEAN_FLOAT,
},
'D': {
'class': float,
'value_regex': REGEX_FLOAT,
'description': "Defines diameter or radial offset used for cutter compensation. D is used for depth of cut on lathes. It is used for aperture selection and commands on photoplotters.",
'clean_value': CLEAN_FLOAT,
},
# Feed Rates
'E': {
'class': float,
'value_regex': REGEX_FLOAT,
'description': "Precision feedrate for threading on lathes",
'clean_value': CLEAN_FLOAT,
},
'F': {
'class': float,
'value_regex': REGEX_FLOAT,
'description': "Feedrate",
'clean_value': CLEAN_FLOAT,
},
# G-Codes
'G': {
'class': float,
'value_regex': REGEX_CODE,
'description': "Address for preparatory commands",
'clean_value': CLEAN_CODE,
},
# Tool Offsets
'H': {
'class': float,
'value_regex': REGEX_FLOAT,
'description': "Defines tool length offset; Incremental axis corresponding to C axis (e.g., on a turn-mill)",
'clean_value': CLEAN_FLOAT,
},
# Arc radius center coords
'I': {
'class': float,
'value_regex': REGEX_FLOAT,
'description': "Defines arc center in X axis for G02 or G03 arc commands. Also used as a parameter within some fixed cycles.",
'clean_value': CLEAN_FLOAT,
},
'J': {
'class': float,
'value_regex': REGEX_FLOAT,
'description': "Defines arc center in Y axis for G02 or G03 arc commands. Also used as a parameter within some fixed cycles.",
'clean_value': CLEAN_FLOAT,
},
'K': {
'class': float,
'value_regex': REGEX_FLOAT,
'description': "Defines arc center in Z axis for G02 or G03 arc commands. Also used as a parameter within some fixed cycles, equal to L address.",
'clean_value': CLEAN_FLOAT,
},
# Loop Count
'L': {
'class': int,
'value_regex': REGEX_POSITIVEINT,
'description': "Fixed cycle loop count; Specification of what register to edit using G10",
'clean_value': CLEAN_INT,
},
# Miscellaneous Function
'M': {
'class': float,
'value_regex': REGEX_CODE,
'description': "Miscellaneous function",
'clean_value': CLEAN_CODE,
},
# Line Number
'N': {
'class': int,
'value_regex': REGEX_POSITIVEINT,
'description': "Line (block) number in program; System parameter number to change using G10",
'clean_value': CLEAN_INT,
},
# Program Name
'O': {
'class': str,
'value_regex': re.compile(r'^.+$'), # all the way to the end
'description': "Program name",
'clean_value': CLEAN_NONE,
},
# Parameter (arbitrary parameter)
'P': {
'class': float, # parameter is often an integer, but can be a float
'value_regex': REGEX_FLOAT,
'description': "Serves as parameter address for various G and M codes",
'clean_value': CLEAN_FLOAT,
},
# Peck increment
'Q': {
'class': float,
'value_regex': REGEX_FLOAT,
'description': "Depth to increase on each peck; Peck increment in canned cycles",
'clean_value': CLEAN_FLOAT,
},
# Arc Radius
'R': {
'class': float,
'value_regex': REGEX_FLOAT,
'description': "Defines size of arc radius, or defines retract height in milling canned cycles",
'clean_value': CLEAN_FLOAT,
},
# Spindle speed
'S': {
'class': float,
'value_regex': REGEX_FLOAT,
'description': "Defines speed, either spindle speed or surface speed depending on mode",
'clean_value': CLEAN_FLOAT,
},
# Tool Selecton
'T': {
'class': str,
'value_regex': REGEX_POSITIVEINT, # tool string may have leading '0's, but is effectively an index (integer)
'description': "Tool selection",
'clean_value': CLEAN_NONE,
},
# Incremental axes
'U': {
'class': float,
'value_regex': REGEX_FLOAT,
'description': "Incremental axis corresponding to X axis (typically only lathe group A controls) Also defines dwell time on some machines (instead of 'P' or 'X').",
'clean_value': CLEAN_FLOAT,
},
'V': {
'class': float,
'value_regex': REGEX_FLOAT,
'description': "Incremental axis corresponding to Y axis",
'clean_value': CLEAN_FLOAT,
},
'W': {
'class': float,
'value_regex': REGEX_FLOAT,
'description': "Incremental axis corresponding to Z axis (typically only lathe group A controls)",
'clean_value': CLEAN_FLOAT,
},
# Linear Axes
'X': {
'class': float,
'value_regex': REGEX_FLOAT,
'description': "Absolute or incremental position of X axis.",
'clean_value': CLEAN_FLOAT,
},
'Y': {
'class': float,
'value_regex': REGEX_FLOAT,
'description': "Absolute or incremental position of Y axis.",
'clean_value': CLEAN_FLOAT,
},
'Z': {
'class': float,
'value_regex': REGEX_FLOAT,
'description': "Absolute or incremental position of Z axis.",
'clean_value': CLEAN_FLOAT,
},
}
class Word(object):
def __init__(self, *args):
if len(args) not in (1, 2):
raise AssertionError("input arguments either: (letter, value) or (word_str)")
if len(args) == 2:
# Word('G', 90)
(letter, value) = args
else:
# Word('G90')
letter = args[0][0] # first letter
value = args[0][1:] # rest of string
letter = letter.upper()
self._value_class = WORD_MAP[letter]['class']
self._value_clean = WORD_MAP[letter]['clean_value']
self.letter = letter
self.value = value
def __str__(self):
return "{letter}{value}".format(
letter=self.letter,
value=self.value_str,
)
def __repr__(self):
return "<{class_name}: {string}>".format(
class_name=self.__class__.__name__,
string=str(self),
)
# Sorting
def __lt__(self, other):
return (self.letter, self.value) < (other.letter, other.value)
def __gt__(self, other):
return (self.letter, self.value) > (other.letter, other.value)
def __le__(self, other):
return (self.letter, self.value) <= (other.letter, other.value)
def __ge__(self, other):
return (self.letter, self.value) >= (other.letter, other.value)
# Equality
def __eq__(self, other):
if isinstance(other, six.string_types):
other = str2word(other)
return (self.letter == other.letter) and (self.value == other.value)
def __ne__(self, other):
return not self.__eq__(other)
# Hashing
def __hash__(self):
return hash((self.letter, self.value))
@property
def value_str(self):
"""Clean string representation, for consistent file output"""
return self._value_clean(self.value)
# Value Properties
@property
def value(self):
return self._value
@value.setter
def value(self, new_value):
self._value = self._value_class(new_value)
@property
def description(self):
return "%s: %s" % (self.letter, WORD_MAP[self.letter]['description'])
def text2words(block_text):
"""
Iterate through block text yielding Word instances
:param block_text: text for given block with comments removed
"""
next_word = re.compile(r'^.*?(?P<letter>[%s])' % ''.join(WORD_MAP.keys()), re.IGNORECASE)
index = 0
while True:
letter_match = next_word.search(block_text[index:])
if letter_match:
# Letter
letter = letter_match.group('letter').upper()
index += letter_match.end() # propogate index to start of value
# Value
value_regex = WORD_MAP[letter]['value_regex']
value_match = value_regex.search(block_text[index:])
if value_match is None:
raise GCodeWordStrError("word '%s' value invalid" % letter)
value = value_match.group() # matched text
yield Word(letter, value)
index += value_match.end() # propogate index to end of value
else:
break
remainder = block_text[index:]
if remainder and re.search(r'\S', remainder):
raise GCodeWordStrError("block code remaining '%s'" % remainder)
def str2word(word_str):
words = list(text2words(word_str))
if words:
if len(words) > 1:
raise GCodeWordStrError("more than one word given")
return words[0]
return None
def words2dict(word_list, limit_word_letters=None):
"""
Represent a list of words as a dict
:param limit_word_letters: iterable containing a white-list of word letters (None allows all)
:return: dict of the form: {<letter>: <value>, ... }
"""
# Remember: duplicate word letters cannot be represented as a dict
return dict(
(w.letter, w.value) for w in word_list
if (limit_word_letters is None) or (w.letter in limit_word_letters)
)

3
tests/runtests.sh Executable file
View File

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

View File

@ -0,0 +1,26 @@
T5 M06 G43
G17 G90 G21
G00 Z5 S7000 M03
X9.60 Y48.74
Z2
G01 Z-0.5 F100
G02 X10.75 Y47.44 I-0.11 J-1.26 F70
G01 X10.75 Y-47.44
G02 X9.51 Y-48.69 I-1.25 J0
G02 X8.25 Y-47.44 I0 J1.25
G01 X8.25 Y-47.44
X8.25 Y47.44
G02 X9.6 Y48.74 I1.25 J0.05
G00 Z5
Z1.5
G01 Z-1 F100
G02 X10.75 Y47.44 I-0.11 J-1.26 F70
G01 X10.75 Y-47.44
G02 X9.51 Y-48.69 I-1.25 J0
G02 X8.25 Y-47.44 I0 J1.25
G01 X8.25 Y-47.44
X8.25 Y47.44
G02 X9.60 Y48.74 I1.25 J0.05
G00 Z5
T0 M06 M02

24
tests/test_file.py Normal file
View File

@ -0,0 +1,24 @@
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()

193
tests/test_gcodes.py Normal file
View File

@ -0,0 +1,193 @@
import sys
import os
import inspect
import re
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 import gcodes
from pygcode import words
from pygcode import machine
from pygcode.exceptions import GCodeWordStrError
class GCodeWordMappingTests(unittest.TestCase):
def test_word_map_integrity(self):
gcodes.build_maps()
for (word_maches, fn_class) in gcodes._gcode_function_list:
for (word, key_class) in gcodes._gcode_word_map.items():
# Verify that no mapped word will yield a True result
# from any of the 'word_maches' functions
self.assertFalse(
word_maches(word),
"conflict with %s and %s" % (fn_class, key_class)
)
class GCodeModalGroupTests(unittest.TestCase):
def test_modal_groups(self):
# Modal groups taken (and slightly modified) from LinuxCNC documentation:
# link: http://linuxcnc.org/docs/html/gcode/overview.html#_modal_groups
table_rows = ''
# Table 5. G-Code Modal Groups
# MODAL GROUP MEANING MEMBER WORDS
table_rows += '''
Non-modal codes (Group 0) G4,G10,G28,G30,G53,G92,G92.1,G92.2,G92.3
Motion (Group 1) G0,G1,G2,G3,G33,G38.2,G38.3,G38.4
Motion (Group 1) G38.5,G73,G76,G80,G81,G82,G83,G85,G89
Plane selection (Group 2) G17, G18, G19, G17.1, G18.1, G19.1
Distance Mode (Group 3) G90, G91
Arc IJK Distance Mode (Group 4) G90.1, G91.1
Feed Rate Mode (Group 5) G93, G94, G95
Units (Group 6) G20, G21
Cutter Diameter Compensation (Group 7) G40, G41, G42, G41.1, G42.1
Tool Length Offset (Group 8) G43, G43.1, G49
Canned Cycles Return Mode (Group 10) G98
Coordinate System (Group 12) G54,G55,G56,G57,G58,G59,G59.1,G59.2,G59.3
Control Mode (Group 13) G61, G61.1, G64
Spindle Speed Mode (Group 14) G96, G97
Lathe Diameter Mode (Group 15) G7,G8
'''
# Table 6. M-Code Modal Groups
# MODAL GROUP MEANING MEMBER WORDS
table_rows += re.sub(r'\(Group (\d+)\)', r'(Group 10\1)', '''
Stopping (Group 4) M0, M1, M2, M30, M60
Spindle (Group 7) M3, M4, M5
Coolant (Group 8) M7, M8, M9
Override Switches (Group 9) M48, M49
''') # groups += 100 (to distinguish "M" GCodes from "G" GCodes)
for row in table_rows.split('\n'):
match = re.search(r'^\s*(?P<title>.*)\s*\(Group (?P<group>\d+)\)\s*(?P<words>.*)$', row, re.I)
if match:
for word_str in re.split(r'\s*,\s*', match.group('words')):
word = list(words.text2words(word_str))[0]
gcode_class = gcodes.word_gcode_class(word)
# GCode class found for each word in the table
self.assertIsNotNone(gcode_class)
# GCode's modal group equals that defined in the table
expected_group = int(match.group('group'))
if expected_group == 0:
self.assertIsNone(
gcode_class.modal_group,
"%s modal_group: %s is not None" % (gcode_class, gcode_class.modal_group)
)
else:
self.assertEqual(
gcode_class.modal_group, expected_group,
"%s != %s (%r)" % (gcode_class.modal_group, expected_group, word)
)
class Words2GCodesTests(unittest.TestCase):
def test_stuff(self): # FIXME: function name
line = 'G1 X82.6892 Y-38.6339 F1500'
word_list = list(words.text2words(line))
result = gcodes.words2gcodes(word_list)
# result form
self.assertIsInstance(result, tuple)
self.assertEqual(len(result), 2)
# result content
(gcode_list, unused_words) = result
self.assertEqual(len(gcode_list), 2)
self.assertEqual(unused_words, [])
# Parsed GCodes
# G1
self.assertEqual(gcode_list[0].word, words.Word('G', 1))
self.assertEqual(gcode_list[0].X, 82.6892)
self.assertEqual(gcode_list[0].Y, -38.6339)
# F1500
self.assertEqual(gcode_list[1].word, words.Word('F', 1500))
class Text2GCodesTests(unittest.TestCase):
def test_basic(self):
gcs = gcodes.text2gcodes('G1 X1 Y2 G90')
self.assertEqual(len(gcs), 2)
# G1 X1 Y2
self.assertEqual(gcs[0].word, words.Word('G', 1))
self.assertEqual(gcs[0].X, 1)
self.assertEqual(gcs[0].Y, 2)
# G90
self.assertEqual(gcs[1].word, words.Word('G', 90))
def test_modal_params(self):
with self.assertRaises(GCodeWordStrError):
gcodes.text2gcodes('X1 Y2')
class GCodeSplitTests(unittest.TestCase):
def test_split(self):
g_list = gcodes.text2gcodes('G91 S1000 G1 X1 Y2 M3')
split = gcodes.split_gcodes(g_list, gcodes.GCodeStartSpindle)
self.assertEqual([len(x) for x in split], [1, 1, 2])
self.assertTrue(any(isinstance(g, gcodes.GCodeSpindleSpeed) for g in split[0]))
self.assertTrue(isinstance(split[1][0], gcodes.GCodeStartSpindle))
self.assertTrue(any(isinstance(g, gcodes.GCodeDistanceMode) for g in split[2]))
self.assertTrue(any(isinstance(g, gcodes.GCodeMotion) for g in split[2]))
def test_split_unsorted(self):
g_list = gcodes.text2gcodes('G91 G1 X1 Y2 M3 S1000')
split = gcodes.split_gcodes(g_list, gcodes.GCodeStartSpindle, sort_list=False)
self.assertEqual([len(x) for x in split], [2, 1, 1])
self.assertTrue(any(isinstance(g, gcodes.GCodeDistanceMode) for g in split[0]))
self.assertTrue(any(isinstance(g, gcodes.GCodeMotion) for g in split[0]))
self.assertTrue(isinstance(split[1][0], gcodes.GCodeStartSpindle))
self.assertTrue(any(isinstance(g, gcodes.GCodeSpindleSpeed) for g in split[2]))
class GCodeAbsoluteToRelativeDecoratorTests(unittest.TestCase):
def test_gcodes_abs2rel(self):
# setup gcode testlist
L = gcodes.GCodeLinearMove
R = gcodes.GCodeRapidMove
args = lambda x, y, z: dict(a for a in zip('XYZ', [x,y,z]) if a[1] is not None)
gcode_list = [
# GCode instances Expected incremental output
(L(**args(0, 0, 0)), L(**args(-10, -20, -30))),
(L(**args(1, 2, 0)), L(**args(1, 2, None))),
(L(**args(3, 4, 0)), L(**args(2, 2, None))),
(R(**args(1, 2, 0)), R(**args(-2, -2, None))),
(R(**args(3, 4, 0)), R(**args(2, 2, None))),
(L(**args(3, 4, 0)), None),
(L(**args(3, 4, 8)), L(**args(None, None, 8))),
]
m = machine.Machine()
# Incremental Output
m.set_mode(gcodes.GCodeAbsoluteDistanceMode())
m.move_to(X=10, Y=20, Z=30) # initial position (absolute)
m.set_mode(gcodes.GCodeIncrementalDistanceMode())
@gcodes._gcodes_abs2rel(m.pos, dist_mode=m.mode.distance, axes=m.axes)
def expecting_rel():
return [g[0] for g in gcode_list]
trimmed_expecting_list = [x[1] for x in gcode_list if x[1] is not None]
for (i, g) in enumerate(expecting_rel()):
expected = trimmed_expecting_list[i]
self.assertEqual(type(g), type(expected))
self.assertEqual(g.word, expected.word)
self.assertEqual(g.params, expected.params)
# Absolute Output
m.set_mode(gcodes.GCodeAbsoluteDistanceMode())
m.move_to(X=10, Y=20, Z=30) # initial position
@gcodes._gcodes_abs2rel(m.pos, dist_mode=m.mode.distance, axes=m.axes)
def expecting_abs():
return [g[0] for g in gcode_list]
for (i, g) in enumerate(expecting_abs()):
expected = gcode_list[i][0] # expecting passthrough
self.assertEqual(type(g), type(expected))
self.assertEqual(g.word, expected.word)
self.assertEqual(g.params, expected.params)

29
tests/test_line.py Normal file
View File

@ -0,0 +1,29 @@
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.line import Line
class LineCommentTests(unittest.TestCase):
def test_line_comment_semicolon(self):
line = Line('G02 X10.75 Y47.44 I-0.11 J-1.26 F70 ; blah blah')
self.assertEqual(line.comment.text, 'blah blah')
self.assertEqual(len(line.block.words), 6)
def test_line_comment_brackets(self):
line = Line('G02 X10.75 Y47.44 I-0.11 J-1.26 F70 (blah blah)')
self.assertEqual(line.comment.text, 'blah blah')
self.assertEqual(len(line.block.words), 6)
def test_line_comment_brackets_multi(self):
line = Line('G02 X10.75 (x coord) Y47.44 (y coord) I-0.11 J-1.26 F70 (eol)')
self.assertEqual(line.comment.text, 'x coord. y coord. eol')
self.assertEqual(len(line.block.words), 6)

123
tests/test_machine.py Normal file
View File

@ -0,0 +1,123 @@
import unittest
# Add relative pygcode to path
from testutils import add_pygcode_to_path, str_lines
add_pygcode_to_path()
# Units under test
from pygcode.machine import Position, Machine
from pygcode.line import Line
from pygcode.exceptions import MachineInvalidAxis
class PositionTests(unittest.TestCase):
def test_basics(self):
p = Position()
#
def test_default_axes(self):
p = Position() # no instantiation parameters
# all initialized to zero
for axis in 'XYZABCUVW':
self.assertEqual(getattr(p, axis), 0)
for axis in 'XYZABCUVW':
# set to 100
setattr(p, axis, 100)
self.assertEqual(getattr(p, axis), 100)
for inner_axis in set('XYZABCUVW') - {axis}: # no other axis has changed
self.assertEqual(getattr(p, inner_axis), 0), "axis '%s'" % inner_axis
# revert back to zero
setattr(p, axis, 0)
self.assertEqual(getattr(p, axis), 0)
# Equality
def test_equality(self):
p1 = Position(axes='XYZ', X=1, Y=2)
p2 = Position(axes='XYZ', X=1, Y=2, Z=0)
p3 = Position(axes='XYZ', X=1, Y=2, Z=1000)
p4 = Position(axes='XYZA', X=1, Y=2, Z=0)
# p1 <--> p2
self.assertTrue(p1 == p2)
self.assertFalse(p1 != p2) # negative case
# p2 <--> p3
self.assertTrue(p2 != p3)
self.assertFalse(p2 == p3) # negative case
# p2 <--> p4
self.assertTrue(p2 != p4)
self.assertFalse(p2 == p4) # negative case
# Arithmetic
def test_arithmetic_add(self):
p1 = Position(axes='XYZ', X=1, Y=2)
p2 = Position(axes='XYZ', Y=10, Z=-20)
self.assertEqual(p1 + p2, Position(axes='XYZ', X=1, Y=12, Z=-20))
p3 = Position(axes='XYZA')
with self.assertRaises(MachineInvalidAxis):
p1 + p3 # mismatched axes
with self.assertRaises(MachineInvalidAxis):
p3 + p1 # mismatched axes
def test_arithmetic_sub(self):
p1 = Position(axes='XYZ', X=1, Y=2)
p2 = Position(axes='XYZ', Y=10, Z=-20)
self.assertEqual(p1 - p2, Position(axes='XYZ', X=1, Y=-8, Z=20))
p3 = Position(axes='XYZA')
p3 - p1 # fine
with self.assertRaises(MachineInvalidAxis):
p1 - p3 # mismatched axes
def test_arithmetic_multiply(self):
p = Position(axes='XYZ', X=2, Y=10)
self.assertEqual(p * 2, Position(axes='XYZ', X=4, Y=20))
def test_arithmetic_divide(self):
p = Position(axes='XYZ', X=2, Y=10)
self.assertEqual(p / 2, Position(axes='XYZ', X=1, Y=5))
class MachineGCodeProcessingTests(unittest.TestCase):
def test_linear_movement(self):
m = Machine()
test_str = '''; move in a 10mm square
F100 M3 S1000 ; 0
g1 x0 y10 ; 1
g1 x10 y10 ; 2
g1 x10 y0 ; 3
g1 x0 y0 ; 4
'''
expected_pos = {
'0': m.Position(),
'1': m.Position(X=0, Y=10),
'2': m.Position(X=10, Y=10),
'3': m.Position(X=10, Y=0),
'4': m.Position(X=0, Y=0),
}
#print("\n%r\n%r" % (m.mode, m.state))
for line_text in str_lines(test_str):
line = Line(line_text)
if line.block:
#print("\n%s" % line.block)
m.process_block(line.block)
# Assert possition change correct
comment = line.comment.text
if comment in expected_pos:
self.assertEqual(m.pos, expected_pos[comment])
#print("%r\n%r\npos=%r" % (m.mode, m.state, m.pos))
#m = Machine()
#
#file = GCodeParser('part1.gcode')
#for line in file.iterlines():
# for (i, gcode) in enumerate(line.block.gcode):
# if isinstance(gcode, GCodeArcMove):
# arc = gcode
# line_params = arc.line_segments(precision=0.0005)
# for

33
tests/test_utils.py Normal file
View File

@ -0,0 +1,33 @@
import unittest
import re
# Add relative pygcode to path
from testutils import add_pygcode_to_path, str_lines
add_pygcode_to_path()
# Units under test
from pygcode.utils import omit_redundant_modes
from pygcode import text2gcodes, Line
class UtilityTests(unittest.TestCase):
def test_omit_redundant_modes(self):
lines = [
Line(line_str)
for line_str in re.split(r'\s*\n\s*', '''
g1 x0 y0 ; yes
g1 x10 y-20 ; no
g0 x-3 y2 ; yes
g0 x0 y0 ; no
g0 x1 y1 ; no
g1 x20 y20 z5 ; yes
''')
if line_str
]
gcodes = [l.gcodes[0] for l in lines]
comments = [l.comment for l in lines]
for (i, g) in enumerate(omit_redundant_modes(gcodes)):
comment = comments[i].text if comments[i] else None
if comment == 'no':
self.assertIsNotNone(re.search(r'^\s', str(g)))
elif comment == 'yes':
self.assertIsNone(re.search(r'^\s', str(g)))

74
tests/test_words.py Normal file
View File

@ -0,0 +1,74 @@
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 import words
class WordIterTests(unittest.TestCase):
def test_iter1(self):
block_str = 'G01 Z-0.5 F100'
w = list(words.text2words(block_str))
# word length
self.assertEqual(len(w), 3)
# word values
self.assertEqual(w[0], words.Word('G', 1))
self.assertEqual(w[1], words.Word('Z', -0.5))
self.assertEqual(w[2], words.Word('F', 100))
def test_iter2(self):
block_str = 'G02 X10.75 Y47.44 I-0.11 J-1.26 F70'
w = list(words.text2words(block_str))
# word length
self.assertEqual(len(w), 6)
# word values
self.assertEqual([w[0].letter, w[0].value], ['G', 2])
self.assertEqual([w[1].letter, w[1].value], ['X', 10.75])
self.assertEqual([w[2].letter, w[2].value], ['Y', 47.44])
self.assertEqual([w[3].letter, w[3].value], ['I', -0.11])
self.assertEqual([w[4].letter, w[4].value], ['J', -1.26])
self.assertEqual([w[5].letter, w[5].value], ['F', 70])
class WordValueMatchTests(unittest.TestCase):
def regex_assertions(self, regex, positive_list, negative_list):
# Assert all elements of positive_list match regex
for (value_str, expected_match) in positive_list:
match = regex.search(value_str)
self.assertIsNotNone(match, "failed to match '%s'" % value_str)
self.assertEqual(match.group(), expected_match)
# Asesrt all elements of negative_list do not match regex
for value_str in negative_list:
match = regex.search(value_str)
self.assertIsNone(match, "matched for '%s'" % value_str)
def test_float(self):
self.regex_assertions(
regex=words.REGEX_FLOAT,
positive_list=[
('1.2', '1.2'), ('1', '1'), ('200', '200'), ('0092', '0092'),
('1.', '1.'), ('.2', '.2'), ('-1.234', '-1.234'),
('-1.', '-1.'), ('-.289', '-.289'),
# error cases (only detectable in gcode context)
('1.2e3', '1.2'),
],
negative_list=['.', ' 1.2']
)
def test_code(self):
self.regex_assertions(
regex=words.REGEX_CODE,
positive_list=[
('1.2', '1.2'), ('1', '1'), ('10', '10'),
('02', '02'), ('02.3', '02.3'),
('1.', '1'), ('03 ', '03'),
# error cases (only detectable in gcode context)
('30.12', '30.1'),
],
negative_list=['.2', '.', ' 2']
)

26
tests/testutils.py Normal file
View File

@ -0,0 +1,26 @@
# utilities for the testing suite (as opposed to the tests for utils.py)
import sys
import os
import inspect
import re
# Units Under Test
_pygcode_in_path = False
def add_pygcode_to_path():
global _pygcode_in_path
if not _pygcode_in_path:
# Add pygcode (relative to this test-path) to the system path
_this_path = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
sys.path.insert(0, os.path.join(_this_path, '..'))
_pygcode_in_path = True
add_pygcode_to_path()
# String Utilities
def str_lines(text):
"""Split given string into lines (ignore blank lines, and automagically strip)"""
for match in re.finditer(r'\s*(?P<content>.*?)\s*\n', text):
yield match.group('content')