diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d2015f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# python cache / compilations +*__pycache__ +*.pyc + +# editor backups +*.swp diff --git a/README.md b/README.rst similarity index 51% rename from README.md rename to README.rst index 4a8a505..af72bfd 100644 --- a/README.md +++ b/README.rst @@ -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 `__. -## 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 `__ which follows +`LinuxCNC `__ (list of gcodes documented +`here `__). -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) diff --git a/scripts/pygcode-normalize.py b/scripts/pygcode-normalize.py new file mode 100755 index 0000000..adecfd4 --- /dev/null +++ b/scripts/pygcode-normalize.py @@ -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 ,, 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'): , ... } +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 + +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) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..f98a093 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,6 @@ +[bdist_wheel] +universal = 1 + +[metadata] +description-file = README.md +license_file = LICENSE diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..277d36e --- /dev/null +++ b/setup.py @@ -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__{meta}__)\s*=\s*['\"](?P[^'\"]*)['\"]".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 = [], +#) diff --git a/src/pygcode/__init__.py b/src/pygcode/__init__.py new file mode 100644 index 0000000..fba96cb --- /dev/null +++ b/src/pygcode/__init__.py @@ -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 +) diff --git a/src/pygcode/block.py b/src/pygcode/block.py new file mode 100644 index 0000000..bb8b716 --- /dev/null +++ b/src/pygcode/block.py @@ -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 )""" + + 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)) diff --git a/src/pygcode/comment.py b/src/pygcode/comment.py new file mode 100644 index 0000000..5e9b170 --- /dev/null +++ b/src/pygcode/comment.py @@ -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.*)$') + + 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[^\)]*)\)') + + 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(), CommentBase()) + """ + 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) diff --git a/src/pygcode/exceptions.py b/src/pygcode/exceptions.py new file mode 100644 index 0000000..d445e6b --- /dev/null +++ b/src/pygcode/exceptions.py @@ -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""" diff --git a/src/pygcode/gcodes.py b/src/pygcode/gcodes.py new file mode 100644 index 0000000..4d342ba --- /dev/null +++ b/src/pygcode/gcodes.py @@ -0,0 +1,1533 @@ +import sys +from collections import defaultdict +from copy import copy + +from .utils import Vector3, Quaternion, quat2coord_system +from .words import Word, text2words + +from .exceptions import GCodeParameterError, GCodeWordStrError + +# Terminology of a "G-Code" +# For the purposes of this library, so-called "G" codes do not necessarily +# use the letter "G" in their word; other letters include M, F, S, and T +# Why? +# I've seen documentation thrown around using the term "gcode" interchangably +# for any word that triggers an action, or mode change. The fact that the +# entire language is called "gcode" obfuscates any other convention. +# Considering it's much easier for me to call everything GCode, I'm taking +# the lazy route, so sue me (but seriously, please don't sue me). +# +# Modality groups +# Modal GCodes: +# A machine's "mode" can be changed by any gcode with a modal_group. +# This state is retained by the machine in the blocks to follow until +# the "mode" is revoked, or changed. +# A typical example of this is units: +# G20 +# this will change the machine's "mode" to treat all positions as +# millimeters; G20 does not have to be on every line, thus the machine's +# "mode" is in millimeters for that, and every block after it until G21 +# is specified (changing units to inches). +# +# Modal Groups: +# Only one mode of each modal group can be active. That is to say, a +# modal g-code can only change the sate of a previously set mode if +# they're in the same group. +# For example: +# G20 (mm), and G21 (inches) are in group 6 +# G1 (linear movement), and G2 (arc movement) are in group 1 +# A machine can't use mm and inches at the same time, just as it can't +# move in a straight line, and move in an arc simultaneously. +# +# There are 15 groups: +# ref: http://linuxcnc.org/docs/html/gcode/overview.html#_modal_groups +# +# Table 5. G-Code Modal Groups +# MODAL GROUP MEANING MEMBER WORDS +# Non-modal codes (Group 0) G4, G10 G28, G30, G53, G92, G92.1, G92.2, G92.3, +# Motion (Group 1) G0, G1, G2, G3, G33, G38.x, G73, G76, G80, G81 +# G82, G83, G84, G85, G86, G87, G88,G89 +# Plane selection (Group 2) G17, G18, G19, G17.1, G18.1, G19.1 +# 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, G99 +# 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 +# Stopping (Group 4) M0, M1, M2, M30, M60 +# Spindle (Group 7) M3, M4, M5 +# Coolant (Group 8) (M7 M8 can both be on), M9 +# Override Switches (Group 9) M48, M49 +# User Defined (Group 10) M100-M199 +# + +MODAL_GROUP_MAP = { + # "G" codes + 'motion': 1, + 'plane_selection': 2, + 'distance': 3, + 'arc_ijk_distance': 4, + 'feed_rate_mode': 5, + 'units': 6, + 'cutter_diameter_comp': 7, + 'tool_length_offset': 8, + 'canned_cycles_return': 10, + 'coordinate_system': 12, + 'control_mode': 13, + 'spindle_speed_mode': 14, + 'lathe_diameter': 15, + + # "M" codes + 'stopping': 104, + 'spindle': 107, + 'coolant': 108, + 'override_switches': 109, + 'user_defined': 110, + + # 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 + # modal group. + # However, having a modal group assists with: + # - validating gcode blocks for conflicting commands + # - remembering machine's state with consistency across gcodes + # Therefore, I've added F, S, and T GCodes to custom group numbers (> 200) + 'feed_rate': 201, + 'spindle_speed': 202, + 'tool': 203, +} + +# Execution Order +# Order taken http://linuxcnc.org/docs/html/gcode/overview.html#_g_code_order_of_execution +# (as of 2017-07-03) +# 010: O-word commands (optionally followed by a comment but no other words allowed on the same line) +# 020: Comment (including message) +# 030: Set feed rate mode (G93, G94). +# 040: Set feed rate (F). +# 050: Set spindle speed (S). +# 060: Select tool (T). +# 070: HAL pin I/O (M62-M68). +# 080: Change tool (M6) and Set Tool Number (M61). +# 090: Spindle on or off (M3, M4, M5). +# 100: Save State (M70, M73), Restore State (M72), Invalidate State (M71). +# 110: Coolant on or off (M7, M8, M9). +# 120: Enable or disable overrides (M48, M49,M50,M51,M52,M53). +# 130: User-defined Commands (M100-M199). +# 140: Dwell (G4). +# 150: Set active plane (G17, G18, G19). +# 160: Set length units (G20, G21). +# 170: Cutter radius compensation on or off (G40, G41, G42) +# 180: Cutter length compensation on or off (G43, G49) +# 190: Coordinate system selection (G54, G55, G56, G57, G58, G59, G59.1, G59.2, G59.3). +# 200: Set path control mode (G61, G61.1, G64) +# 210: Set distance mode (G90, G91). +# 220: Set retract mode (G98, G99). +# 230: Go to reference location (G28, G30) or change coordinate system data (G10) or set axis offsets (G92, G92.1, G92.2, G94). +# 240: Perform motion (G0 to G3, G33, G38.x, G73, G76, G80 to G89), as modified (possibly) by G53. +# 250: Stop (M0, M1, M2, M30, M60). + + +class GCode(object): + # Defining Word + word_key = None # Word instance to use in lookup + word_matches = None # function (secondary) + default_word = None + + # Parameters associated to this gcode + param_letters = set() + + # Modal stuff + modal_group = None + modal_param_letters = set() # by default: no parameters are retained in modal state + + # Execution Order + exec_order = 999 # if not otherwise specified, run last + + def __init__(self, *words, **params): + """ + :param word: Word instance defining gcode (eg: Word('G0') for rapid movement) + :param params: list of Word instances (eg: Word('X-1.2') as x-coordinate) + """ + gcode_word_list = words[:1] + param_words = words[1:] + if gcode_word_list: + gcode_word = gcode_word_list[0] + else: + gcode_word = self._default_word() + assert isinstance(gcode_word, Word), "invalid gcode word %r" % gcode_word + self.word = gcode_word + self.params = {} + + # Whitespace as prefix + # if True, str(self) will repalce self.word code with whitespace + self._whitespace_prefix = False + + # Add Given Parameters + for param_word in param_words: + self.add_parameter(param_word) + for (k, v) in params.items(): + self.add_parameter(Word(k, v)) + + def __repr__(self): + param_str = '' + if self.params: + param_str = "{%s}" % (', '.join([ + "{}".format(self.params[k]) + for k in sorted(self.params.keys()) + ])) + return "<{class_name}: {gcode}{params}>".format( + class_name=self.__class__.__name__, + gcode=self.word, + params=param_str, + ) + + def __str__(self): + """String representation of gcode, as it would be seen in a .gcode file""" + param_str = '' + if self.params: + param_str += ' ' + ' '.join([ + "{}".format(self.params[k]) + for k in sorted(self.params.keys()) + ]) + word_str = str(self.word) + if self._whitespace_prefix: + word_str = ' ' * len(word_str) + return "{word_str}{parameters}".format( + word_str=word_str, + parameters=param_str, + ) + + def _default_word(self): + if self.default_word: + return copy(self.default_word) + elif self.word_key: + return copy(self.word_key) + raise AssertionError("class %r has no default word" % self.__class__) + + # Comparisons + def __lt__(self, other): + """Sort by execution order""" + return self.exec_order < other.exec_order + + def __gt__(self, other): + """Sort by execution order""" + return self.exec_order > other.exec_order + + # Parameters + def add_parameter(self, word): + """ + Add given word as a parameter for this gcode + :param word: Word instance + """ + assert isinstance(word, Word), "invalid parameter class: %r" % word + if word.letter not in self.param_letters: + raise GCodeParameterError("invalid parameter for %s: %s" % (self.__class__.__name__, str(word))) + if word.letter in self.params: + raise GCodeParameterError("parameter defined twice: %s -> %s" % (self.params[word.letter], word)) + + self.params[word.letter] = word + + # Assert Parameters + def assert_params(self): + """ + Assert validity of gcode's parameters. + This verification is irrespective of machine, or machine's state; + verification is g-code language-based verification + :raises: GCodeParameterError + """ + # to be overridden in inheriting classes + pass + + def __getattr__(self, key): + # Return parameter values (if valid parameter for gcode) + if key in self.param_letters: + if key in self.params: + return self.params[key].value + else: + return None # parameter is valid for GCode, but undefined + + raise AttributeError("'{cls}' object has no attribute '{key}'".format( + cls=self.__class__.__name__, + key=key + )) + + def __setattr__(self, key, value): + if key in self.param_letters: + if key in self.params: + self.params[key].value = value + else: + self.add_parameter(Word(key, value)) + + else: + self.__dict__[key] = value + + @property + def description(self): + return self.__doc__ + + def modal_copy(self): + """Copy of GCode instance containing only parameters listed in modal_param_letters""" + return self.__class__(self.word, *[ + w for (l, w) in self.params.items() + if l in self.modal_param_letters + ]) + + def get_param_dict(self, letters=None, lc=False): + """ + Get gcode parameters as a dict + gcode parameter like "X3.1, Y-2" would return {'X': 3.1, 'Y': -2} + :param letters: iterable whitelist of letters to include as dict keys + :param lc: lower case parameter letters + :return: dict of gcode parameters' (letter, value) pairs + """ + letter_mod = lambda x: x + if lc: + letter_mod = lambda x: x.lower() + return dict( + (letter_mod(w.letter), w.value) for w in self.params.values() + if (letters is None) or (w.letter in letters) + ) + + # Process GCode + def process(self, machine): + """ + Process a GCode on the given Machine + :param machine: Machine instance, to change state + :return: GCodeEffect instance; effect the gcode just had on machine + """ + from .machine import Machine # importing up (done live to avoid dependency loop) + assert isinstance(machine, Machine), "invalid machine type: %r" % machine + + # Set mode + self._process_mode(machine) + + # GCode specific + self._process(machine) + + def _process_mode(self, machine): + """Set machine's state""" + machine.set_mode(self) + + def _process(self, machine): + """Process this GCode (to be overridden)""" + pass + + + +# ======================= Motion ======================= +# (X Y Z A B C U V W apply to all motions) +# CODE PARAMETERS DESCRIPTION +# G0 Rapid Move +# G1 Linear Move +# G2, G3 I J K or R, P Arc Move +# G4 P Dwell +# G5 I J P Q Cubic Spline +# G5.1 I J Quadratic Spline +# G5.2 P L NURBS +# G38.2 - G38.5 Straight Probe +# G33 K Spindle Synchronized Motion +# G33.1 K Rigid Tapping +# G80 Cancel Canned Cycle + +class GCodeMotion(GCode): + param_letters = set('XYZABCUVW') + modal_group = MODAL_GROUP_MAP['motion'] + exec_order = 241 + + def _process(self, machine): + machine.move_to(**self.get_param_dict(letters=machine.axes)) + + +class GCodeRapidMove(GCodeMotion): + """G0: Rapid Move""" + word_key = Word('G', 0) + + def _process(self, machine): + machine.move_to(rapid=True, **self.get_param_dict(letters=machine.axes)) + + +class GCodeLinearMove(GCodeMotion): + """G1: Linear Move""" + word_key = Word('G', 1) + + +class GCodeArcMove(GCodeMotion): + """Arc Move""" + param_letters = GCodeMotion.param_letters | set('IJKRP') + + def assert_params(self): + param_letters = set(self.params.keys()) + # Parameter groups + params_xyz = set('XYZ') & set(param_letters) + params_ijk = set('IJK') & set(param_letters) + params_r = set('R') & set(param_letters) + params_ijkr = params_ijk | params_r + + # --- Parameter Groups + # XYZ: at least 1 + if not params_xyz: + raise GCodeParameterError("no XYZ parameters set for destination: %r" % arc_gcode) + # IJK or R: only in 1 group + if params_ijk and params_r: + raise GCodeParameterError("both IJK and R parameters defined: %r" % arc_gcode) + # IJKR: at least 1 + if not params_ijkr: + raise GCodeParameterError("neither IJK or R parameters defined: %r" % arc_gcode) + + # --- Parameter Values + if params_r and (self.R == 0): + raise GCodeParameterError("cannot plot a circle with a radius of zero: %r" % arc_gcode) + + +class GCodeArcMoveCW(GCodeArcMove): + """G2: Arc Move (clockwise)""" + word_key = Word('G', 2) + + +class GCodeArcMoveCCW(GCodeArcMove): + """G3: Arc Move (counter-clockwise)""" + word_key = Word('G', 3) + + +class GCodeDwell(GCodeMotion): + """G4: Dwell""" + param_letters = set('P') # doesn't accept axis parameters + word_key = Word('G', 4) + modal_group = None # one of the few motion commands that isn't modal + exec_order = 140 + + def _process(self, machine): + pass # no movements made + + +class GCodeCublcSpline(GCodeMotion): + """G5: Cubic Spline""" + param_letters = GCodeMotion.param_letters | set('IJPQ') + word_key = Word('G', 5) + + +class GCodeQuadraticSpline(GCodeMotion): + """G5.1: Quadratic Spline""" + param_letters = GCodeMotion.param_letters | set('IJ') + word_key = Word('G', 5.1) + + +class GCodeNURBS(GCodeMotion): + """G5.2: Non-uniform rational basis spline (NURBS)""" + param_letters = GCodeMotion.param_letters | set('PL') + word_key = Word('G', 5.2) + + +class GCodeNURBSEnd(GCodeNURBS): + """G5.3: end NURBS mode""" + word_key = Word('G', 5.3) + + +class GCodeStraightProbe(GCodeMotion): + """G38.2-G38.5: Straight Probe""" + @classmethod + def word_matches(cls, w): + return (w.letter == 'G') and (38.2 <= w.value <= 38.5) + default_word = Word('G', 38.2) + + +class GCodeSpindleSyncMotion(GCodeMotion): + """G33: Spindle Synchronized Motion""" + param_letters = GCodeMotion.param_letters | set('K') + word_key = Word('G', 33) + + +class GCodeRigidTapping(GCodeMotion): + """G33.1: Rigid Tapping""" + param_letters = GCodeMotion.param_letters | set('K') + word_key = Word('G', 33.1) + + +class GCodeCancelCannedCycle(GCodeMotion): + """G80: Cancel Canned Cycle""" + word_key = Word('G', 80) + + +# ======================= Canned Cycles ======================= +# (X Y Z or U V W apply to canned cycles, depending on active plane) +# CODE PARAMETERS DESCRIPTION +# G81 R L (P) Drilling Cycle +# G82 R L (P) Drilling Cycle, Dwell +# G83 R L Q Drilling Cycle, Peck +# G73 R L Q Drilling Cycle, Chip Breaking +# G85 R L (P) Boring Cycle, Feed Out +# G89 R L (P) Boring Cycle, Dwell, Feed Out +# G76 P Z I J R K Q H L E Threading Cycle + +class GCodeCannedCycle(GCode): + param_letters = set('XYZUVW') + modal_group = MODAL_GROUP_MAP['motion'] + exec_order = 241 + + def _process(self, machine): + moveto_coords = self.get_param_dict(letters=machine.axes) + 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.move_to(**moveto_coords) + + +class GCodeDrillingCycle(GCodeCannedCycle): + """G81: Drilling Cycle""" + param_letters = GCodeCannedCycle.param_letters | set('RLP') + word_key = Word('G', 81) + modal_param_letters = GCodeCannedCycle.param_letters | set('RP') + + +class GCodeDrillingCycleDwell(GCodeCannedCycle): + """G82: Drilling Cycle, Dwell""" + param_letters = GCodeCannedCycle.param_letters | set('RLP') + word_key = Word('G', 82) + modal_param_letters = GCodeCannedCycle.param_letters | set('RP') + + +class GCodeDrillingCyclePeck(GCodeCannedCycle): + """G83: Drilling Cycle, Peck""" + param_letters = GCodeCannedCycle.param_letters | set('RLQ') + word_key = Word('G', 83) + modal_param_letters = GCodeCannedCycle.param_letters | set('RQ') + + +class GCodeDrillingCycleChipBreaking(GCodeCannedCycle): + """G73: Drilling Cycle, ChipBreaking""" + param_letters = GCodeCannedCycle.param_letters | set('RLQ') + word_key = Word('G', 73) + modal_param_letters = GCodeCannedCycle.param_letters | set('RQ') + + +class GCodeBoringCycleFeedOut(GCodeCannedCycle): + """G85: Boring Cycle, Feed Out""" + param_letters = GCodeCannedCycle.param_letters | set('RLP') + word_key = Word('G', 85) + modal_param_letters = GCodeCannedCycle.param_letters | set('RP') + + +class GCodeBoringCycleDwellFeedOut(GCodeCannedCycle): + """G89: Boring Cycle, Dwell, Feed Out""" + param_letters = GCodeCannedCycle.param_letters | set('RLP') + word_key = Word('G', 89) + modal_param_letters = GCodeCannedCycle.param_letters | set('RP') + + +class GCodeThreadingCycle(GCodeCannedCycle): + """G76: Threading Cycle""" + param_letters = GCodeCannedCycle.param_letters | set('PZIJRKQHLE') + word_key = Word('G', 76) + + +# ======================= Distance Mode ======================= +# CODE PARAMETERS DESCRIPTION +# G90, G91 Distance Mode +# G90.1, G91.1 Arc Distance Mode +# G7 Lathe Diameter Mode +# G8 Lathe Radius Mode + +class GCodeDistanceMode(GCode): + exec_order = 210 + + +class GCodeAbsoluteDistanceMode(GCodeDistanceMode): + """G90: Absolute Distance Mode""" + word_key = Word('G', 90) + modal_group = MODAL_GROUP_MAP['distance'] + + +class GCodeIncrementalDistanceMode(GCodeDistanceMode): + """G91: Incremental Distance Mode""" + word_key = Word('G', 91) + modal_group = MODAL_GROUP_MAP['distance'] + + +class GCodeAbsoluteArcDistanceMode(GCodeDistanceMode): + """G90.1: Absolute Distance Mode for Arc IJK Parameters""" + word_key = Word('G', 90.1) + modal_group = MODAL_GROUP_MAP['arc_ijk_distance'] + + +class GCodeIncrementalArcDistanceMode(GCodeDistanceMode): + """G91.1: Incremental Distance Mode for Arc IJK Parameters""" + word_key = Word('G', 91.1) + modal_group = MODAL_GROUP_MAP['arc_ijk_distance'] + + +class GCodeLatheDiameterMode(GCodeDistanceMode): + """G7: Lathe Diameter Mode""" + word_key = Word('G', 7) + modal_group = MODAL_GROUP_MAP['lathe_diameter'] + + +class GCodeLatheRadiusMode(GCodeDistanceMode): + """G8: Lathe Radius Mode""" + word_key = Word('G', 8) + modal_group = MODAL_GROUP_MAP['lathe_diameter'] + + +# ======================= Feed Rate Mode ======================= +# CODE PARAMETERS DESCRIPTION +# G93, G94, G95 Feed Rate Mode + +class GCodeFeedRateMode(GCode): + modal_group = MODAL_GROUP_MAP['feed_rate_mode'] + exec_order = 30 + + +class GCodeInverseTimeMode(GCodeFeedRateMode): + """G93: Inverse Time Mode""" + word_key = Word('G', 93) + + +class GCodeUnitsPerMinuteMode(GCodeFeedRateMode): + """G94: Units Per MinuteMode""" + word_key = Word('G', 94) + + +class GCodeUnitsPerRevolution(GCodeFeedRateMode): + """G95: Units Per Revolution""" + word_key = Word('G', 95) + + +# ======================= Spindle Control ======================= +# CODE PARAMETERS DESCRIPTION +# M3, M4, M5 S Spindle Control +# M19 Orient Spindle +# G96, G97 S D Spindle Control Mode + +class GCodeSpindle(GCode): + exec_order = 90 + + +class GCodeStartSpindle(GCodeSpindle): + """M3,M4: Start Spindle Clockwise""" + modal_group = MODAL_GROUP_MAP['spindle'] + + +class GCodeStartSpindleCW(GCodeStartSpindle): + """M3: Start Spindle Clockwise""" + #param_letters = set('S') # S is it's own gcode, makes no sense to be here + word_key = Word('M', 3) + +class GCodeStartSpindleCCW(GCodeStartSpindle): + """M4: Start Spindle Counter-Clockwise""" + #param_letters = set('S') # S is it's own gcode, makes no sense to be here + word_key = Word('M', 4) + + +class GCodeStopSpindle(GCodeSpindle): + """M5: Stop Spindle""" + #param_letters = set('S') # S is it's own gcode, makes no sense to be here + word_key = Word('M', 5) + modal_group = MODAL_GROUP_MAP['spindle'] + + +class GCodeOrientSpindle(GCodeSpindle): + """M19: Orient Spindle""" + word_key = Word('M', 19) + + +class GCodeSpindleSpeedMode(GCodeSpindle): + modal_group = MODAL_GROUP_MAP['spindle_speed_mode'] + + +class GCodeSpindleConstantSurfaceSpeedMode(GCodeSpindleSpeedMode): + """G96: Spindle Constant Surface Speed""" + param_letters = set('DS') + word_key = Word('G', 96) + + +class GCodeSpindleRPMMode(GCodeSpindleSpeedMode): + """G97: Spindle RPM Speed""" + param_letters = set('D') + word_key = Word('G', 97) + + + +# ======================= Coolant ======================= +# CODE PARAMETERS DESCRIPTION +# M7, M8, M9 Coolant Control + +class GCodeCoolant(GCode): + modal_group = MODAL_GROUP_MAP['coolant'] + exec_order = 110 + + +class GCodeCoolantMistOn(GCodeCoolant): + """M7: turn mist coolant on""" + word_key = Word('M', 7) + + +class GCodeCoolantFloodOn(GCodeCoolant): + """M8: turn flood coolant on""" + word_key = Word('M', 8) + + +class GCodeCoolantOff(GCodeCoolant): + """M9: turn all coolant off""" + word_key = Word('M', 9) + + +# ======================= Tool Length ======================= +# CODE PARAMETERS DESCRIPTION +# G43 H Tool Length Offset +# G43.1 Dynamic Tool Length Offset +# G43.2 H Apply additional Tool Length Offset +# G49 Cancel Tool Length Compensation + +class GCodeToolLength(GCode): + modal_group = MODAL_GROUP_MAP['tool_length_offset'] + exec_order = 180 + + +class GCodeToolLengthOffset(GCodeToolLength): + """G43: Tool Length Offset""" + param_letters = set('H') + word_key = Word('G', 43) + + +class GCodeDynamicToolLengthOffset(GCodeToolLength): + """G43.1: Dynamic Tool Length Offset""" + word_key = Word('G', 43.1) + + +class GCodeAddToolLengthOffset(GCodeToolLength): + """G43.2: Appkly Additional Tool Length Offset""" + param_letters = set('H') + word_key = Word('G', 43.2) + + +class GCodeCancelToolLengthOffset(GCodeToolLength): + """G49: Cancel Tool Length Compensation""" + word_key = Word('G', 49) + + +# ======================= Stopping (Program Control) ======================= +# CODE PARAMETERS DESCRIPTION +# M0, M1 Program Pause +# M2, M30 Program End +# M60 Pallet Change Pause + +class GCodeProgramControl(GCode): + modal_group = MODAL_GROUP_MAP['stopping'] + exec_order = 250 + +class GCodePauseProgram(GCodeProgramControl): + """M0: Program Pause""" + word_key = Word('M', 0) + + +class GCodePauseProgramOptional(GCodeProgramControl): + """M1: Program Pause (optional)""" + word_key = Word('M', 1) + + +class GCodeEndProgram(GCodeProgramControl): + """M2: Program End""" + word_key = Word('M', 2) + + +class GCodeEndProgramPalletShuttle(GCodeProgramControl): + """M30: exchange pallet shuttles and end the program""" + word_key = Word('M', 30) + + +class GCodePalletChangePause(GCodeProgramControl): + """M60: Pallet Change Pause""" + word_key = Word('M', 60) + + +# ======================= Units ======================= +# CODE PARAMETERS DESCRIPTION +# G20, G21 Units + +class GCodeUnit(GCode): + modal_group = MODAL_GROUP_MAP['units'] + exec_order = 160 + + +class GCodeUseInches(GCodeUnit): + """G20: use inches for length units""" + word_key = Word('G', 20) + unit_id = 0 + + +class GCodeUseMillimeters(GCodeUnit): + """G21: use millimeters for length units""" + word_key = Word('G', 21) + unit_id = 1 + + +# ======================= Plane Selection ======================= +# (affects G2, G3, G81-G89, G40-G42) +# CODE PARAMETERS DESCRIPTION +# G17 - G19.1 Plane Select + +class GCodePlaneSelect(GCode): + modal_group = MODAL_GROUP_MAP['plane_selection'] + exec_order = 150 + + # -- Plane Orientation Quaternion + # Such that... + # vectorXY = Vector3() + # vectorZX = GCodeSelectZXPlane.quat * vectorXY + # vectorZX += some_offset_vector + # vectorXY = GCodeSelectZXPlane.quat.conjugate() * vectorZX + # note: all quaternions use the XY plane as a basis + # To transform from ZX to YZ planes via these quaternions, you must + # first translate it to XY, like so: + # vectorYZ = GCodeSelectYZPlane.quat * (GCodeSelectZXPlane.quat.conjugate() * vectorZX) + quat = None # Quaternion + + # -- Plane Normal + # Vector normal to plane (such that XYZ axes follow the right-hand rule) + normal_axis = None # Letter of normal axis (upper case) + normal = None # Vector3 + + +class GCodeSelectXYPlane(GCodePlaneSelect): + """G17: select XY plane (default)""" + word_key = Word('G', 17) + quat = Quaternion() # no effect + normal_axis = 'Z' + normal = Vector3(0., 0., 1.) + + +class GCodeSelectZXPlane(GCodePlaneSelect): + """G18: select ZX plane""" + word_key = Word('G', 18) + quat = quat2coord_system( + Vector3(1., 0., 0.), Vector3(0., 1., 0.), + Vector3(0., 0., 1.), Vector3(1., 0., 0.) + ) + normal_axis = 'Y' + normal = Vector3(0., 1., 0.) + + +class GCodeSelectYZPlane(GCodePlaneSelect): + """G19: select YZ plane""" + word_key = Word('G', 19) + quat = quat2coord_system( + Vector3(1., 0., 0.), Vector3(0., 1., 0.), + Vector3(0., 1., 0.), Vector3(0., 0., 1.) + ) + normal_axis = 'X' + normal = Vector3(1., 0., 0.) + + +class GCodeSelectUVPlane(GCodePlaneSelect): + """G17.1: select UV plane""" + word_key = Word('G', 17.1) + + +class GCodeSelectWUPlane(GCodePlaneSelect): + """G18.1: select WU plane""" + word_key = Word('G', 18.1) + + +class GCodeSelectVWPlane(GCodePlaneSelect): + """G19.1: select VW plane""" + word_key = Word('G', 19.1) + + +# ======================= Cutter Radius Compensation ======================= +# CODE PARAMETERS DESCRIPTION +# G40 Compensation Off +# G41,G42 D Cutter Compensation +# G41.1, G42.1 D L Dynamic Cutter Compensation + +class GCodeCutterRadiusComp(GCode): + modal_group = MODAL_GROUP_MAP['cutter_diameter_comp'] + exec_order = 170 + + +class GCodeCutterRadiusCompOff(GCodeCutterRadiusComp): + """G40: Cutter Radius Compensation Off""" + word_key = Word('G', 40) + + +class GCodeCutterCompLeft(GCodeCutterRadiusComp): + """G41: Cutter Radius Compensation (left)""" + param_letters = set('D') + word_key = Word('G', 41) + + +class GCodeCutterCompRight(GCodeCutterRadiusComp): + """G42: Cutter Radius Compensation (right)""" + param_letters = set('D') + word_key = Word('G', 42) + + +class GCodeDynamicCutterCompLeft(GCodeCutterRadiusComp): + """G41.1: Dynamic Cutter Radius Compensation (left)""" + param_letters = set('DL') + word_key = Word('G', 41.1) + + +class GCodeDynamicCutterCompRight(GCodeCutterRadiusComp): + """G42.1: Dynamic Cutter Radius Compensation (right)""" + param_letters = set('DL') + word_key = Word('G', 42.1) + + +# ======================= Path Control Mode ======================= +# CODE PARAMETERS DESCRIPTION +# G61 G61.1 Exact Path Mode +# G64 P Q Path Blending + +class GCodePathControlMode(GCode): + modal_group = MODAL_GROUP_MAP['control_mode'] + exec_order = 200 + + +class GCodeExactPathMode(GCodePathControlMode): + """G61: Exact path mode""" + word_key = Word('G', 61) + + +class GCodeExactStopMode(GCodePathControlMode): + """G61.1: Exact stop mode""" + word_key = Word('G', 61.1) + + +class GCodePathBlendingMode(GCodePathControlMode): + """G64: Path Blending""" + param_letters = set('PQ') + word_key = Word('G', 64) + + +# ======================= Return Mode in Canned Cycles ======================= +# CODE PARAMETERS DESCRIPTION +# G98 Canned Cycle Return Level to previous +# G99 Canned Cycle Return to the level set by R + +class GCodeCannedReturnMode(GCode): + modal_group = MODAL_GROUP_MAP['canned_cycles_return'] + exec_order = 220 + + +class GCodeCannedCycleReturnLevel(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) + + +class GCodeCannedCycleReturnToR(GCodeCannedReturnMode): + """G99: Canned Cycle Return to the level set by R""" + # "retract to the position specified by the R word of the canned cycle" + word_key = Word('G', 99) + + +# ======================= Other Modal Codes ======================= +# CODE PARAMETERS DESCRIPTION +# F Set Feed Rate +# S Set Spindle Speed +# T Select Tool +# M48, M49 Speed and Feed Override Control +# M50 P0 (off) or P1 (on) Feed Override Control +# M51 P0 (off) or P1 (on) Spindle Speed Override Control +# M52 P0 (off) or P1 (on) Adaptive Feed Control +# M53 P0 (off) or P1 (on) Feed Stop Control +# G54-G59.3 Select Coordinate System + +class GCodeOtherModal(GCode): + pass + + +class GCodeFeedRate(GCodeOtherModal): + """F: Set Feed Rate""" + @classmethod + def word_matches(cls, w): + return w.letter == 'F' + default_word = Word('F', 0) + modal_group = MODAL_GROUP_MAP['feed_rate'] + exec_order = 40 + + +class GCodeSpindleSpeed(GCodeOtherModal): + """S: Set Spindle Speed""" + @classmethod + def word_matches(cls, w): + return w.letter == 'S' + default_word = Word('S', 0) + # Modal Group: (see description in GCodeFeedRate) + modal_group = MODAL_GROUP_MAP['spindle_speed'] + exec_order = 50 + + +class GCodeSelectTool(GCodeOtherModal): + """T: Select Tool""" + @classmethod + def word_matches(cls, w): + return w.letter == 'T' + default_word = Word('T', 0) + # Modal Group: (see description in GCodeFeedRate) + modal_group = MODAL_GROUP_MAP['tool'] + exec_order = 60 + + +class GCodeSpeedAndFeedOverrideOn(GCodeOtherModal): + """M48: Speed and Feed Override Control On""" + word_key = Word('M', 48) + modal_group = MODAL_GROUP_MAP['override_switches'] + exec_order = 120 + + +class GCodeSpeedAndFeedOverrideOff(GCodeOtherModal): + """M49: Speed and Feed Override Control Off""" + word_key = Word('M', 49) + modal_group = MODAL_GROUP_MAP['override_switches'] + exec_order = 120 + + +class GCodeFeedOverride(GCodeOtherModal): + """M50: Feed Override Control""" + param_letters = set('P') + word_key = Word('M', 50) + exec_order = 120 + + +class GCodeSpindleSpeedOverride(GCodeOtherModal): + """M51: Spindle Speed Override Control""" + param_letters = set('P') + word_key = Word('M', 51) + exec_order = 120 + + +class GCodeAdaptiveFeed(GCodeOtherModal): + """M52: Adaptive Feed Control""" + param_letters = set('P') + word_key = Word('M', 52) + exec_order = 120 + + +class GCodeFeedStop(GCodeOtherModal): + """M53: Feed Stop Control""" + param_letters = set('P') + word_key = Word('M', 53) + exec_order = 120 + + +class GCodeSelectCoordinateSystem(GCodeOtherModal): + """Select Coordinate System""" + modal_group = MODAL_GROUP_MAP['coordinate_system'] + exec_order = 190 + coord_system_id = None # overridden in inheriting classes + + +class GCodeSelectCoordinateSystem1(GCodeSelectCoordinateSystem): + """Select Coordinate System 1""" + word_key = Word('G', 54) + coord_system_id = 1 + + +class GCodeSelectCoordinateSystem2(GCodeSelectCoordinateSystem): + """Select Coordinate System 2""" + word_key = Word('G', 55) + coord_system_id = 2 + + +class GCodeSelectCoordinateSystem3(GCodeSelectCoordinateSystem): + """Select Coordinate System 3""" + word_key = Word('G', 56) + coord_system_id = 3 + + +class GCodeSelectCoordinateSystem4(GCodeSelectCoordinateSystem): + """Select Coordinate System 4""" + word_key = Word('G', 57) + coord_system_id = 4 + + +class GCodeSelectCoordinateSystem5(GCodeSelectCoordinateSystem): + """Select Coordinate System 5""" + word_key = Word('G', 58) + coord_system_id = 5 + + +class GCodeSelectCoordinateSystem6(GCodeSelectCoordinateSystem): + """Select Coordinate System 6""" + word_key = Word('G', 59) + coord_system_id = 6 + + +class GCodeSelectCoordinateSystem7(GCodeSelectCoordinateSystem): + """Select Coordinate System 7""" + coord_system_id = 7 + word_key = Word('G', 59.1) + + +class GCodeSelectCoordinateSystem8(GCodeSelectCoordinateSystem): + """Select Coordinate System 8""" + word_key = Word('G', 59.2) + coord_system_id = 8 + + +class GCodeSelectCoordinateSystem9(GCodeSelectCoordinateSystem): + """Select Coordinate System 9""" + word_key = Word('G', 59.3) + coord_system_id = 9 + + +# ======================= Flow-control Codes ======================= +# CODE PARAMETERS DESCRIPTION +# o sub Subroutines, sub/endsub call [unsupported] +# o while Looping, while/endwhile do/while [unsupported] +# o if Conditional, if/else/endif [unsupported] +# o repeat Repeat a loop of code [unsupported] +# [] Indirection [unsupported] +# o call Call named file [unsupported] +# M70 Save modal state [unsupported] +# M71 Invalidate stored state [unsupported] +# M72 Restore modal state [unsupported] +# M73 Save and Auto-restore modal state [unsupported] + + +# ======================= Input/Output Codes ======================= +# CODE PARAMETERS DESCRIPTION +# M62 - M65 P Digital Output Control +# M66 P E L Q Wait on Input +# M67 T Analog Output, Synchronized +# M68 T Analog Output, Immediate + +class GCodeIO(GCode): + exec_order = 70 + + +class GCodeDigitalOutput(GCodeIO): + """Digital Output Control""" + param_letters = set('P') + + +class GCodeDigitalOutputOnSyncd(GCodeDigitalOutput): + """M62: turn on digital output synchronized with motion""" + word_key = Word('M', 62) + + +class GCodeDigitalOutputOffSyncd(GCodeDigitalOutput): + """M63: turn off digital output synchronized with motion""" + word_key = Word('M', 63) + + +class GCodeDigitalOutputOn(GCodeDigitalOutput): + """M64: turn on digital output immediately""" + word_key = Word('M', 64) + + +class GCodeDigitalOutputOff(GCodeDigitalOutput): + """M65: turn off digital output immediately""" + word_key = Word('M', 65) + + +class GCodeWaitOnInput(GCodeIO): + """M66: Wait on Input""" + param_letters = set('PELQ') + word_key = Word('M', 66) + + +class GCodeAnalogOutput(GCodeIO): + """Analog Output""" + param_letters = set('T') + + +class GCodeAnalogOutputSyncd(GCodeAnalogOutput): + """M67: Analog Output, Synchronized""" + word_key = Word('M', 67) + + +class GCodeAnalogOutputImmediate(GCodeAnalogOutput): + """M68: Analog Output, Immediate""" + word_key = Word('M', 68) + + +# ======================= Non-modal Codes ======================= +# CODE PARAMETERS DESCRIPTION +# M6 T Tool Change +# M61 Q Set Current Tool +# G10 L1 P Q R Set Tool Table +# G10 L10 P Set Tool Table +# G10 L11 P Set Tool Table +# G10 L2 P R Set Coordinate System +# G10 L20 P Set Coordinate System +# G28, G28.1 Go/Set Predefined Position +# G30, G30.1 Go/Set Predefined Position +# G53 Move in Machine Coordinates +# G92 Coordinate System Offset +# G92.1, G92.2 Reset G92 Offsets +# G92.3 Restore G92 Offsets +# M101 - M199 P Q User Defined Commands + +class GCodeNonModal(GCode): + pass + + +class GCodeToolChange(GCodeNonModal): + """M6: Tool Change""" + param_letters = set('T') + word_key = Word('M', 6) + exec_order = 80 + + +class GCodeToolSetCurrent(GCodeNonModal): + """M61: Set Current Tool""" + param_letters = set('Q') + word_key = Word('M', 61) + exec_order = 80 + + +class GCodeSet(GCodeNonModal): + """G10: Set stuff""" + param_letters = set('LPQR') + word_key = Word('G', 10) + exec_order = 230 + + +class GCodeGotoPredefinedPosition(GCodeNonModal): + """G28,G30: Goto Predefined Position (rapid movement)""" + @classmethod + def word_matches(cls, w): + return (w.letter == 'G') and (w.value in [28, 30]) + default_word = Word('G', 28) + exec_order = 230 + + +class GCodeSetPredefinedPosition(GCodeNonModal): + """G28.1,G30.1: Set Predefined Position""" # redundancy in language there, but I'll let it slide + @classmethod + def word_matches(cls, w): + return (w.letter == 'G') and (w.value in [28.1, 30.1]) + default_word = Word('G', 28.1) + exec_order = 230 + + +class GCodeMoveInMachineCoords(GCodeNonModal): + """G53: Move in Machine Coordinates""" + word_key = Word('G', 53) + exec_order = 240 + + +class GCodeCoordSystemOffset(GCodeNonModal): + """G92: Coordinate System Offset""" + word_key = Word('G', 92) + exec_order = 230 + + +class GCodeResetCoordSystemOffset(GCodeNonModal): + """G92.1,G92.2: Reset Coordinate System Offset""" + @classmethod + def word_matches(cls, w): + return (w.letter == 'G') and (w.value in [92.1, 92.2]) + default_word = Word('G', 92.1) + exec_order = 230 + + # TODO: machine.state.offset *= 0 + + +class GCodeRestoreCoordSystemOffset(GCodeNonModal): + """G92.3: Restore Coordinate System Offset""" + word_key = Word('G', 92.3) + exec_order = 230 + + +class GCodeUserDefined(GCodeNonModal): + """M101-M199: User Defined Commands""" + # To create user g-codes, inherit from this class + param_letters = set('PQ') + #@classmethod + #def word_matches(cls, w): + # return (w.letter == 'M') and (101 <= w.value <= 199) + #default_word = Word('M', 101) + exec_order = 130 + modal_group = MODAL_GROUP_MAP['user_defined'] + + +# ======================= Utilities ======================= + +def _subclasses_level(root_class, recursion_level=0): + """ + Hierarcical list of all classes inheriting from the given root class (recursive) + :param root_class: class used as trunk of hierarchy (included inoutput) + :param recursion_level: should always be 0 (unless recursively called) + :param + """ + yield (root_class, recursion_level) + for cls in sorted(root_class.__subclasses__(), key=lambda c: c.__name__): + for (sub, level) in _subclasses_level(cls, recursion_level+1): + yield (sub, level) + + +def _subclasses(root_class): + """Flat list of all classes inheriting from the given root class (recursive)""" + for (cls, level) in _subclasses_level(root_class): + yield cls + + +def _gcode_class_infostr(base_class=GCode, prefix=''): + """ + List all ineheriting classes for the given gcode class + :param base_class: root of hierarcy + :return: str listing all gcode classes + """ + info_str = "" + for (cls, level) in _subclasses_level(base_class): + word_str = '' + if cls.word_key: + word_str = str(cls.word_key) + info_str += "{prefix}{word} {indent}- {name}: {description}\n".format( + prefix=prefix, + word="%-5s" % word_str, + indent=(level * " "), + name=cls.__name__, + description=cls.__doc__ or "", + ) + return info_str + + +# ======================= GCode Word Mapping ======================= +_gcode_maps_created = False # only set when the below values are populated +_gcode_word_map = {} # of the form: {Word('G', 0): GCodeRapidMove, ... } +_gcode_function_list = [] # of the form: [(lambda w: w.letter == 'F', GCodeFeedRate), ... ] + + +def build_maps(): + """Populate _gcode_word_map and _gcode_function_list""" + # Ensure Word maps / lists are clear + global _gcode_word_map + global _gcode_function_list + _gcode_word_map = {} + _gcode_function_list = [] + + for cls in _subclasses(GCode): + if cls.word_key is not None: + # Map Word instance to g-code class + if cls.word_key in _gcode_word_map: + raise RuntimeError("Multiple GCode classes map to '%s'" % str(cls.word_key)) + _gcode_word_map[cls.word_key] = cls + elif cls.word_matches is not None: + # Add to list of functions + _gcode_function_list.append((cls.word_matches, cls)) + + global _gcode_maps_created + _gcode_maps_created = True + + +# ======================= Words -> GCodes ======================= +def word_gcode_class(word, exhaustive=False): + """ + Map word to corresponding GCode class + :param word: Word instance + :param exhausitve: if True, all words are tested; not just 'GMFST' + :return: class inheriting GCode + """ + + if not _gcode_maps_created: + build_maps() + + # quickly eliminate parameters + if (not exhaustive) and (word.letter not in 'GMFST'): + return None + + # by Word Map (faster) + if word in _gcode_word_map: + return _gcode_word_map[word] + + # by Function List (slower, so checked last) + for (match_function, gcode_class) in _gcode_function_list: + if match_function(word): + return gcode_class + + return None + + +def words2gcodes(words): + """ + Group words into g-codes (includes both G & M codes) + :param words: list of Word instances + :return: tuple([, , ...], list()) + """ + + gcodes = [] + # Lines to consider + # Conflicts with non G|M codes (ie: S|F|T) + # Spindle Control: + # - S1000 + # - M3 S2000 + # Tool Change: + # - T2 + # - M6 T1 + # + # Conclusion: words are parameters first, gcodes second + + # First determine which words are GCode candidates + word_info_list = [ + { + 'index': i, # for internal referencing + 'word': word, + 'gcode_class': word_gcode_class(word), # if not None, word is a candidate + 'param_to_index': None, + } + for (i, word) in enumerate(words) + ] + + # Link parameters to candidates + # note: gcode candidates may be valid parameters... therefore + # Also eliminate candidates that are parameters for earlier gcode candidates + for word_info in word_info_list: + if word_info['gcode_class'] is None: + continue # not a gcode candidate, so cannot have parameters + # which of the words following this gcode candidate are a valid parameter + for param_info in word_info_list[word_info['index'] + 1:]: + if param_info['word'].letter in word_info['gcode_class'].param_letters: + param_info['param_to_index'] = word_info['index'] + param_info['gcode_class'] = None # no longer a valid candidate + + # Map parameters + parameter_map = defaultdict(list) # {: [], ... } + for word_info in word_info_list: + if word_info['gcode_class']: + continue # will form a gcode, so must not also be a parameter + parameter_map[word_info['param_to_index']].append(word_info['word']) + + # Create gcode instances + for word_info in word_info_list: + if word_info['gcode_class'] is None: + continue # not a gcode candidate + gcode = word_info['gcode_class']( + word_info['word'], + *parameter_map[word_info['index']] # gcode parameters + ) + gcodes.append(gcode) + + return (gcodes, parameter_map[None]) + + +def text2gcodes(text): + """ + Convert text to GCode instances (must be fully formed; no modal parameters) + :param text: line from a g-code file + :return: tuple([, , ...], list()) + """ + words = list(text2words(text)) + (gcodes, modal_words) = words2gcodes(words) + if modal_words: + raise GCodeWordStrError("gcode text not fully formed, unassigned parameters: %r" % modal_words) + return gcodes + + +# ======================= Utilities ======================= + +def split_gcodes(gcode_list, splitter_class, sort_list=True): + """ + Splits a list of GCode instances into 3, the center list containing the splitter_class gcode + :param gcode_list: list of GCode instances to split + :param splitter_class: class of gcode identifying split from left to right + :return: list of: [[], [], []] + """ + # for example: + # g_list = sorted([g1, g2, g3, g4]) + # split_gcodes(g_list, type(g2)) == [[g1], [g2], [g3, g4]] + # 3 lists are always returned, even if empty; if 2nd list is empty, + # then the 3rd will be as well. + if sort_list: # sort by execution order first + gcode_list = sorted(gcode_list) + + split = [gcode_list, [], []] # default (if no splitter can be found) + + # Find splitter index (only one can be found) + split_index = None + for (i, gcode) in enumerate(gcode_list): + if isinstance(gcode, splitter_class): + split_index = i + break + + # Form split: pivoting around split_index + if split_index is not None: + split[0] = gcode_list[:split_index] + split[1] = [gcode_list[split_index]] + split[2] = gcode_list[split_index+1:] + + return split + + +def _gcodes_abs2rel(start_pos, dist_mode=None, axes='XYZ'): + """ + Decorator to convert returned motion gcode coordinates to incremental. + Intended to be used internally (mainly because it's a little shonky). + Function being decorated is only expected to return GCodeRapidMove or + GCodeLinearMove instances. + :param start_pos: starting machine position (Position) + :param dist_mode: machine's distance mode (GCodeAbsoluteDistanceMode or GCodeIncrementalDistanceMode) + :param axes: axes machine accepts (set) + """ + # Usage: + # m = Machine() # defaults to absolute distance mode + # m.process_gcodes(GCodeRapidMove(X=10, Y=20, Z=3)) + # m.process_gcodes(GCodeIncrementalDistanceMode()) + # + # @_gcodes_abs2rel(start_pos=m.pos, dist_mode=m.mode.distance, axes=m.axes) + # def do_stuff(): + # yield GCodeRapidMove(X=0, Y=30, Z=3) + # yield GCodeLinearMove(X=0, Y=30, Z=-5) + # + # gcode_list = do_stuff() + # gocde_list[0] # == GCodeRapidMove(X=-10, Y=10) + # gocde_list[1] # == GCodeLinearMove(Z=-8) + + SUPPORTED_MOTIONS = ( + GCodeRapidMove, GCodeLinearMove, + ) + + def wrapper(func): + + def inner(*largs, **kwargs): + # Create Machine (with minimal information) + from .machine import Machine, Mode, Position + m = type('AbsoluteCoordMachine', (Machine,), { + 'MODE_CLASS': type('NullMode', (Mode,), {'default_mode': 'G90'}), + 'axes': axes, + })() + m.pos = start_pos + + for gcode in func(*largs, **kwargs): + # Verification & passthrough's + if not isinstance(gcode, GCode): + yield gcode # whatever this thing is + else: + # Process gcode + pos_from = m.pos + m.process_gcodes(gcode) + pos_to = m.pos + + if gcode.modal_group != MODAL_GROUP_MAP['motion']: + yield gcode # only deal with motion gcodes + continue + elif not isinstance(gcode, SUPPORTED_MOTIONS): + raise NotImplementedError("%r to iterative coords is not supported (this is only a very simple function)" % gcode) + + # Convert coordinates to iterative + rel_pos = pos_to - pos_from + coord_words = [w for w in rel_pos.words if w.value] + if coord_words: # else relative coords are all zero; do nothing + yield words2gcodes([gcode.word] + coord_words)[0].pop() + + + # Return apropriate function + if (dist_mode is None) or isinstance(dist_mode, GCodeIncrementalDistanceMode): + return inner + else: + return func # bypass decorator entirely; nothing to be done + return wrapper diff --git a/src/pygcode/line.py b/src/pygcode/line.py new file mode 100644 index 0000000..881b5f1 --- /dev/null +++ b/src/pygcode/line.py @@ -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]) diff --git a/src/pygcode/machine.py b/src/pygcode/machine.py new file mode 100644 index 0000000..4f0e42b --- /dev/null +++ b/src/pygcode/machine.py @@ -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_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 + + """ + 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 diff --git a/src/pygcode/transform.py b/src/pygcode/transform.py new file mode 100644 index 0000000..10a415b --- /dev/null +++ b/src/pygcode/transform.py @@ -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 (, ) 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: + # -> + + # 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() diff --git a/src/pygcode/utils.py b/src/pygcode/utils.py new file mode 100644 index 0000000..6c9216a --- /dev/null +++ b/src/pygcode/utils.py @@ -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 diff --git a/src/pygcode/words.py b/src/pygcode/words.py new file mode 100644 index 0000000..25d9197 --- /dev/null +++ b/src/pygcode/words.py @@ -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[%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: {: , ... } + """ + # 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) + ) diff --git a/tests/runtests.sh b/tests/runtests.sh new file mode 100755 index 0000000..06ad089 --- /dev/null +++ b/tests/runtests.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +python -m unittest discover -s . -p 'test_*.py' --verbose diff --git a/tests/test-files/vertical-slot.ngc b/tests/test-files/vertical-slot.ngc new file mode 100644 index 0000000..5f8d4b8 --- /dev/null +++ b/tests/test-files/vertical-slot.ngc @@ -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 + diff --git a/tests/test_file.py b/tests/test_file.py new file mode 100644 index 0000000..e678b74 --- /dev/null +++ b/tests/test_file.py @@ -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() diff --git a/tests/test_gcodes.py b/tests/test_gcodes.py new file mode 100644 index 0000000..3da46a8 --- /dev/null +++ b/tests/test_gcodes.py @@ -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.*)\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) diff --git a/tests/test_line.py b/tests/test_line.py new file mode 100644 index 0000000..24337ca --- /dev/null +++ b/tests/test_line.py @@ -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) diff --git a/tests/test_machine.py b/tests/test_machine.py new file mode 100644 index 0000000..5f52aa6 --- /dev/null +++ b/tests/test_machine.py @@ -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 diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..fdd5114 --- /dev/null +++ b/tests/test_utils.py @@ -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))) diff --git a/tests/test_words.py b/tests/test_words.py new file mode 100644 index 0000000..b5482c1 --- /dev/null +++ b/tests/test_words.py @@ -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'] + ) diff --git a/tests/testutils.py b/tests/testutils.py new file mode 100644 index 0000000..dcc494c --- /dev/null +++ b/tests/testutils.py @@ -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')