mirror of
https://git.mirrors.martin98.com/https://github.com/petaflot/pygcode
synced 2025-07-04 05:05:10 +08:00
commit
35a1dab19f
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
# python cache / compilations
|
||||
*__pycache__
|
||||
*.pyc
|
||||
|
||||
# editor backups
|
||||
*.swp
|
@ -1,31 +1,44 @@
|
||||
# pygcode
|
||||
pygcode
|
||||
=======
|
||||
|
||||
GCODE Parser for Python
|
||||
|
||||
Currently in development, this is planned to be a pythonic interpreter and encoder for g-code.
|
||||
I'll be learning along the way, but the plan is to follow the lead of [GRBL](https://github.com/gnea/grbl).
|
||||
Currently in development, this is planned to be a pythonic interpreter
|
||||
and encoder for g-code. I'll be learning along the way, but the plan is
|
||||
to follow the lead of `GRBL <https://github.com/gnea/grbl>`__.
|
||||
|
||||
## Installation
|
||||
Installation
|
||||
------------
|
||||
|
||||
`pip install pygcode`
|
||||
``pip install pygcode``
|
||||
|
||||
## Usage
|
||||
FIXME: well, that's the plan... give me some time to get it going
|
||||
though.
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
Just brainstorming here...
|
||||
|
||||
::
|
||||
|
||||
import pygcode
|
||||
import math
|
||||
import euclid
|
||||
|
||||
gfile_in = pygcode.Parser('part.gcode')
|
||||
gfile_out = pygcode.Writer('part2.gcode')
|
||||
gfile_in = pygcode.parse('part1.gcode') #
|
||||
gfile_out = pygcode.GCodeFile('part2.gcode')
|
||||
|
||||
total_travel = 0
|
||||
total_time = 0
|
||||
|
||||
for (state, block) in gfile_in.iterstate():
|
||||
# where:
|
||||
# state = CNC's state before the block is executed
|
||||
# block = the gcode to be executed next
|
||||
machine = pygcode.Machine()
|
||||
|
||||
for line in gfile_in.iterlines():
|
||||
|
||||
block = line.block
|
||||
if block is None:
|
||||
continue
|
||||
|
||||
# validation
|
||||
if isinstance(block, pygcode.GCodeArc):
|
||||
@ -36,13 +49,13 @@ Just brainstorming here...
|
||||
block.set_precision(0.0005, method=pygcode.GCodeArc.EFFECT_RADIUS)
|
||||
|
||||
# random metrics
|
||||
travel_vector = block.position - state.position # euclid.Vector3 instance
|
||||
travel_vector = block.position - machine.state.position # euclid.Vector3 instance
|
||||
distance = travel_vector.magnitude()
|
||||
travel = block.travel_distance(position=state.position) # eg: distance != travel for G02 & G03
|
||||
travel = block.travel_distance(position=machine.state.position) # eg: distance != travel for G02 & G03
|
||||
|
||||
total_travel += travel
|
||||
#total_time += block.time(feed_rate=state.feed_rate) # doesn't consider the feedrate being changed in this block
|
||||
total_time += block.time(state=state)
|
||||
#total_time += block.time(feed_rate=machine.state.feed_rate) # doesn't consider the feedrate being changed in this block
|
||||
total_time += block.time(state=machine.state)
|
||||
|
||||
# rotate : entire file 90deg CCW
|
||||
block.rotate(euclid.Quaternion.new_rotate_axis(
|
||||
@ -51,16 +64,23 @@ Just brainstorming here...
|
||||
# translate : entire file x += 1, y += 2 mm (after rotation)
|
||||
block.translate(euclid.Vector3(1, 2, 0), unit=pygcode.UNIT_MM)
|
||||
|
||||
|
||||
|
||||
# TODO: then do something like write it to another file
|
||||
gfile_out.write(block)
|
||||
|
||||
gfile_in.close()
|
||||
gfile_out.close()
|
||||
|
||||
Supported G-Codes
|
||||
-----------------
|
||||
|
||||
## Supported G-Codes
|
||||
GCode support is planned to follow that of [GRBL](https://github.com/gnea/grbl).
|
||||
GCode support is planned to follow that of
|
||||
`GRBL <https://github.com/gnea/grbl>`__ which follows
|
||||
`LinuxCNC <http://linuxcnc.org>`__ (list of gcodes documented
|
||||
`here <http://linuxcnc.org/docs/html/gcode.html>`__).
|
||||
|
||||
But anything pre v1.0 will be a sub-set, focusing on the issues I'm having... I'm selfish that way.
|
||||
But anything pre v1.0 will be a sub-set, focusing on the issues I'm
|
||||
having... I'm selfish that way.
|
||||
|
||||
TBD: list of gcodes (also as a TODO list)
|
226
scripts/pygcode-normalize.py
Executable file
226
scripts/pygcode-normalize.py
Executable file
@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# Script to take (theoretically) any g-code file as input, and output a
|
||||
# normalized version of it.
|
||||
#
|
||||
# Script outcome can have cursory verification with:
|
||||
# https://nraynaud.github.io/webgcode/
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
|
||||
for pygcode_lib_type in ('installed_lib', 'relative_lib'):
|
||||
try:
|
||||
# pygcode
|
||||
from pygcode import Word
|
||||
from pygcode import Machine, Mode, Line
|
||||
from pygcode import GCodeArcMove, GCodeArcMoveCW, GCodeArcMoveCCW
|
||||
from pygcode import GCodeCannedCycle
|
||||
from pygcode import split_gcodes
|
||||
from pygcode import Comment
|
||||
from pygcode.transform import linearize_arc, simplify_canned_cycle
|
||||
from pygcode.transform import ArcLinearizeInside, ArcLinearizeOutside, ArcLinearizeMid
|
||||
from pygcode.gcodes import _subclasses
|
||||
from pygcode.utils import omit_redundant_modes
|
||||
|
||||
except ImportError:
|
||||
import sys, os, inspect
|
||||
# Add pygcode (relative to this test-path) to the system path
|
||||
_this_path = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
|
||||
sys.path.insert(0, os.path.join(_this_path, '..'))
|
||||
if pygcode_lib_type == 'installed_lib':
|
||||
continue # import was attempted before sys.path addition. retry import
|
||||
raise # otherwise the raised ImportError is a genuine problem
|
||||
break
|
||||
|
||||
|
||||
# =================== Command Line Arguments ===================
|
||||
# --- Defaults
|
||||
DEFAULT_PRECISION = 0.005 # mm
|
||||
DEFAULT_MACHINE_MODE = 'G0 G54 G17 G21 G90 G94 M5 M9 T0 F0 S0'
|
||||
DEFAULT_ARC_LIN_METHOD = 'm'
|
||||
DEFAULT_CANNED_CODES = ','.join(str(w) for w in sorted(c.word_key for c in _subclasses(GCodeCannedCycle) if c.word_key))
|
||||
|
||||
# --- Create Parser
|
||||
parser = argparse.ArgumentParser(description='Normalize gcode for machine consistency using different CAM software')
|
||||
parser.add_argument(
|
||||
'infile', type=argparse.FileType('r'), nargs=1,
|
||||
help="gcode file to normalize",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--precision', '-p', dest='precision', type=float, default=DEFAULT_PRECISION,
|
||||
help="maximum positional error when generating gcodes (eg: arcs to lines) "
|
||||
"(default: %g)" % DEFAULT_PRECISION,
|
||||
)
|
||||
|
||||
# Machine
|
||||
parser.add_argument(
|
||||
'--machine_mode', '-mm', dest='machine_mode', default=DEFAULT_MACHINE_MODE,
|
||||
help="Machine's startup mode as gcode (default: '%s')" % DEFAULT_MACHINE_MODE,
|
||||
)
|
||||
|
||||
# Arc Linearizing
|
||||
group = parser.add_argument_group(
|
||||
"Arc Linearizing",
|
||||
"Converting arcs (G2/G3 codes) into linear interpolations (G1 codes) to "
|
||||
"aproximate the original arc. Indistinguishable from an original arc when "
|
||||
"--precision is set low enough."
|
||||
)
|
||||
group.add_argument(
|
||||
'--arc_linearize', '-al', dest='arc_linearize',
|
||||
action='store_const', const=True, default=False,
|
||||
help="convert G2,G3 commands to a series of linear interpolations (G1 codes)",
|
||||
)
|
||||
group.add_argument(
|
||||
'--arc_lin_method', '-alm', dest='arc_lin_method', default=DEFAULT_ARC_LIN_METHOD,
|
||||
help="Method of linearizing arcs, i=inner, o=outer, m=mid. List 2 "
|
||||
"for <ccw>,<cw>, eg 'i,o'. 'i' is equivalent to 'i,i'. "
|
||||
"(default: '%s')" % DEFAULT_ARC_LIN_METHOD,
|
||||
metavar='{i,o,m}[,{i,o,m}]',
|
||||
)
|
||||
|
||||
# Canned Cycles
|
||||
group = parser.add_argument_group(
|
||||
"Canned Cycle Simplification",
|
||||
"Convert canned cycles into basic linear or scalar codes, such as linear "
|
||||
"interpolation (G1), and pauses (or 'dwells', G4)"
|
||||
)
|
||||
group.add_argument(
|
||||
'--canned_expand', '-ce', dest='canned_expand',
|
||||
action='store_const', const=True, default=False,
|
||||
help="Expand canned cycles into basic linear movements, and pauses",
|
||||
)
|
||||
group.add_argument(
|
||||
'---canned_codes', '-cc', dest='canned_codes', default=DEFAULT_CANNED_CODES,
|
||||
help="List of canned gcodes to expand, (default is '%s')" % DEFAULT_CANNED_CODES,
|
||||
)
|
||||
|
||||
#parser.add_argument(
|
||||
# '--arc_alignment', '-aa', dest='arc_alignment', type=str, choices=('XYZ','IJK','R'),
|
||||
# default=None,
|
||||
# help="enforce precision on arcs, if XYZ the destination is altered to match the radius"
|
||||
# "if IJK or R then the arc'c centre point is moved to assure precision",
|
||||
#)
|
||||
|
||||
# --- Parse Arguments
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
# --- Manually Parsing : Arc Linearizing Method
|
||||
# args.arc_lin_method = {Word('G2'): <linearize method class>, ... }
|
||||
ARC_LIN_CLASS_MAP = {
|
||||
'i': ArcLinearizeInside,
|
||||
'o': ArcLinearizeOutside,
|
||||
'm': ArcLinearizeMid,
|
||||
}
|
||||
|
||||
arc_lin_method_regex = re.compile(r'^(?P<g2>[iom])(,(?P<g3>[iom]))?$', re.I)
|
||||
if args.arc_lin_method:
|
||||
match = arc_lin_method_regex.search(args.arc_lin_method)
|
||||
if not match:
|
||||
raise RuntimeError("parameter for --arc_lin_method is invalid: '%s'" % args.arc_lin_method)
|
||||
|
||||
# changing args.arc_lin_method (because I'm a fiend)
|
||||
args.arc_lin_method = {}
|
||||
args.arc_lin_method[Word('g2')] = ARC_LIN_CLASS_MAP[match.group('g2')]
|
||||
if match.group('g3'):
|
||||
args.arc_lin_method[Word('g3')] = ARC_LIN_CLASS_MAP[match.group('g3')]
|
||||
else:
|
||||
args.arc_lin_method[Word('g3')] = args.arc_lin_method[Word('g2')]
|
||||
else:
|
||||
# FIXME: change default to ArcLinearizeMid (when it's working)
|
||||
args.arc_lin_method = defaultdict(lambda: ArcLinearizeMid) # just to be sure
|
||||
|
||||
|
||||
# --- Manually Parsing : Canned Codes
|
||||
# args.canned_codes = [Word('G73'), Word('G89'), ... ]
|
||||
canned_code_words = set()
|
||||
for word_str in re.split(r'\s*,\s*', args.canned_codes):
|
||||
canned_code_words.add(Word(word_str))
|
||||
|
||||
args.canned_codes = canned_code_words
|
||||
|
||||
|
||||
# =================== Create Virtual CNC Machine ===================
|
||||
class MyMode(Mode):
|
||||
default_mode = args.machine_mode
|
||||
|
||||
class MyMachine(Machine):
|
||||
MODE_CLASS = MyMode
|
||||
|
||||
machine = MyMachine()
|
||||
|
||||
# =================== Utility Functions ===================
|
||||
def gcodes2str(gcodes):
|
||||
return ' '.join("%s" % g for g in gcodes)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def split_and_process(gcode_list, gcode_class, comment):
|
||||
"""
|
||||
Split gcodes by given class, yields given class instance
|
||||
:param gcode_list: list of GCode instances
|
||||
:param gcode_class: class inheriting from GCode (directly, or indirectly)
|
||||
:param comment: Comment instance, or None
|
||||
"""
|
||||
(befores, (g,), afters) = split_gcodes(gcode_list, gcode_class)
|
||||
# print & process those before gcode_class instance
|
||||
if befores:
|
||||
print(gcodes2str(befores))
|
||||
machine.process_gcodes(*befores)
|
||||
# yield, then process gcode_class instance
|
||||
yield g
|
||||
machine.process_gcodes(g)
|
||||
# print & process those after gcode_class instance
|
||||
if afters:
|
||||
print(gcodes2str(afters))
|
||||
machine.process_gcodes(*afters)
|
||||
# print comment (if given)
|
||||
if comment:
|
||||
print(str(line.comment))
|
||||
|
||||
|
||||
# =================== Process File ===================
|
||||
|
||||
for line_str in args.infile[0].readlines():
|
||||
line = Line(line_str)
|
||||
|
||||
# Effective G-Codes:
|
||||
# fills in missing motion modal gcodes (using machine's current motion mode).
|
||||
effective_gcodes = machine.block_modal_gcodes(line.block)
|
||||
|
||||
if args.arc_linearize and any(isinstance(g, GCodeArcMove) for g in effective_gcodes):
|
||||
with split_and_process(effective_gcodes, GCodeArcMove, line.comment) as arc:
|
||||
print(Comment("linearized arc: %r" % arc))
|
||||
linearize_params = {
|
||||
'arc_gcode': arc,
|
||||
'start_pos': machine.pos,
|
||||
'plane': machine.mode.plane_selection,
|
||||
'method_class': args.arc_lin_method[arc.word],
|
||||
'dist_mode': machine.mode.distance,
|
||||
'arc_dist_mode': machine.mode.arc_ijk_distance,
|
||||
'max_error': args.precision,
|
||||
'decimal_places': 3,
|
||||
}
|
||||
for linear_gcode in omit_redundant_modes(linearize_arc(**linearize_params)):
|
||||
print(linear_gcode)
|
||||
|
||||
elif args.canned_expand and any((g.word in args.canned_codes) for g in effective_gcodes):
|
||||
with split_and_process(effective_gcodes, GCodeCannedCycle, line.comment) as canned:
|
||||
print(Comment("expanded: %r" % canned))
|
||||
simplify_canned_params = {
|
||||
'canned_gcode': canned,
|
||||
'start_pos': machine.pos,
|
||||
'plane': machine.mode.plane_selection,
|
||||
'dist_mode': machine.mode.distance,
|
||||
'axes': machine.axes,
|
||||
}
|
||||
for simplified_gcode in omit_redundant_modes(simplify_canned_cycle(**simplify_canned_params)):
|
||||
print(simplified_gcode)
|
||||
|
||||
else:
|
||||
print(str(line))
|
||||
machine.process_block(line.block)
|
6
setup.cfg
Normal file
6
setup.cfg
Normal file
@ -0,0 +1,6 @@
|
||||
[bdist_wheel]
|
||||
universal = 1
|
||||
|
||||
[metadata]
|
||||
description-file = README.md
|
||||
license_file = LICENSE
|
96
setup.py
Normal file
96
setup.py
Normal file
@ -0,0 +1,96 @@
|
||||
import codecs
|
||||
import os
|
||||
import re
|
||||
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
|
||||
###################################################################
|
||||
|
||||
NAME = "attrs"
|
||||
PACKAGES = find_packages(where="src")
|
||||
META_PATH = os.path.join("src", "attr", "__init__.py")
|
||||
KEYWORDS = ["class", "attribute", "boilerplate"]
|
||||
CLASSIFIERS = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Natural Language :: English",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 2",
|
||||
"Programming Language :: Python :: 2.7",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.3",
|
||||
"Programming Language :: Python :: 3.4",
|
||||
"Programming Language :: Python :: 3.5",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Programming Language :: Python :: Implementation :: PyPy",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
]
|
||||
INSTALL_REQUIRES = []
|
||||
|
||||
###################################################################
|
||||
|
||||
HERE = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
|
||||
def read(*parts):
|
||||
"""
|
||||
Build an absolute path from *parts* and and return the contents of the
|
||||
resulting file. Assume UTF-8 encoding.
|
||||
"""
|
||||
with codecs.open(os.path.join(HERE, *parts), "rb", "utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
META_FILE = read(META_PATH)
|
||||
|
||||
|
||||
def find_meta(meta):
|
||||
"""
|
||||
Extract __*meta*__ from META_FILE.
|
||||
"""
|
||||
meta_match = re.search(
|
||||
r"^(?P<name>__{meta}__)\s*=\s*['\"](?P<value>[^'\"]*)['\"]".format(meta=meta),
|
||||
META_FILE, re.M
|
||||
)
|
||||
if meta_match:
|
||||
return meta_match.group('value')
|
||||
raise RuntimeError("Unable to find __{meta}__ string.".format(meta=meta))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
setup(
|
||||
name=NAME,
|
||||
description=find_meta("description"),
|
||||
license=find_meta("license"),
|
||||
url=find_meta("uri"),
|
||||
version=find_meta("version"),
|
||||
author=find_meta("author"),
|
||||
author_email=find_meta("email"),
|
||||
maintainer=find_meta("author"),
|
||||
maintainer_email=find_meta("email"),
|
||||
keywords=KEYWORDS,
|
||||
long_description=read("README.rst"),
|
||||
packages=PACKAGES,
|
||||
package_dir={"": "src"},
|
||||
zip_safe=False,
|
||||
classifiers=CLASSIFIERS,
|
||||
install_requires=INSTALL_REQUIRES,
|
||||
)
|
||||
|
||||
#VERSION = '0.1.dev' # *.dev = release candidate
|
||||
#
|
||||
#setup(
|
||||
# name = 'pygcode',
|
||||
# packages = ['pygcode'],
|
||||
# version = VERSION,
|
||||
# description = 'basic g-code parser, interpreter, and writer library',
|
||||
# author = 'Peter Boin',
|
||||
# author_email = 'peter.boin@gmail.com',
|
||||
# url = 'https://github.com/fragmuffin/pygcode',
|
||||
# download_url = 'https://github.com/fragmuffin/pygcode/archive/%s.tar.gz' % VERSION,
|
||||
# keywords = ['gcode', 'cnc', 'parser', 'interpreter'],
|
||||
# classifiers = [],
|
||||
#)
|
413
src/pygcode/__init__.py
Normal file
413
src/pygcode/__init__.py
Normal file
@ -0,0 +1,413 @@
|
||||
__all__ = [
|
||||
# Machine
|
||||
'Machine', 'Position', 'CoordinateSystem', 'State', 'Mode',
|
||||
# Line
|
||||
'Line',
|
||||
# Block
|
||||
'Block',
|
||||
# Comment
|
||||
'Comment', 'split_line',
|
||||
# Word
|
||||
'Word', 'text2words', 'str2word', 'words2dict',
|
||||
|
||||
# GCodes
|
||||
'words2gcodes', 'text2gcodes', 'split_gcodes',
|
||||
# $ python -c "from pygcode.gcodes import GCode, _subclasses as sc; print(',\\n '.join(sorted('\\'%s\\'' % g.__name__ for g in sc(GCode))))"python -c "from pygcode.gcodes import GCode, _subclasses as sc; print(',\\n '.join(sorted('\\'%s\\'' % g.__name__ for g in sc(GCode))))"
|
||||
'GCode',
|
||||
'GCodeAbsoluteArcDistanceMode',
|
||||
'GCodeAbsoluteDistanceMode',
|
||||
'GCodeAdaptiveFeed',
|
||||
'GCodeAddToolLengthOffset',
|
||||
'GCodeAnalogOutput',
|
||||
'GCodeAnalogOutputImmediate',
|
||||
'GCodeAnalogOutputSyncd',
|
||||
'GCodeArcMove',
|
||||
'GCodeArcMoveCCW',
|
||||
'GCodeArcMoveCW',
|
||||
'GCodeBoringCycleDwellFeedOut',
|
||||
'GCodeBoringCycleFeedOut',
|
||||
'GCodeCancelCannedCycle',
|
||||
'GCodeCancelToolLengthOffset',
|
||||
'GCodeCannedCycle',
|
||||
'GCodeCannedCycleReturnLevel',
|
||||
'GCodeCannedReturnMode',
|
||||
'GCodeCoolant',
|
||||
'GCodeCoolantFloodOn',
|
||||
'GCodeCoolantMistOn',
|
||||
'GCodeCoolantOff',
|
||||
'GCodeCoordSystemOffset',
|
||||
'GCodeCublcSpline',
|
||||
'GCodeCutterCompLeft',
|
||||
'GCodeCutterCompRight',
|
||||
'GCodeCutterRadiusComp',
|
||||
'GCodeCutterRadiusCompOff',
|
||||
'GCodeDigitalOutput',
|
||||
'GCodeDigitalOutputOff',
|
||||
'GCodeDigitalOutputOffSyncd',
|
||||
'GCodeDigitalOutputOn',
|
||||
'GCodeDigitalOutputOnSyncd',
|
||||
'GCodeDistanceMode',
|
||||
'GCodeDrillingCycle',
|
||||
'GCodeDrillingCycleChipBreaking',
|
||||
'GCodeDrillingCycleDwell',
|
||||
'GCodeDrillingCyclePeck',
|
||||
'GCodeDwell',
|
||||
'GCodeDynamicCutterCompLeft',
|
||||
'GCodeDynamicCutterCompRight',
|
||||
'GCodeDynamicToolLengthOffset',
|
||||
'GCodeEndProgram',
|
||||
'GCodeEndProgramPalletShuttle',
|
||||
'GCodeExactPathMode',
|
||||
'GCodeExactStopMode',
|
||||
'GCodeFeedOverride',
|
||||
'GCodeFeedRate',
|
||||
'GCodeFeedRateMode',
|
||||
'GCodeFeedStop',
|
||||
'GCodeGotoPredefinedPosition',
|
||||
'GCodeIO',
|
||||
'GCodeIncrementalArcDistanceMode',
|
||||
'GCodeIncrementalDistanceMode',
|
||||
'GCodeInverseTimeMode',
|
||||
'GCodeLatheDiameterMode',
|
||||
'GCodeLatheRadiusMode',
|
||||
'GCodeLinearMove',
|
||||
'GCodeMotion',
|
||||
'GCodeMoveInMachineCoords',
|
||||
'GCodeNURBS',
|
||||
'GCodeNURBSEnd',
|
||||
'GCodeNonModal',
|
||||
'GCodeOrientSpindle',
|
||||
'GCodeOtherModal',
|
||||
'GCodePalletChangePause',
|
||||
'GCodePathBlendingMode',
|
||||
'GCodePathControlMode',
|
||||
'GCodePauseProgram',
|
||||
'GCodePauseProgramOptional',
|
||||
'GCodePlaneSelect',
|
||||
'GCodeProgramControl',
|
||||
'GCodeQuadraticSpline',
|
||||
'GCodeRapidMove',
|
||||
'GCodeResetCoordSystemOffset',
|
||||
'GCodeRestoreCoordSystemOffset',
|
||||
'GCodeRigidTapping',
|
||||
'GCodeSelectCoordinateSystem',
|
||||
'GCodeSelectCoordinateSystem1',
|
||||
'GCodeSelectCoordinateSystem2',
|
||||
'GCodeSelectCoordinateSystem3',
|
||||
'GCodeSelectCoordinateSystem4',
|
||||
'GCodeSelectCoordinateSystem5',
|
||||
'GCodeSelectCoordinateSystem6',
|
||||
'GCodeSelectCoordinateSystem7',
|
||||
'GCodeSelectCoordinateSystem8',
|
||||
'GCodeSelectCoordinateSystem9',
|
||||
'GCodeSelectTool',
|
||||
'GCodeSelectUVPlane',
|
||||
'GCodeSelectVWPlane',
|
||||
'GCodeSelectWUPlane',
|
||||
'GCodeSelectXYPlane',
|
||||
'GCodeSelectYZPlane',
|
||||
'GCodeSelectZXPlane',
|
||||
'GCodeSet',
|
||||
'GCodeSetPredefinedPosition',
|
||||
'GCodeSpeedAndFeedOverrideOff',
|
||||
'GCodeSpeedAndFeedOverrideOn',
|
||||
'GCodeSpindle',
|
||||
'GCodeSpindleConstantSurfaceSpeedMode',
|
||||
'GCodeSpindleRPMMode',
|
||||
'GCodeSpindleSpeed',
|
||||
'GCodeSpindleSpeedMode',
|
||||
'GCodeSpindleSpeedOverride',
|
||||
'GCodeSpindleSyncMotion',
|
||||
'GCodeStartSpindle',
|
||||
'GCodeStartSpindleCCW',
|
||||
'GCodeStartSpindleCW',
|
||||
'GCodeStopSpindle',
|
||||
'GCodeStraightProbe',
|
||||
'GCodeThreadingCycle',
|
||||
'GCodeToolChange',
|
||||
'GCodeToolLength',
|
||||
'GCodeToolLengthOffset',
|
||||
'GCodeToolSetCurrent',
|
||||
'GCodeUnit',
|
||||
'GCodeUnitsPerMinuteMode',
|
||||
'GCodeUnitsPerRevolution',
|
||||
'GCodeUseInches',
|
||||
'GCodeUseMillimeters',
|
||||
'GCodeUserDefined',
|
||||
'GCodeWaitOnInput'
|
||||
]
|
||||
|
||||
# Machine
|
||||
from .machine import (
|
||||
Position, CoordinateSystem,
|
||||
State, Mode,
|
||||
Machine,
|
||||
)
|
||||
|
||||
# Line
|
||||
from .line import Line
|
||||
|
||||
# Block
|
||||
from .block import Block
|
||||
|
||||
# Comment
|
||||
from .comment import Comment, split_line
|
||||
|
||||
# Word
|
||||
from .words import (
|
||||
Word,
|
||||
text2words, str2word, words2dict,
|
||||
)
|
||||
|
||||
# GCode
|
||||
from .gcodes import (
|
||||
words2gcodes, text2gcodes, split_gcodes,
|
||||
|
||||
# $ python -c "from pygcode.gcodes import _gcode_class_infostr as x; print(x(prefix=' # '))"
|
||||
# - GCode:
|
||||
# - GCodeCannedCycle:
|
||||
# G89 - GCodeBoringCycleDwellFeedOut: G89: Boring Cycle, Dwell, Feed Out
|
||||
# G85 - GCodeBoringCycleFeedOut: G85: Boring Cycle, Feed Out
|
||||
# G81 - GCodeDrillingCycle: G81: Drilling Cycle
|
||||
# G73 - GCodeDrillingCycleChipBreaking: G73: Drilling Cycle, ChipBreaking
|
||||
# G82 - GCodeDrillingCycleDwell: G82: Drilling Cycle, Dwell
|
||||
# G83 - GCodeDrillingCyclePeck: G83: Drilling Cycle, Peck
|
||||
# G76 - GCodeThreadingCycle: G76: Threading Cycle
|
||||
# - GCodeCannedReturnMode:
|
||||
# G98 - GCodeCannedCycleReturnLevel: G98: Canned Cycle Return Level
|
||||
# - GCodeCoolant:
|
||||
# M08 - GCodeCoolantFloodOn: M8: turn flood coolant on
|
||||
# M07 - GCodeCoolantMistOn: M7: turn mist coolant on
|
||||
# M09 - GCodeCoolantOff: M9: turn all coolant off
|
||||
# - GCodeCutterRadiusComp:
|
||||
# G41 - GCodeCutterCompLeft: G41: Cutter Radius Compensation (left)
|
||||
# G42 - GCodeCutterCompRight: G42: Cutter Radius Compensation (right)
|
||||
# G40 - GCodeCutterRadiusCompOff: G40: Cutter Radius Compensation Off
|
||||
# G41.1 - GCodeDynamicCutterCompLeft: G41.1: Dynamic Cutter Radius Compensation (left)
|
||||
# G42.1 - GCodeDynamicCutterCompRight: G42.1: Dynamic Cutter Radius Compensation (right)
|
||||
# - GCodeDistanceMode:
|
||||
# G90.1 - GCodeAbsoluteArcDistanceMode: G90.1: Absolute Distance Mode for Arc IJK Parameters
|
||||
# G90 - GCodeAbsoluteDistanceMode: G90: Absolute Distance Mode
|
||||
# G91.1 - GCodeIncrementalArcDistanceMode: G91.1: Incremental Distance Mode for Arc IJK Parameters
|
||||
# G91 - GCodeIncrementalDistanceMode: G91: Incremental Distance Mode
|
||||
# G07 - GCodeLatheDiameterMode: G7: Lathe Diameter Mode
|
||||
# G08 - GCodeLatheRadiusMode: G8: Lathe Radius Mode
|
||||
# - GCodeFeedRateMode:
|
||||
# G93 - GCodeInverseTimeMode: G93: Inverse Time Mode
|
||||
# G94 - GCodeUnitsPerMinuteMode: G94: Units Per MinuteMode
|
||||
# G95 - GCodeUnitsPerRevolution: G95: Units Per Revolution
|
||||
# - GCodeIO:
|
||||
# - GCodeAnalogOutput: Analog Output
|
||||
# M68 - GCodeAnalogOutputImmediate: M68: Analog Output, Immediate
|
||||
# M67 - GCodeAnalogOutputSyncd: M67: Analog Output, Synchronized
|
||||
# - GCodeDigitalOutput: Digital Output Control
|
||||
# M65 - GCodeDigitalOutputOff: M65: turn off digital output immediately
|
||||
# M63 - GCodeDigitalOutputOffSyncd: M63: turn off digital output synchronized with motion
|
||||
# M64 - GCodeDigitalOutputOn: M64: turn on digital output immediately
|
||||
# M62 - GCodeDigitalOutputOnSyncd: M62: turn on digital output synchronized with motion
|
||||
# M66 - GCodeWaitOnInput: M66: Wait on Input
|
||||
# - GCodeMotion:
|
||||
# - GCodeArcMove: Arc Move
|
||||
# G03 - GCodeArcMoveCCW: G3: Arc Move (counter-clockwise)
|
||||
# G02 - GCodeArcMoveCW: G2: Arc Move (clockwise)
|
||||
# G80 - GCodeCancelCannedCycle: G80: Cancel Canned Cycle
|
||||
# G05 - GCodeCublcSpline: G5: Cubic Spline
|
||||
# G04 - GCodeDwell: G4: Dwell
|
||||
# G01 - GCodeLinearMove: G1: Linear Move
|
||||
# G05.2 - GCodeNURBS: G5.2: Non-uniform rational basis spline (NURBS)
|
||||
# G05.3 - GCodeNURBSEnd: G5.3: end NURBS mode
|
||||
# G05.1 - GCodeQuadraticSpline: G5.1: Quadratic Spline
|
||||
# G00 - GCodeRapidMove: G0: Rapid Move
|
||||
# G33.1 - GCodeRigidTapping: G33.1: Rigid Tapping
|
||||
# G33 - GCodeSpindleSyncMotion: G33: Spindle Synchronized Motion
|
||||
# - GCodeStraightProbe: G38.2-G38.5: Straight Probe
|
||||
# - GCodeNonModal:
|
||||
# G92 - GCodeCoordSystemOffset: G92: Coordinate System Offset
|
||||
# - GCodeGotoPredefinedPosition: G28,G30: Goto Predefined Position (rapid movement)
|
||||
# G53 - GCodeMoveInMachineCoords: G53: Move in Machine Coordinates
|
||||
# - GCodeResetCoordSystemOffset: G92.1,G92.2: Reset Coordinate System Offset
|
||||
# G92.3 - GCodeRestoreCoordSystemOffset: G92.3: Restore Coordinate System Offset
|
||||
# G10 - GCodeSet: G10: Set stuff
|
||||
# - GCodeSetPredefinedPosition: G28.1,G30.1: Set Predefined Position
|
||||
# M06 - GCodeToolChange: M6: Tool Change
|
||||
# M61 - GCodeToolSetCurrent: M61: Set Current Tool
|
||||
# - GCodeUserDefined: M101-M199: User Defined Commands
|
||||
# - GCodeOtherModal:
|
||||
# M52 - GCodeAdaptiveFeed: M52: Adaptive Feed Control
|
||||
# M50 - GCodeFeedOverride: M50: Feed Override Control
|
||||
# - GCodeFeedRate: F: Set Feed Rate
|
||||
# M53 - GCodeFeedStop: M53: Feed Stop Control
|
||||
# - GCodeSelectCoordinateSystem: Select Coordinate System
|
||||
# G54 - GCodeSelectCoordinateSystem1: Select Coordinate System 1
|
||||
# G55 - GCodeSelectCoordinateSystem2: Select Coordinate System 2
|
||||
# G56 - GCodeSelectCoordinateSystem3: Select Coordinate System 3
|
||||
# G57 - GCodeSelectCoordinateSystem4: Select Coordinate System 4
|
||||
# G58 - GCodeSelectCoordinateSystem5: Select Coordinate System 5
|
||||
# G59 - GCodeSelectCoordinateSystem6: Select Coordinate System 6
|
||||
# G59.1 - GCodeSelectCoordinateSystem7: Select Coordinate System 7
|
||||
# G59.2 - GCodeSelectCoordinateSystem8: Select Coordinate System 8
|
||||
# G59.3 - GCodeSelectCoordinateSystem9: Select Coordinate System 9
|
||||
# - GCodeSelectTool: T: Select Tool
|
||||
# M49 - GCodeSpeedAndFeedOverrideOff: M49: Speed and Feed Override Control Off
|
||||
# M48 - GCodeSpeedAndFeedOverrideOn: M48: Speed and Feed Override Control On
|
||||
# - GCodeSpindleSpeed: S: Set Spindle Speed
|
||||
# M51 - GCodeSpindleSpeedOverride: M51: Spindle Speed Override Control
|
||||
# - GCodePathControlMode:
|
||||
# G61 - GCodeExactPathMode: G61: Exact path mode
|
||||
# G61.1 - GCodeExactStopMode: G61.1: Exact stop mode
|
||||
# G64 - GCodePathBlendingMode: G64: Path Blending
|
||||
# - GCodePlaneSelect:
|
||||
# G17.1 - GCodeSelectUVPlane: G17.1: select UV plane
|
||||
# G19.1 - GCodeSelectVWPlane: G19.1: select VW plane
|
||||
# G18.1 - GCodeSelectWUPlane: G18.1: select WU plane
|
||||
# G17 - GCodeSelectXYPlane: G17: select XY plane (default)
|
||||
# G19 - GCodeSelectYZPlane: G19: select YZ plane
|
||||
# G18 - GCodeSelectZXPlane: G18: select ZX plane
|
||||
# - GCodeProgramControl:
|
||||
# M02 - GCodeEndProgram: M2: Program End
|
||||
# M30 - GCodeEndProgramPalletShuttle: M30: exchange pallet shuttles and end the program
|
||||
# M60 - GCodePalletChangePause: M60: Pallet Change Pause
|
||||
# M00 - GCodePauseProgram: M0: Program Pause
|
||||
# M01 - GCodePauseProgramOptional: M1: Program Pause (optional)
|
||||
# - GCodeSpindle:
|
||||
# M19 - GCodeOrientSpindle: M19: Orient Spindle
|
||||
# - GCodeSpindleSpeedMode:
|
||||
# G96 - GCodeSpindleConstantSurfaceSpeedMode: G96: Spindle Constant Surface Speed
|
||||
# G97 - GCodeSpindleRPMMode: G97: Spindle RPM Speed
|
||||
# - GCodeStartSpindle: M3,M4: Start Spindle Clockwise
|
||||
# M04 - GCodeStartSpindleCCW: M4: Start Spindle Counter-Clockwise
|
||||
# M03 - GCodeStartSpindleCW: M3: Start Spindle Clockwise
|
||||
# M05 - GCodeStopSpindle: M5: Stop Spindle
|
||||
# - GCodeToolLength:
|
||||
# G43.2 - GCodeAddToolLengthOffset: G43.2: Appkly Additional Tool Length Offset
|
||||
# G49 - GCodeCancelToolLengthOffset: G49: Cancel Tool Length Compensation
|
||||
# G43.1 - GCodeDynamicToolLengthOffset: G43.1: Dynamic Tool Length Offset
|
||||
# G43 - GCodeToolLengthOffset: G43: Tool Length Offset
|
||||
# - GCodeUnit:
|
||||
# G20 - GCodeUseInches: G20: use inches for length units
|
||||
# G21 - GCodeUseMillimeters: G21: use millimeters for length units
|
||||
|
||||
# $ python -c "from pygcode.gcodes import GCode, _subclasses as sc; print(',\\n '.join(sorted(g.__name__ for g in sc(GCode))))"
|
||||
GCode,
|
||||
GCodeAbsoluteArcDistanceMode,
|
||||
GCodeAbsoluteDistanceMode,
|
||||
GCodeAdaptiveFeed,
|
||||
GCodeAddToolLengthOffset,
|
||||
GCodeAnalogOutput,
|
||||
GCodeAnalogOutputImmediate,
|
||||
GCodeAnalogOutputSyncd,
|
||||
GCodeArcMove,
|
||||
GCodeArcMoveCCW,
|
||||
GCodeArcMoveCW,
|
||||
GCodeBoringCycleDwellFeedOut,
|
||||
GCodeBoringCycleFeedOut,
|
||||
GCodeCancelCannedCycle,
|
||||
GCodeCancelToolLengthOffset,
|
||||
GCodeCannedCycle,
|
||||
GCodeCannedCycleReturnLevel,
|
||||
GCodeCannedReturnMode,
|
||||
GCodeCoolant,
|
||||
GCodeCoolantFloodOn,
|
||||
GCodeCoolantMistOn,
|
||||
GCodeCoolantOff,
|
||||
GCodeCoordSystemOffset,
|
||||
GCodeCublcSpline,
|
||||
GCodeCutterCompLeft,
|
||||
GCodeCutterCompRight,
|
||||
GCodeCutterRadiusComp,
|
||||
GCodeCutterRadiusCompOff,
|
||||
GCodeDigitalOutput,
|
||||
GCodeDigitalOutputOff,
|
||||
GCodeDigitalOutputOffSyncd,
|
||||
GCodeDigitalOutputOn,
|
||||
GCodeDigitalOutputOnSyncd,
|
||||
GCodeDistanceMode,
|
||||
GCodeDrillingCycle,
|
||||
GCodeDrillingCycleChipBreaking,
|
||||
GCodeDrillingCycleDwell,
|
||||
GCodeDrillingCyclePeck,
|
||||
GCodeDwell,
|
||||
GCodeDynamicCutterCompLeft,
|
||||
GCodeDynamicCutterCompRight,
|
||||
GCodeDynamicToolLengthOffset,
|
||||
GCodeEndProgram,
|
||||
GCodeEndProgramPalletShuttle,
|
||||
GCodeExactPathMode,
|
||||
GCodeExactStopMode,
|
||||
GCodeFeedOverride,
|
||||
GCodeFeedRate,
|
||||
GCodeFeedRateMode,
|
||||
GCodeFeedStop,
|
||||
GCodeGotoPredefinedPosition,
|
||||
GCodeIO,
|
||||
GCodeIncrementalArcDistanceMode,
|
||||
GCodeIncrementalDistanceMode,
|
||||
GCodeInverseTimeMode,
|
||||
GCodeLatheDiameterMode,
|
||||
GCodeLatheRadiusMode,
|
||||
GCodeLinearMove,
|
||||
GCodeMotion,
|
||||
GCodeMoveInMachineCoords,
|
||||
GCodeNURBS,
|
||||
GCodeNURBSEnd,
|
||||
GCodeNonModal,
|
||||
GCodeOrientSpindle,
|
||||
GCodeOtherModal,
|
||||
GCodePalletChangePause,
|
||||
GCodePathBlendingMode,
|
||||
GCodePathControlMode,
|
||||
GCodePauseProgram,
|
||||
GCodePauseProgramOptional,
|
||||
GCodePlaneSelect,
|
||||
GCodeProgramControl,
|
||||
GCodeQuadraticSpline,
|
||||
GCodeRapidMove,
|
||||
GCodeResetCoordSystemOffset,
|
||||
GCodeRestoreCoordSystemOffset,
|
||||
GCodeRigidTapping,
|
||||
GCodeSelectCoordinateSystem,
|
||||
GCodeSelectCoordinateSystem1,
|
||||
GCodeSelectCoordinateSystem2,
|
||||
GCodeSelectCoordinateSystem3,
|
||||
GCodeSelectCoordinateSystem4,
|
||||
GCodeSelectCoordinateSystem5,
|
||||
GCodeSelectCoordinateSystem6,
|
||||
GCodeSelectCoordinateSystem7,
|
||||
GCodeSelectCoordinateSystem8,
|
||||
GCodeSelectCoordinateSystem9,
|
||||
GCodeSelectTool,
|
||||
GCodeSelectUVPlane,
|
||||
GCodeSelectVWPlane,
|
||||
GCodeSelectWUPlane,
|
||||
GCodeSelectXYPlane,
|
||||
GCodeSelectYZPlane,
|
||||
GCodeSelectZXPlane,
|
||||
GCodeSet,
|
||||
GCodeSetPredefinedPosition,
|
||||
GCodeSpeedAndFeedOverrideOff,
|
||||
GCodeSpeedAndFeedOverrideOn,
|
||||
GCodeSpindle,
|
||||
GCodeSpindleConstantSurfaceSpeedMode,
|
||||
GCodeSpindleRPMMode,
|
||||
GCodeSpindleSpeed,
|
||||
GCodeSpindleSpeedMode,
|
||||
GCodeSpindleSpeedOverride,
|
||||
GCodeSpindleSyncMotion,
|
||||
GCodeStartSpindle,
|
||||
GCodeStartSpindleCCW,
|
||||
GCodeStartSpindleCW,
|
||||
GCodeStopSpindle,
|
||||
GCodeStraightProbe,
|
||||
GCodeThreadingCycle,
|
||||
GCodeToolChange,
|
||||
GCodeToolLength,
|
||||
GCodeToolLengthOffset,
|
||||
GCodeToolSetCurrent,
|
||||
GCodeUnit,
|
||||
GCodeUnitsPerMinuteMode,
|
||||
GCodeUnitsPerRevolution,
|
||||
GCodeUseInches,
|
||||
GCodeUseMillimeters,
|
||||
GCodeUserDefined,
|
||||
GCodeWaitOnInput
|
||||
)
|
85
src/pygcode/block.py
Normal file
85
src/pygcode/block.py
Normal file
@ -0,0 +1,85 @@
|
||||
import re
|
||||
from .words import text2words, WORD_MAP
|
||||
from .gcodes import words2gcodes
|
||||
|
||||
class Block(object):
|
||||
"""GCode block (effectively any gcode file line that defines any <word><value>)"""
|
||||
|
||||
def __init__(self, text=None, verify=True):
|
||||
"""
|
||||
Block Constructor
|
||||
:param A-Z: gcode parameter values
|
||||
:param comment: comment text
|
||||
"""
|
||||
|
||||
self._raw_text = None
|
||||
self._text = None
|
||||
self.words = []
|
||||
self.gcodes = []
|
||||
self.modal_params = []
|
||||
|
||||
# clean up block string
|
||||
if text:
|
||||
self._raw_text = text # unaltered block content (before alteration)
|
||||
text = re.sub(r'(^\s+|\s+$)', '', text) # remove whitespace padding
|
||||
text = re.sub(r'\s+', ' ', text) # remove duplicate whitespace with ' '
|
||||
self._text = text # cleaned up block content
|
||||
|
||||
# Get words from text, and group into gcodes
|
||||
self.words = list(text2words(self._text))
|
||||
(self.gcodes, self.modal_params) = words2gcodes(self.words)
|
||||
|
||||
# Verification
|
||||
if verify:
|
||||
self._assert_gcodes()
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
if self._text:
|
||||
return self._text
|
||||
return str(self)
|
||||
|
||||
def _assert_gcodes(self):
|
||||
modal_groups = set()
|
||||
code_words = set()
|
||||
|
||||
for gc in self.gcodes:
|
||||
|
||||
# Assert all gcodes are not repeated in the same block
|
||||
if gc.word in code_words:
|
||||
raise AssertionError("%s cannot be in the same block" % ([
|
||||
x for x in self.gcodes
|
||||
if x.modal_group == gc.modal_group
|
||||
]))
|
||||
code_words.add(gc.word)
|
||||
|
||||
# Assert all gcodes are from different modal groups
|
||||
if gc.modal_group is not None:
|
||||
if gc.modal_group in modal_groups:
|
||||
raise AssertionError("%s cannot be in the same block" % ([
|
||||
x for x in self.gcodes
|
||||
if x.modal_group == gc.modal_group
|
||||
]))
|
||||
modal_groups.add(gc.modal_group)
|
||||
|
||||
def __getattr__(self, k):
|
||||
if k in WORD_MAP:
|
||||
for w in self.words:
|
||||
if w.letter == k:
|
||||
return w
|
||||
# if word is not in this block:
|
||||
return None
|
||||
|
||||
else:
|
||||
raise AttributeError("'{cls}' object has no attribute '{key}'".format(
|
||||
cls=self.__class__.__name__,
|
||||
key=k
|
||||
))
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.words)
|
||||
|
||||
__nonzero__ = __bool__ # python < 3 compatability
|
||||
|
||||
def __str__(self):
|
||||
return ' '.join(str(x) for x in (self.gcodes + self.modal_params))
|
67
src/pygcode/comment.py
Normal file
67
src/pygcode/comment.py
Normal file
@ -0,0 +1,67 @@
|
||||
import re
|
||||
|
||||
|
||||
class CommentBase(object):
|
||||
ORDER = 0
|
||||
MULTICOMMENT_JOINER = ". " # joiner if multiple comments are found on the same line
|
||||
def __init__(self, text):
|
||||
self.text = text
|
||||
|
||||
def __repr__(self):
|
||||
return "<{class_name}: '{comment}'>".format(
|
||||
class_name=self.__class__.__name__,
|
||||
comment=str(self),
|
||||
)
|
||||
|
||||
|
||||
class CommentSemicolon(CommentBase):
|
||||
"Comments of the format: 'G00 X1 Y2 ; something profound'"
|
||||
ORDER = 1
|
||||
AUTO_REGEX = re.compile(r'\s*;\s*(?P<text>.*)$')
|
||||
|
||||
def __str__(self):
|
||||
return "; {text}".format(text=self.text)
|
||||
|
||||
|
||||
class CommentBrackets(CommentBase):
|
||||
"Comments of the format: 'G00 X1 Y2 (something profound)"
|
||||
ORDER = 2
|
||||
AUTO_REGEX = re.compile(r'\((?P<text>[^\)]*)\)')
|
||||
|
||||
def __str__(self):
|
||||
return "({text})".format(text=self.text)
|
||||
|
||||
|
||||
Comment = CommentBrackets # default comment type
|
||||
|
||||
|
||||
def split_line(line_text):
|
||||
"""
|
||||
Split functional block content from comments
|
||||
:param line_text: line from gcode file
|
||||
:return: tuple of (str(<functional block code>), CommentBase(<comment(s)>))
|
||||
"""
|
||||
comments_class = None
|
||||
|
||||
# Auto-detect comment type if I can
|
||||
comments = []
|
||||
block_str = line_text.rstrip("\n") # to remove potential return carriage from comment body
|
||||
|
||||
for cls in sorted(CommentBase.__subclasses__(), key=lambda c: c.ORDER):
|
||||
matches = list(cls.AUTO_REGEX.finditer(block_str))
|
||||
if matches:
|
||||
for match in reversed(matches):
|
||||
# Build list of comment text
|
||||
comments.insert(0, match.group('text')) # prepend
|
||||
# Remove comments from given block_str
|
||||
block_str = block_str[:match.start()] + block_str[match.end():]
|
||||
comments_class = cls
|
||||
break
|
||||
|
||||
# Create comment instance if content was found
|
||||
comment_obj = None
|
||||
if comments_class:
|
||||
comment_text = comments_class.MULTICOMMENT_JOINER.join(comments)
|
||||
comment_obj = comments_class(comment_text)
|
||||
|
||||
return (block_str, comment_obj)
|
18
src/pygcode/exceptions.py
Normal file
18
src/pygcode/exceptions.py
Normal file
@ -0,0 +1,18 @@
|
||||
|
||||
# ===================== Parsing Exceptions =====================
|
||||
class GCodeBlockFormatError(Exception):
|
||||
"""Raised when errors encountered while parsing block text"""
|
||||
|
||||
class GCodeParameterError(Exception):
|
||||
"""Raised for conflicting / invalid / badly formed parameters"""
|
||||
|
||||
class GCodeWordStrError(Exception):
|
||||
"""Raised when issues found while parsing a word string"""
|
||||
|
||||
# ===================== Machine Exceptions =====================
|
||||
class MachineInvalidAxis(Exception):
|
||||
"""Raised if an axis is invalid"""
|
||||
# For example: for axes X/Y/Z, set the value of "Q"; wtf?
|
||||
|
||||
class MachineInvalidState(Exception):
|
||||
"""Raised if a machine state is set incorrectly, or in conflict"""
|
1533
src/pygcode/gcodes.py
Normal file
1533
src/pygcode/gcodes.py
Normal file
File diff suppressed because it is too large
Load Diff
31
src/pygcode/line.py
Normal file
31
src/pygcode/line.py
Normal file
@ -0,0 +1,31 @@
|
||||
from .comment import split_line
|
||||
from .block import Block
|
||||
|
||||
class Line(object):
|
||||
def __init__(self, text=None):
|
||||
self._text = text
|
||||
|
||||
# Initialize
|
||||
self.block = None
|
||||
self.comment = None
|
||||
|
||||
# Split line into block text, and comments
|
||||
if text is not None:
|
||||
(block_str, comment) = split_line(text)
|
||||
self.block = Block(block_str)
|
||||
if comment:
|
||||
self.comment = comment
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
if self._text is None:
|
||||
return str(self)
|
||||
return self._text
|
||||
|
||||
@property
|
||||
def gcodes(self):
|
||||
"""self.block.gcodes passthrough"""
|
||||
return self.block.gcodes
|
||||
|
||||
def __str__(self):
|
||||
return ' '.join([str(x) for x in [self.block, self.comment] if x])
|
443
src/pygcode/machine.py
Normal file
443
src/pygcode/machine.py
Normal file
@ -0,0 +1,443 @@
|
||||
from copy import copy, deepcopy
|
||||
from collections import defaultdict
|
||||
|
||||
from .gcodes import (
|
||||
MODAL_GROUP_MAP, GCode,
|
||||
# Modal GCodes
|
||||
GCodeIncrementalDistanceMode,
|
||||
GCodeUseInches, GCodeUseMillimeters,
|
||||
# Utilities
|
||||
words2gcodes,
|
||||
)
|
||||
from .block import Block
|
||||
from .line import Line
|
||||
from .words import Word
|
||||
from .utils import Vector3, Quaternion
|
||||
|
||||
from .exceptions import MachineInvalidAxis, MachineInvalidState
|
||||
|
||||
UNIT_IMPERIAL = GCodeUseInches.unit_id # G20
|
||||
UNIT_METRIC = GCodeUseMillimeters.unit_id # G21
|
||||
UNIT_MAP = {
|
||||
UNIT_IMPERIAL: {
|
||||
'name': 'inches',
|
||||
'conversion_factor': { UNIT_METRIC: 25.4 },
|
||||
},
|
||||
UNIT_METRIC: {
|
||||
'name': 'millimeters',
|
||||
'conversion_factor': { UNIT_IMPERIAL: 1. / 25.4 },
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Position(object):
|
||||
default_axes = 'XYZABCUVW'
|
||||
default_unit = UNIT_METRIC
|
||||
POSSIBLE_AXES = set('XYZABCUVW')
|
||||
|
||||
def __init__(self, axes=None, **kwargs):
|
||||
# Set axes (note: usage in __getattr__ and __setattr__)
|
||||
if axes is None:
|
||||
axes = self.__class__.default_axes
|
||||
else:
|
||||
invalid_axes = set(axes) - self.POSSIBLE_AXES
|
||||
if invalid_axes:
|
||||
raise MachineInvalidAxis("invalid axes proposed %s" % invalid_axes)
|
||||
self.__dict__['axes'] = set(axes) & self.POSSIBLE_AXES
|
||||
|
||||
# Unit
|
||||
self._unit = kwargs.pop('unit', self.default_unit)
|
||||
|
||||
# Initial Values
|
||||
self._value = defaultdict(lambda: 0.0, dict((k, 0.0) for k in self.axes))
|
||||
self._value.update(kwargs)
|
||||
|
||||
def update(self, **coords):
|
||||
for (k, v) in coords.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
# Attributes Get/Set
|
||||
def __getattr__(self, key):
|
||||
if key in self.axes:
|
||||
return self._value[key]
|
||||
|
||||
raise AttributeError("'{cls}' object has no attribute '{key}'".format(
|
||||
cls=self.__class__.__name__,
|
||||
key=key
|
||||
))
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
if key in self.axes:
|
||||
self._value[key] = value
|
||||
elif key in self.POSSIBLE_AXES:
|
||||
raise MachineInvalidAxis("'%s' axis is not defined to be set" % key)
|
||||
else:
|
||||
self.__dict__[key] = value
|
||||
|
||||
# Copy
|
||||
def __copy__(self):
|
||||
return self.__class__(axes=copy(self.axes), unit=self._unit, **self.values)
|
||||
|
||||
# Equality
|
||||
def __eq__(self, other):
|
||||
if self.axes ^ other.axes:
|
||||
return False
|
||||
else:
|
||||
if self._unit == other._unit:
|
||||
return self._value == other._value
|
||||
else:
|
||||
x = copy(other)
|
||||
x.set_unit(self._unit)
|
||||
return self._value == x._value
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
# Arithmetic
|
||||
def __add__(self, other):
|
||||
if self.axes ^ other.axes:
|
||||
raise MachineInvalidAxis("axes: %r != %r" % (self.axes, other.axes))
|
||||
new_obj = copy(self)
|
||||
for k in new_obj._value:
|
||||
new_obj._value[k] += other._value[k]
|
||||
return new_obj
|
||||
|
||||
def __sub__(self, other):
|
||||
if other.axes - self.axes:
|
||||
raise MachineInvalidAxis("for a - b: axes in b, that are not in a: %r" % (other.axes - self.axes))
|
||||
new_obj = copy(self)
|
||||
for k in other._value:
|
||||
new_obj._value[k] -= other._value[k]
|
||||
return new_obj
|
||||
|
||||
def __mul__(self, scalar):
|
||||
new_obj = copy(self)
|
||||
for k in self._value:
|
||||
new_obj._value[k] = self._value[k] * scalar
|
||||
return new_obj
|
||||
|
||||
def __div__(self, scalar):
|
||||
new_obj = copy(self)
|
||||
for k in self._value:
|
||||
new_obj._value[k] = self._value[k] / scalar
|
||||
return new_obj
|
||||
|
||||
__truediv__ = __div__ # Python 3 division
|
||||
|
||||
# Conversion
|
||||
def set_unit(self, unit):
|
||||
if unit == self._unit:
|
||||
return
|
||||
factor = UNIT_MAP[self._unit]['conversion_factor'][unit]
|
||||
for k in [k for (k, v) in self._value.items() if v is not None]:
|
||||
self._value[k] *= factor
|
||||
self._unit = unit
|
||||
|
||||
@property
|
||||
def words(self):
|
||||
return sorted(Word(k, self._value[k]) for k in self.axes)
|
||||
|
||||
@property
|
||||
def values(self):
|
||||
return dict(self._value)
|
||||
|
||||
@property
|
||||
def vector(self):
|
||||
return Vector3(self._value['X'], self._value['Y'], self._value['Z'])
|
||||
|
||||
def __repr__(self):
|
||||
return "<{class_name}: {coordinates}>".format(
|
||||
class_name=self.__class__.__name__,
|
||||
coordinates=' '.join(str(w) for w in self.words)
|
||||
)
|
||||
|
||||
|
||||
class CoordinateSystem(object):
|
||||
def __init__(self, axes):
|
||||
self.offset = Position(axes)
|
||||
|
||||
def __add__(self, other):
|
||||
if isinstance(other, CoordinateSystem):
|
||||
pass
|
||||
|
||||
def __repr__(self):
|
||||
return "<{class_name}: offset={offset}>".format(
|
||||
class_name=self.__class__.__name__,
|
||||
offset=repr(self.offset),
|
||||
)
|
||||
|
||||
|
||||
class State(object):
|
||||
"""State of a Machine"""
|
||||
# LinuxCNC documentation lists parameters for a machine's state:
|
||||
# http://linuxcnc.org/docs/html/gcode/overview.html#sub:numbered-parameters
|
||||
# AFAIK: this is everything needed to remember a machine's state that isn't
|
||||
# handled by modal gcodes.
|
||||
def __init__(self, axes=None):
|
||||
# Coordinate Systems
|
||||
self.coord_systems = {}
|
||||
for i in range(1, 10): # G54-G59.3
|
||||
self.coord_systems[i] = CoordinateSystem(axes)
|
||||
|
||||
self.cur_coord_sys = 1 # default to coord system 1 (G54)
|
||||
|
||||
# Temporary Offset
|
||||
self.offset = Position(axes) # G92 offset (reset by G92.x)
|
||||
|
||||
# Missing from state (according to LinuxCNC's state variables):
|
||||
# - G38.2 probe result (Position())
|
||||
# - G38 probe result (bool)
|
||||
# - M66: result (bool)
|
||||
# - Tool offsets (Position())
|
||||
# - Tool info (number, diameter, front angle, back angle, orientation)
|
||||
|
||||
#self.work_offset = defaultdict(lambda: 0.0)
|
||||
|
||||
# TODO: how to manage work offsets? (probs not like the above)
|
||||
# read up on:
|
||||
# Coordinate System config:
|
||||
# - G92: set machine coordinate system value (no movement, effects all coordinate systems)
|
||||
# - G10 L2: offsets the origin of the axes in the coordinate system specified to the value of the axis word
|
||||
# - G10 L20: makes the current machine coordinates the coordinate system's offset
|
||||
# Coordinate System selection:
|
||||
# - G54-G59: select coordinate system (offsets from machine coordinates set by G10 L2)
|
||||
|
||||
# TODO: Move this class into MachineState
|
||||
|
||||
@property
|
||||
def coord_sys(self):
|
||||
"""Current equivalent coordinate system, including all """
|
||||
if self.cur_coord_sys in self.coord_systems:
|
||||
return self.coord_systems[self.cur_coord_sys]
|
||||
return None
|
||||
|
||||
def __repr__(self):
|
||||
return "<{class_name}: coord_sys[{coord_index}]; offset={offset}>".format(
|
||||
class_name=self.__class__.__name__,
|
||||
coord_index=self.cur_coord_sys,
|
||||
offset=repr(self.offset),
|
||||
)
|
||||
|
||||
|
||||
class Mode(object):
|
||||
"""Machine's mode"""
|
||||
# State is very forgiving:
|
||||
# Anything possible in a machine's state may be changed & fetched.
|
||||
# For example: x, y, z, a, b, c may all be set & requested.
|
||||
# However, the machine for which this state is stored probably doesn't
|
||||
# have all possible 6 axes.
|
||||
# It is also possible to set an axis to an impossibly large distance.
|
||||
# It is the responsibility of the Machine using this class to be
|
||||
# discerning in these respects.
|
||||
|
||||
# Default Mode
|
||||
# for a Grbl controller this can be obtained with the `$G` command, eg:
|
||||
# > $G
|
||||
# > [GC:G0 G54 G17 G21 G90 G94 M5 M9 T0 F0 S0]
|
||||
# ref: https://github.com/gnea/grbl/wiki/Grbl-v1.1-Commands#g---view-gcode-parser-state
|
||||
default_mode = '''
|
||||
G0 (movement: rapid)
|
||||
G17 (plane_selection: X/Y plane)
|
||||
G90 (distance: absolute position. ie: not "turtle" mode)
|
||||
G91.1 (arc_ijk_distance: IJK sets arc center vertex relative to current position)
|
||||
G94 (feed_rate_mode: feed-rate defined in units/min)
|
||||
G21 (units: mm)
|
||||
G40 (cutter_diameter_comp: no compensation)
|
||||
G49 (tool_length_offset: no offset)
|
||||
G54 (coordinate_system: 1)
|
||||
G61 (control_mode: exact path mode)
|
||||
G97 (spindle_speed_mode: RPM Mode)
|
||||
M5 (spindle: off)
|
||||
M9 (coolant: off)
|
||||
F0 (feed_rate: 0)
|
||||
S0 (spindle_speed: 0)
|
||||
T0 (tool: 0)
|
||||
'''
|
||||
|
||||
# Mode is defined by gcodes set by processed blocks:
|
||||
# see modal_group in gcode.py module for details
|
||||
def __init__(self):
|
||||
self.modal_groups = defaultdict(lambda: None)
|
||||
|
||||
# Initialize
|
||||
self.set_mode(*Line(self.default_mode).block.gcodes)
|
||||
|
||||
def set_mode(self, *gcode_list):
|
||||
"""
|
||||
Set machine mode from given gcodes (will not be processed)
|
||||
:param gcode_list: list of GCode instances (given as individual parameters)
|
||||
:return: dict of form: {<modal group>: <new mode GCode>, ...}
|
||||
"""
|
||||
modal_gcodes = {}
|
||||
for g in sorted(gcode_list): # sorted by execution order
|
||||
if g.modal_group is not None:
|
||||
self.modal_groups[g.modal_group] = g.modal_copy()
|
||||
modal_gcodes[g.modal_group] = self.modal_groups[g.modal_group]
|
||||
# assumption: no 2 gcodes are in the same modal_group
|
||||
return modal_gcodes
|
||||
|
||||
def __getattr__(self, key):
|
||||
if key in MODAL_GROUP_MAP:
|
||||
return self.modal_groups[MODAL_GROUP_MAP[key]]
|
||||
|
||||
raise AttributeError("'{cls}' object has no attribute '{key}'".format(
|
||||
cls=self.__class__.__name__,
|
||||
key=key
|
||||
))
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
if key in MODAL_GROUP_MAP:
|
||||
# Set/Clear modal group gcode
|
||||
if value is None:
|
||||
# clear mode group
|
||||
self.modal_groups[MODAL_GROUP_MAP[key]] = None
|
||||
else:
|
||||
# set mode group explicitly, not advisable
|
||||
# (recommended to use self.set_mode(value) instead)
|
||||
if not isinstance(value, GCode):
|
||||
raise MachineInvalidState("invalid mode value: %r" % value)
|
||||
if value.modal_group != MODAL_GROUP_MAP[key]:
|
||||
raise MachineInvalidState("cannot set '%s' mode as %r, wrong group" % (key, value))
|
||||
self.modal_groups[MODAL_GROUP_MAP[key]] = value.modal_copy()
|
||||
else:
|
||||
self.__dict__[key] = value
|
||||
|
||||
@property
|
||||
def gcodes(self):
|
||||
"""List of modal gcodes"""
|
||||
gcode_list = []
|
||||
for modal_group in sorted(MODAL_GROUP_MAP.values()):
|
||||
if self.modal_groups[modal_group]:
|
||||
gcode_list.append(self.modal_groups[modal_group])
|
||||
return gcode_list
|
||||
|
||||
def __str__(self):
|
||||
return ' '.join(str(g) for g in self.gcodes)
|
||||
|
||||
def __repr__(self):
|
||||
return "<{class_name}: {gcodes}>".format(
|
||||
class_name=self.__class__.__name__, gcodes=str(self)
|
||||
)
|
||||
|
||||
|
||||
class Machine(object):
|
||||
"""Machine to process gcodes, enforce axis limits, keep track of time, etc"""
|
||||
|
||||
# Class types
|
||||
MODE_CLASS = Mode
|
||||
STATE_CLASS = State
|
||||
|
||||
axes = set('XYZ')
|
||||
|
||||
def __init__(self):
|
||||
self.mode = self.MODE_CLASS()
|
||||
self.state = self.STATE_CLASS(axes=self.axes)
|
||||
|
||||
# Position type (with default axes the same as this machine)
|
||||
units_mode = getattr(self.mode, 'units', None)
|
||||
self.Position = type('Position', (Position,), {
|
||||
'default_axes': self.axes,
|
||||
'default_unit': units_mode.unit_id if units_mode else UNIT_METRIC,
|
||||
})
|
||||
|
||||
# Absolute machine position
|
||||
self.abs_pos = self.Position()
|
||||
|
||||
def set_mode(self, *gcode_list):
|
||||
self.mode.set_mode(*gcode_list) # passthrough
|
||||
|
||||
# Act on mode changes
|
||||
coord_sys_mode = self.mode.coordinate_system
|
||||
if coord_sys_mode:
|
||||
self.state.cur_coord_sys = coord_sys_mode.coord_system_id
|
||||
|
||||
def modal_gcode(self, modal_params):
|
||||
|
||||
if not modal_params:
|
||||
return None
|
||||
if self.mode.motion is None:
|
||||
raise MachineInvalidState("unable to assign modal parameters when no motion mode is set")
|
||||
params = copy(self.mode.motion.params) # dict
|
||||
params.update(dict((w.letter, w) for w in modal_params)) # override retained modal parameters
|
||||
(modal_gcodes, unasigned_words) = words2gcodes([self.mode.motion.word] + params.values())
|
||||
if unasigned_words:
|
||||
raise MachineInvalidState("modal parameters '%s' cannot be assigned when in mode: %r" % (
|
||||
' '.join(str(x) for x in unasigned_words), self.mode
|
||||
))
|
||||
if modal_gcodes:
|
||||
assert len(modal_gcodes) == 1, "more than 1 modal code found"
|
||||
return modal_gcodes[0]
|
||||
return None
|
||||
|
||||
def block_modal_gcodes(self, block):
|
||||
"""
|
||||
Block's GCode list in current machine mode
|
||||
:param block: Block instance
|
||||
:return: list of gcodes, block.gcodes + <modal gcode, if there is one>
|
||||
"""
|
||||
assert isinstance(block, Block), "invalid parameter"
|
||||
gcodes = copy(block.gcodes)
|
||||
modal_gcode = self.modal_gcode(block.modal_params)
|
||||
if modal_gcode:
|
||||
gcodes.append(modal_gcode)
|
||||
return sorted(gcodes)
|
||||
|
||||
def process_gcodes(self, *gcode_list, **kwargs):
|
||||
"""
|
||||
Process gcodes
|
||||
:param gcode_list: list of GCode instances
|
||||
:param modal_params: list of Word instances to be applied to current movement mode
|
||||
"""
|
||||
gcode_list = list(gcode_list) # make appendable
|
||||
# Add modal gcode to list of given gcodes
|
||||
modal_params = kwargs.get('modal_params', [])
|
||||
if modal_params:
|
||||
modal_gcode = self.modal_gcode(modal_params)
|
||||
if modal_gcode:
|
||||
gcode_list.append(modal_gcode)
|
||||
|
||||
for gcode in sorted(gcode_list):
|
||||
gcode.process(self) # shifts ownership of what happens now to GCode class
|
||||
|
||||
# TODO: gcode instance to change machine's state
|
||||
# Questions to drive design:
|
||||
# - how much time did the command take?
|
||||
# - what was the tool's distance / displacement
|
||||
# - did the tool travel outside machine boundaries?
|
||||
# Use-cases
|
||||
# - Transform / rotate coordinate system in given gcode
|
||||
# - Convert arcs to linear segments (visa versa?)
|
||||
# - Correct precision errors
|
||||
# - Crop a file (eg: resume half way through)
|
||||
|
||||
def process_block(self, block):
|
||||
self.process_gcodes(*block.gcodes, modal_params=block.modal_params)
|
||||
|
||||
def process_str(self, block_str):
|
||||
line = Line(block_str)
|
||||
self.process_block(line.block)
|
||||
|
||||
@property
|
||||
def pos(self):
|
||||
"""Return current position in current coordinate system"""
|
||||
coord_sys_offset = getattr(self.state.coord_sys, 'offset', Position(axes=self.axes))
|
||||
temp_offset = self.state.offset
|
||||
return (self.abs_pos - coord_sys_offset) - temp_offset
|
||||
|
||||
@pos.setter
|
||||
def pos(self, value):
|
||||
"""Set absolute position given current position and coordinate system"""
|
||||
coord_sys_offset = getattr(self.state.coord_sys, 'offset', Position(axes=self.axes))
|
||||
temp_offset = self.state.offset
|
||||
self.abs_pos = (value + temp_offset) + coord_sys_offset
|
||||
|
||||
# =================== Machine Actions ===================
|
||||
def move_to(self, rapid=False, **coords):
|
||||
"""Move machine to given position"""
|
||||
if isinstance(self.mode.distance, GCodeIncrementalDistanceMode):
|
||||
pos_delta = Position(axes=self.axes, **coords)
|
||||
self.pos += pos_delta
|
||||
else: # assumed: GCodeAbsoluteDistanceMode
|
||||
new_pos = self.pos
|
||||
new_pos.update(**coords) # only change given coordinates
|
||||
self.pos = new_pos
|
485
src/pygcode/transform.py
Normal file
485
src/pygcode/transform.py
Normal file
@ -0,0 +1,485 @@
|
||||
from math import sin, cos, tan, asin, acos, atan2, pi, sqrt, ceil
|
||||
|
||||
from .gcodes import GCodeLinearMove, GCodeRapidMove
|
||||
from .gcodes import GCodeArcMove, GCodeArcMoveCW, GCodeArcMoveCCW
|
||||
from .gcodes import GCodePlaneSelect, GCodeSelectXYPlane, GCodeSelectYZPlane, GCodeSelectZXPlane
|
||||
from .gcodes import GCodeAbsoluteDistanceMode, GCodeIncrementalDistanceMode
|
||||
from .gcodes import GCodeAbsoluteArcDistanceMode, GCodeIncrementalArcDistanceMode
|
||||
from .gcodes import GCodeCannedCycle
|
||||
from .gcodes import GCodeDrillingCyclePeck, GCodeDrillingCycleDwell, GCodeDrillingCycleChipBreaking
|
||||
from .gcodes import GCodeCannedReturnMode, GCodeCannedCycleReturnLevel, GCodeCannedCycleReturnToR
|
||||
from .gcodes import _gcodes_abs2rel
|
||||
|
||||
from .machine import Position
|
||||
from .exceptions import GCodeParameterError
|
||||
from .utils import Vector3, Quaternion, plane_projection
|
||||
|
||||
|
||||
# ==================== Arcs (G2,G3) --> Linear Motion (G1) ====================
|
||||
|
||||
class ArcLinearizeMethod(object):
|
||||
# Chord Phase Offest:
|
||||
# False : each line will span an equal portion of the arc
|
||||
# True : the first & last chord will span 1/2 the angular distance of all other chords
|
||||
chord_phase_offset = False
|
||||
|
||||
def __init__(self, max_error, plane_normal,
|
||||
arc_p_start, arc_p_end, arc_p_center,
|
||||
arc_radius, arc_angle, helical_start, helical_end):
|
||||
self.max_error = max_error
|
||||
self.plane_normal = plane_normal
|
||||
self.arc_p_start = arc_p_start
|
||||
self.arc_p_end = arc_p_end
|
||||
self.arc_p_center = arc_p_center
|
||||
self.arc_radius = arc_radius
|
||||
self.arc_angle = arc_angle
|
||||
self.helical_start = helical_start
|
||||
self.helical_end = helical_end
|
||||
|
||||
if self.max_error > self.arc_radius:
|
||||
self.max_error = self.arc_radius
|
||||
|
||||
# Initializing
|
||||
self._max_wedge_angle = None
|
||||
self._wedge_count = None
|
||||
self._wedge_angle = None
|
||||
self._inner_radius = None
|
||||
self._outer_radius = None
|
||||
|
||||
# Overridden Functions
|
||||
def get_max_wedge_angle(self):
|
||||
"""Calculate angular coverage of a single line reaching maximum allowable error"""
|
||||
raise NotImplementedError("not overridden")
|
||||
|
||||
def get_inner_radius(self):
|
||||
"""Radius each line is tangential to"""
|
||||
# IMPORTANT: when overriding, calculate this using self.wedge_angle,
|
||||
# (self.wedge_angle will almost always be < self.max_wedge_angle)
|
||||
raise NotImplementedError("not overridden")
|
||||
|
||||
def get_outer_radius(self):
|
||||
"""Radius from which each line forms a chord"""
|
||||
# IMPORTANT: when overriding, calculate this using self.wedge_angle,
|
||||
# (self.wedge_angle will almost always be < self.max_wedge_angle)
|
||||
raise NotImplementedError("not overridden")
|
||||
|
||||
# Properties
|
||||
@property
|
||||
def max_wedge_angle(self):
|
||||
if self._max_wedge_angle is None:
|
||||
self._max_wedge_angle = self.get_max_wedge_angle()
|
||||
return self._max_wedge_angle
|
||||
|
||||
@property
|
||||
def wedge_count(self):
|
||||
"""
|
||||
Number of full wedges covered across the arc.
|
||||
NB: if there is phase offset, then the actual number of linearized lines
|
||||
is this + 1, because the first and last are considered to be the
|
||||
same 'wedge'.
|
||||
"""
|
||||
if self._wedge_count is None:
|
||||
self._wedge_count = int(ceil(abs(self.arc_angle) / self.max_wedge_angle))
|
||||
return self._wedge_count
|
||||
|
||||
@property
|
||||
def wedge_angle(self):
|
||||
"""Angle each major chord stretches across the original arc"""
|
||||
if self._wedge_angle is None:
|
||||
self._wedge_angle = self.arc_angle / self.wedge_count
|
||||
return self._wedge_angle
|
||||
|
||||
@property
|
||||
def inner_radius(self):
|
||||
if self._inner_radius is None:
|
||||
self._inner_radius = self.get_inner_radius()
|
||||
return self._inner_radius
|
||||
|
||||
@property
|
||||
def outer_radius(self):
|
||||
if self._outer_radius is None:
|
||||
self._outer_radius = self.get_outer_radius()
|
||||
return self._outer_radius
|
||||
|
||||
# Vertex Generator
|
||||
def iter_vertices(self):
|
||||
"""Yield absolute (<start vertex>, <end vertex>) for each line for the arc"""
|
||||
start_vertex = self.arc_p_start - self.arc_p_center
|
||||
outer_vertex = start_vertex.normalized() * self.outer_radius
|
||||
d_helical = self.helical_end - self.helical_start
|
||||
|
||||
l_p_start = self.arc_p_center + start_vertex
|
||||
l_start = l_p_start + self.helical_start
|
||||
|
||||
for i in range(self.wedge_count):
|
||||
wedge_number = i + 1
|
||||
# Current angle
|
||||
cur_angle = self.wedge_angle * wedge_number
|
||||
if self.chord_phase_offset:
|
||||
cur_angle -= self.wedge_angle / 2.
|
||||
elif wedge_number >= self.wedge_count:
|
||||
break # stop 1 iteration short
|
||||
# alow last arc to simply span across:
|
||||
# <the end of the last line> -> <circle's end point>
|
||||
|
||||
# Next end point as projected on selected plane
|
||||
q_end = Quaternion.new_rotate_axis(angle=cur_angle, axis=-self.plane_normal)
|
||||
l_p_end = (q_end * outer_vertex) + self.arc_p_center
|
||||
# += helical displacement (difference along plane's normal)
|
||||
helical_displacement = self.helical_start + (d_helical * (cur_angle / self.arc_angle))
|
||||
l_end = l_p_end + helical_displacement
|
||||
|
||||
yield (l_start, l_end)
|
||||
|
||||
# start of next line is the end of this one
|
||||
l_start = l_end
|
||||
|
||||
# Last line always ends at the circle's end
|
||||
yield (l_start, self.arc_p_end + self.helical_end)
|
||||
|
||||
|
||||
class ArcLinearizeInside(ArcLinearizeMethod):
|
||||
"""Start and end points of each line are on the original arc"""
|
||||
# Attributes / Trade-offs:
|
||||
# - Errors cause arc to be slightly smaller
|
||||
# - pocket milling action will remove less material
|
||||
# - perimeter milling action will remove more material
|
||||
# - Each line is the same length
|
||||
# - Simplest maths, easiest to explain & visually verify
|
||||
|
||||
chord_phase_offset = False
|
||||
|
||||
def get_max_wedge_angle(self):
|
||||
"""Calculate angular coverage of a single line reaching maximum allowable error"""
|
||||
return abs(2 * acos((self.arc_radius - self.max_error) / self.arc_radius))
|
||||
|
||||
def get_inner_radius(self):
|
||||
"""Radius each line is tangential to"""
|
||||
return abs(cos(self.wedge_angle / 2.) * self.arc_radius)
|
||||
|
||||
def get_outer_radius(self):
|
||||
"""Radius from which each line forms a chord"""
|
||||
return self.arc_radius
|
||||
|
||||
|
||||
class ArcLinearizeOutside(ArcLinearizeMethod):
|
||||
"""Mid-points of each line are on the original arc, first and last lines are 1/2 length"""
|
||||
# Attributes / Trade-offs:
|
||||
# - Errors cause arc to be slightly larger
|
||||
# - pocket milling action will remove more material
|
||||
# - perimeter milling action will remove less material
|
||||
# - 1st and last lines are 1/2 length of the others
|
||||
|
||||
chord_phase_offset = True
|
||||
|
||||
def get_max_wedge_angle(self):
|
||||
"""Calculate angular coverage of a single line reaching maximum allowable error"""
|
||||
return abs(2 * acos(self.arc_radius / (self.arc_radius + self.max_error)))
|
||||
|
||||
def get_inner_radius(self):
|
||||
"""Radius each line is tangential to"""
|
||||
return self.arc_radius
|
||||
|
||||
def get_outer_radius(self):
|
||||
"""Radius from which each line forms a chord"""
|
||||
return abs(self.arc_radius / cos(self.wedge_angle / 2.))
|
||||
|
||||
|
||||
class ArcLinearizeMid(ArcLinearizeMethod):
|
||||
"""Lines cross original arc from tangent of arc radius - precision/2, until it reaches arc radius + precision/2"""
|
||||
# Attributes / Trade-offs:
|
||||
# - Closest to original arc (error distances are equal inside and outside the arc)
|
||||
# - Most complex to calculate (but who cares, that's only done once)
|
||||
# - default linearizing method as it's probably the best
|
||||
|
||||
chord_phase_offset = True
|
||||
|
||||
def get_max_wedge_angle(self):
|
||||
"""Calculate angular coverage of a single line reaching maximum allowable error"""
|
||||
d_radius = self.max_error / 2.
|
||||
return abs(2. * acos((self.arc_radius - d_radius) / (self.arc_radius + d_radius)))
|
||||
|
||||
def get_inner_radius(self):
|
||||
"""Radius each line is tangential to"""
|
||||
d_radius = self.arc_radius * (tan(self.wedge_angle / 4.) ** 2)
|
||||
return self.arc_radius - d_radius
|
||||
|
||||
def get_outer_radius(self):
|
||||
"""Radius from which each line forms a chord"""
|
||||
d_radius = self.arc_radius * (tan(self.wedge_angle / 4.) ** 2)
|
||||
return self.arc_radius + d_radius
|
||||
|
||||
|
||||
DEFAULT_LA_METHOD = ArcLinearizeMid
|
||||
DEFAULT_LA_PLANE = GCodeSelectXYPlane
|
||||
DEFAULT_LA_DISTMODE = GCodeAbsoluteDistanceMode
|
||||
DEFAULT_LA_ARCDISTMODE = GCodeIncrementalArcDistanceMode
|
||||
|
||||
def linearize_arc(arc_gcode, start_pos, plane=None, method_class=None,
|
||||
dist_mode=None, arc_dist_mode=None,
|
||||
max_error=0.01, decimal_places=3):
|
||||
"""
|
||||
Convert a G2,G3 arc into a series of approsimation G1 codes
|
||||
:param arc_gcode: arc gcode to approximate (GCodeArcMove)
|
||||
:param start_pos: current machine position (Position)
|
||||
:param plane: machine's active plane (GCodePlaneSelect)
|
||||
:param method_class: method of linear approximation (ArcLinearizeMethod)
|
||||
:param dist_mode: machine's distance mode (GCodeAbsoluteDistanceMode or GCodeIncrementalDistanceMode)
|
||||
:param arc_dist_mode: machine's arc distance mode (GCodeAbsoluteArcDistanceMode or GCodeIncrementalArcDistanceMode)
|
||||
:param max_error: maximum distance approximation arcs can stray from original arc (float)
|
||||
:param decimal_places: number of decimal places gocde will be rounded to, used to mitigate risks of accumulated eror when in incremental distance mode (int)
|
||||
"""
|
||||
# set defaults
|
||||
if method_class is None:
|
||||
method_class = DEFAULT_LA_method_class
|
||||
if plane is None:
|
||||
plane = DEFAULT_LA_PLANE()
|
||||
if dist_mode is None:
|
||||
dist_mode = DEFAULT_LA_DISTMODE()
|
||||
if arc_dist_mode is None:
|
||||
arc_dist_mode = DEFAULT_LA_ARCDISTMODE()
|
||||
|
||||
# Parameter Type Assertions
|
||||
assert isinstance(arc_gcode, GCodeArcMove), "bad arc_gcode type: %r" % arc_gcode
|
||||
assert isinstance(start_pos, Position), "bad start_pos type: %r" % start_pos
|
||||
assert isinstance(plane, GCodePlaneSelect), "bad plane type: %r" % plane
|
||||
assert issubclass(method_class, ArcLinearizeMethod), "bad method_class type: %r" % method_class
|
||||
assert isinstance(dist_mode, (GCodeAbsoluteDistanceMode, GCodeIncrementalDistanceMode)), "bad dist_mode type: %r" % dist_mode
|
||||
assert isinstance(arc_dist_mode, (GCodeAbsoluteArcDistanceMode, GCodeIncrementalArcDistanceMode)), "bad arc_dist_mode type: %r" % arc_dist_mode
|
||||
assert max_error > 0, "max_error must be > 0"
|
||||
|
||||
# Arc Start
|
||||
arc_start = start_pos.vector
|
||||
# Arc End
|
||||
if isinstance(dist_mode, GCodeAbsoluteDistanceMode):
|
||||
# given coordinates override those already defined
|
||||
arc_end_coords = dict(zip('xyz', arc_start.xyz))
|
||||
arc_end_coords.update(arc_gcode.get_param_dict('XYZ', lc=True))
|
||||
arc_end = Vector3(**arc_end_coords)
|
||||
else:
|
||||
# given coordinates are += to arc's start coords
|
||||
arc_end = arc_start + Vector3(**arc_gcode.get_param_dict('XYZ', lc=True))
|
||||
|
||||
# Planar Projections
|
||||
arc_p_start = plane_projection(arc_start, plane.normal)
|
||||
arc_p_end = plane_projection(arc_end, plane.normal)
|
||||
|
||||
# Arc radius, calcualted one of 2 ways:
|
||||
# - R: arc radius is provided
|
||||
# - IJK: arc's center-point is given, errors mitigated
|
||||
arc_gcode.assert_params()
|
||||
if 'R' in arc_gcode.params:
|
||||
# R: radius magnitude specified
|
||||
if abs(arc_p_start - arc_p_end) < max_error:
|
||||
raise GCodeParameterError(
|
||||
"arc starts and finishes in the same spot; cannot "
|
||||
"speculate where circle's center is: %r" % arc_gcode
|
||||
)
|
||||
|
||||
arc_radius = abs(arc_gcode.R) # arc radius (magnitude)
|
||||
|
||||
else:
|
||||
# IJK: radius vertex specified
|
||||
arc_center_ijk = dict((l, 0.) for l in 'IJK')
|
||||
arc_center_ijk.update(arc_gcode.get_param_dict('IJK'))
|
||||
arc_center_coords = dict(({'I':'x','J':'y','K':'z'}[k], v) for (k, v) in arc_center_ijk.items())
|
||||
arc_center = Vector3(**arc_center_coords)
|
||||
if isinstance(arc_dist_mode, GCodeIncrementalArcDistanceMode):
|
||||
arc_center += start_pos.vector
|
||||
|
||||
# planar projection
|
||||
arc_p_center = plane_projection(arc_center, plane.normal)
|
||||
|
||||
# Radii
|
||||
r1 = arc_p_start - arc_p_center
|
||||
r2 = arc_p_end - arc_p_center
|
||||
|
||||
# average the 2 radii to get the most accurate radius
|
||||
arc_radius = (abs(r1) + abs(r2)) / 2.
|
||||
|
||||
# Find Circle's Center (given radius)
|
||||
arc_span = arc_p_end - arc_p_start # vector spanning from start -> end
|
||||
arc_span_mid = arc_span * 0.5 # arc_span's midpoint
|
||||
if arc_radius < abs(arc_span_mid):
|
||||
raise GCodeParameterError("circle cannot reach endpoint at this radius: %r" % arc_gcode)
|
||||
# vector from arc_span midpoint -> circle's centre
|
||||
radius_mid_vect = arc_span_mid.normalized().cross(plane.normal) * sqrt(arc_radius**2 - abs(arc_span_mid)**2)
|
||||
|
||||
if 'R' in arc_gcode.params:
|
||||
# R: radius magnitude specified
|
||||
if isinstance(arc_gcode, GCodeArcMoveCW) == (arc_gcode.R < 0):
|
||||
arc_p_center = arc_p_start + arc_span_mid - radius_mid_vect
|
||||
else:
|
||||
arc_p_center = arc_p_start + arc_span_mid + radius_mid_vect
|
||||
else:
|
||||
# IJK: radius vertex specified
|
||||
# arc_p_center is defined as per IJK params, this is an adjustment
|
||||
arc_p_center_options = [
|
||||
arc_p_start + arc_span_mid - radius_mid_vect,
|
||||
arc_p_start + arc_span_mid + radius_mid_vect
|
||||
]
|
||||
if abs(arc_p_center_options[0] - arc_p_center) < abs(arc_p_center_options[1] - arc_p_center):
|
||||
arc_p_center = arc_p_center_options[0]
|
||||
else:
|
||||
arc_p_center = arc_p_center_options[1]
|
||||
|
||||
# Arc's angle (first rotated back to xy plane)
|
||||
xy_c2start = plane.quat * (arc_p_start - arc_p_center)
|
||||
xy_c2end = plane.quat * (arc_p_end - arc_p_center)
|
||||
(a1, a2) = (atan2(*xy_c2start.yx), atan2(*xy_c2end.yx))
|
||||
if isinstance(arc_gcode, GCodeArcMoveCW):
|
||||
arc_angle = (a1 - a2) % (2 * pi)
|
||||
else:
|
||||
arc_angle = -((a2 - a1) % (2 * pi))
|
||||
|
||||
# Helical interpolation
|
||||
helical_start = plane.normal * arc_start.dot(plane.normal)
|
||||
helical_end = plane.normal * arc_end.dot(plane.normal)
|
||||
|
||||
# Parameters determined above:
|
||||
# - arc_p_start arc start point
|
||||
# - arc_p_end arc end point
|
||||
# - arc_p_center arc center
|
||||
# - arc_angle angle between start & end (>0 is ccw, <0 is cw) (radians)
|
||||
# - helical_start distance along plane.normal of arc start
|
||||
# - helical_disp distance along plane.normal of arc end
|
||||
|
||||
method_class_params = {
|
||||
'max_error': max_error,
|
||||
'plane_normal': plane.normal,
|
||||
'arc_p_start': arc_p_start,
|
||||
'arc_p_end': arc_p_end,
|
||||
'arc_p_center': arc_p_center,
|
||||
'arc_radius': arc_radius,
|
||||
'arc_angle': arc_angle,
|
||||
'helical_start': helical_start,
|
||||
'helical_end': helical_end,
|
||||
}
|
||||
method = method_class(**method_class_params)
|
||||
|
||||
# Iterate & yield each linear line (start, end) vertices
|
||||
if isinstance(dist_mode, GCodeAbsoluteDistanceMode):
|
||||
# Absolute coordinates
|
||||
for line_vertices in method.iter_vertices():
|
||||
(l_start, l_end) = line_vertices
|
||||
yield GCodeLinearMove(**dict(zip('XYZ', l_end.xyz)))
|
||||
else:
|
||||
# Incremental coordinates (beware cumulative errors)
|
||||
cur_pos = arc_start
|
||||
for line_vertices in method.iter_vertices():
|
||||
(l_start, l_end) = line_vertices
|
||||
l_delta = l_end - cur_pos
|
||||
|
||||
# round delta coordinates (introduces errors)
|
||||
for axis in 'xyz':
|
||||
setattr(l_delta, axis, round(getattr(l_delta, axis), decimal_places))
|
||||
yield GCodeLinearMove(**dict(zip('XYZ', l_delta.xyz)))
|
||||
cur_pos += l_delta # mitigate errors by also adding them the accumulated cur_pos
|
||||
|
||||
|
||||
# ==================== Un-Canning ====================
|
||||
|
||||
DEFAULT_SCC_PLANE = GCodeSelectXYPlane
|
||||
DEFAULT_SCC_DISTMODE = GCodeAbsoluteDistanceMode
|
||||
DEFAULT_SCC_RETRACTMODE = GCodeCannedCycleReturnLevel
|
||||
|
||||
def simplify_canned_cycle(canned_gcode, start_pos,
|
||||
plane=None, dist_mode=None, retract_mode=None,
|
||||
axes='XYZ'):
|
||||
"""
|
||||
Simplify canned cycle into it's basic linear components
|
||||
:param canned_gcode: canned gcode to be simplified (GCodeCannedCycle)
|
||||
:param start_pos: current machine position (Position)
|
||||
:param plane: machine's active plane (GCodePlaneSelect)
|
||||
:param dist_mode: machine's distance mode (GCodeAbsoluteDistanceMode or GCodeIncrementalDistanceMode)
|
||||
:param axes: axes machine accepts (set)
|
||||
"""
|
||||
|
||||
# set defaults
|
||||
if plane is None:
|
||||
plane = DEFAULT_SCC_PLANE()
|
||||
if dist_mode is None:
|
||||
dist_mode = DEFAULT_SCC_DISTMODE()
|
||||
if retract_mode is None:
|
||||
retract_mode = DEFAULT_SCC_RETRACTMODE()
|
||||
|
||||
# Parameter Type Assertions
|
||||
assert isinstance(canned_gcode, GCodeCannedCycle), "bad canned_gcode type: %r" % canned_gcode
|
||||
assert isinstance(start_pos, Position), "bad start_pos type: %r" % start_pos
|
||||
assert isinstance(plane, GCodePlaneSelect), "bad plane type: %r" % plane
|
||||
assert isinstance(dist_mode, (GCodeAbsoluteDistanceMode, GCodeIncrementalDistanceMode)), "bad dist_mode type: %r" % dist_mode
|
||||
assert isinstance(retract_mode, GCodeCannedReturnMode), "bad retract_mode type: %r" % retract_mode
|
||||
|
||||
# TODO: implement for planes other than XY
|
||||
if not isinstance(plane, GCodeSelectXYPlane):
|
||||
raise NotImplementedError("simplifying canned cycles for planes other than X/Y has not been implemented")
|
||||
|
||||
@_gcodes_abs2rel(start_pos=start_pos, dist_mode=dist_mode, axes=axes)
|
||||
def inner():
|
||||
cycle_count = 1 if (canned_gcode.L is None) else canned_gcode.L
|
||||
cur_hole_p_axis = start_pos.vector
|
||||
for i in range(cycle_count):
|
||||
# Calculate Depths
|
||||
if isinstance(dist_mode, GCodeAbsoluteDistanceMode):
|
||||
retract_depth = canned_gcode.R
|
||||
drill_depth = canned_gcode.Z
|
||||
cur_hole_p_axis = Vector3(x=canned_gcode.X, y=canned_gcode.Y)
|
||||
else: # incremental
|
||||
retract_depth = start_pos.Z + canned_gcode.R
|
||||
drill_depth = retract_depth + canned_gcode.Z
|
||||
cur_hole_p_axis += Vector3(x=canned_gcode.X, y=canned_gcode.Y)
|
||||
|
||||
if retract_depth < drill_depth:
|
||||
raise NotImplementedError("drilling upward is not supported")
|
||||
|
||||
if isinstance(retract_mode, GCodeCannedCycleReturnToR):
|
||||
final_depth = retract_depth
|
||||
else:
|
||||
final_depth = start_pos.Z
|
||||
|
||||
# Move above hole (height of retract_depth)
|
||||
if retract_depth > start_pos.Z:
|
||||
yield GCodeRapidMove(Z=retract_depth)
|
||||
yield GCodeRapidMove(X=cur_hole_p_axis.x, Y=cur_hole_p_axis.y)
|
||||
if retract_depth < start_pos.Z:
|
||||
yield GCodeRapidMove(Z=retract_depth)
|
||||
|
||||
# Drill hole
|
||||
delta = drill_depth - retract_depth # full depth
|
||||
if isinstance(canned_gcode, (GCodeDrillingCyclePeck, GCodeDrillingCycleChipBreaking)):
|
||||
delta = -abs(canned_gcode.Q)
|
||||
|
||||
cur_depth = retract_depth
|
||||
last_depth = cur_depth
|
||||
while True:
|
||||
# Determine new depth
|
||||
cur_depth += delta
|
||||
if cur_depth < drill_depth:
|
||||
cur_depth = drill_depth
|
||||
|
||||
# Rapid to just above, then slowly drill through delta
|
||||
just_above_base = last_depth + 0.1
|
||||
if just_above_base < retract_depth:
|
||||
yield GCodeRapidMove(Z=just_above_base)
|
||||
yield GCodeLinearMove(Z=cur_depth)
|
||||
if cur_depth <= drill_depth:
|
||||
break # loop stops at the bottom of the hole
|
||||
else:
|
||||
# back up
|
||||
if isinstance(canned_gcode, GCodeDrillingCycleChipBreaking):
|
||||
# retract "a bit"
|
||||
yield GCodeRapidMove(Z=cur_depth + 0.5) # TODO: configurable retraction
|
||||
else:
|
||||
# default behaviour: GCodeDrillingCyclePeck
|
||||
yield GCodeRapidMove(Z=retract_depth)
|
||||
|
||||
last_depth = cur_depth
|
||||
|
||||
# Dwell
|
||||
if isinstance(canned_gcode, GCodeDrillingCycleDwell):
|
||||
yield GCodeDwell(P=0.5) # TODO: configurable pause
|
||||
|
||||
# Return
|
||||
yield GCodeRapidMove(Z=final_depth)
|
||||
|
||||
return inner()
|
96
src/pygcode/utils.py
Normal file
96
src/pygcode/utils.py
Normal file
@ -0,0 +1,96 @@
|
||||
import sys
|
||||
from copy import copy, deepcopy
|
||||
|
||||
if sys.version_info < (3, 0):
|
||||
from euclid import Vector3, Quaternion
|
||||
else:
|
||||
from euclid3 import Vector3, Quaternion
|
||||
|
||||
|
||||
# ==================== Geometric Utilities ====================
|
||||
def quat2align(to_align, with_this, normalize=True):
|
||||
"""
|
||||
Calculate Quaternion that will rotate a given vector to align with another
|
||||
can accumulate with perpendicular alignemnt vectors like so:
|
||||
(x_axis, z_axis) = (Vector3(1, 0, 0), Vector3(0, 0, 1))
|
||||
q1 = quat2align(v1, z_axis)
|
||||
q2 = quat2align(q1 * v2, x_axis)
|
||||
# assuming v1 is perpendicular to v2
|
||||
q3 = q2 * q1 # application of q3 to any vector will re-orient it to the
|
||||
# coordinate system defined by (v1,v2), as (z,x) respectively.
|
||||
:param to_align: Vector3 instance to be rotated
|
||||
:param with_this: Vector3 instance as target for alignment
|
||||
:result: Quaternion such that: q * to_align == with_this
|
||||
"""
|
||||
# Normalize Vectors
|
||||
if normalize:
|
||||
to_align = to_align.normalized()
|
||||
with_this = with_this.normalized()
|
||||
# Calculate Quaternion
|
||||
return Quaternion.new_rotate_axis(
|
||||
angle=to_align.angle(with_this),
|
||||
axis=to_align.cross(with_this),
|
||||
)
|
||||
|
||||
|
||||
def quat2coord_system(origin1, origin2, align1, align2):
|
||||
"""
|
||||
Calculate Quaternion to apply to any vector to re-orientate it to another
|
||||
(target) coordinate system.
|
||||
(note: both origin and align coordinate systems must use right-hand-rule)
|
||||
:param origin1: origin(1|2) are perpendicular vectors in the original coordinate system
|
||||
:param origin2: see origin1
|
||||
:param align1: align(1|2) are 2 perpendicular vectors in the target coordinate system
|
||||
:param align2: see align1
|
||||
:return: Quaternion such that q * origin1 = align1, and q * origin2 = align2
|
||||
"""
|
||||
# Normalize Vectors
|
||||
origin1 = origin1.normalized()
|
||||
origin2 = origin2.normalized()
|
||||
align1 = align1.normalized()
|
||||
align2 = align2.normalized()
|
||||
# Calculate Quaternion
|
||||
q1 = quat2align(origin1, align1, normalize=False)
|
||||
q2 = quat2align(q1 * origin2, align2, normalize=False)
|
||||
return q2 * q1
|
||||
|
||||
|
||||
def plane_projection(vect, normal):
|
||||
"""
|
||||
Project vect to a plane represented by normal
|
||||
:param vect: vector to be projected (Vector3)
|
||||
:param normal: normal of plane to project on to (Vector3)
|
||||
:return: vect projected onto plane represented by normal
|
||||
"""
|
||||
# ref: https://en.wikipedia.org/wiki/Vector_projection
|
||||
n = normal.normalized()
|
||||
return vect - (n * vect.dot(n))
|
||||
|
||||
|
||||
# ==================== GCode Utilities ====================
|
||||
def omit_redundant_modes(gcode_iter):
|
||||
"""
|
||||
Replace redundant machine motion modes with whitespace,
|
||||
:param gcode_iter: iterable to return with modifications
|
||||
"""
|
||||
|
||||
from .machine import Machine, Mode
|
||||
from .gcodes import MODAL_GROUP_MAP
|
||||
class NullModeMachine(Machine):
|
||||
MODE_CLASS = type('NullMode', (Mode,), {'default_mode': ''})
|
||||
m = NullModeMachine()
|
||||
|
||||
for g in gcode_iter:
|
||||
if (g.modal_group is not None) and (m.mode.modal_groups[g.modal_group] is not None):
|
||||
# g-code has a modal groups, and the machine's mode
|
||||
# (of the same modal group) is not None
|
||||
if m.mode.modal_groups[g.modal_group].word == g.word:
|
||||
# machine's mode & g-code's mode match (no machine change)
|
||||
if g.modal_group == MODAL_GROUP_MAP['motion']:
|
||||
# finally: g-code sets a motion mode in the machine
|
||||
g = copy(g) # duplicate gcode object
|
||||
# stop redundant g-code word from being printed
|
||||
g._whitespace_prefix = True
|
||||
|
||||
m.process_gcodes(g)
|
||||
yield g
|
330
src/pygcode/words.py
Normal file
330
src/pygcode/words.py
Normal file
@ -0,0 +1,330 @@
|
||||
import re
|
||||
import itertools
|
||||
import six
|
||||
|
||||
from .exceptions import GCodeBlockFormatError, GCodeWordStrError
|
||||
|
||||
REGEX_FLOAT = re.compile(r'^-?(\d+\.?\d*|\.\d+)') # testcase: ..tests.test_words.WordValueMatchTests.test_float
|
||||
REGEX_INT = re.compile(r'^-?\d+')
|
||||
REGEX_POSITIVEINT = re.compile(r'^\d+')
|
||||
REGEX_CODE = re.compile(r'^\d+(\.\d)?') # similar
|
||||
|
||||
# Value cleaning functions
|
||||
def _clean_codestr(value):
|
||||
if value < 10:
|
||||
return "0%g" % value
|
||||
return "%g" % value
|
||||
|
||||
CLEAN_NONE = lambda v: v
|
||||
CLEAN_FLOAT = lambda v: "{0:g}".format(round(v, 3))
|
||||
CLEAN_CODE = _clean_codestr
|
||||
CLEAN_INT = lambda v: "%g" % v
|
||||
|
||||
WORD_MAP = {
|
||||
# Descriptions copied from wikipedia:
|
||||
# https://en.wikipedia.org/wiki/G-code#Letter_addresses
|
||||
|
||||
# Rotational Axes
|
||||
'A': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Absolute or incremental position of A axis (rotational axis around X axis)",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
'B': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Absolute or incremental position of B axis (rotational axis around Y axis)",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
'C': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Absolute or incremental position of C axis (rotational axis around Z axis)",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
'D': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Defines diameter or radial offset used for cutter compensation. D is used for depth of cut on lathes. It is used for aperture selection and commands on photoplotters.",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
# Feed Rates
|
||||
'E': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Precision feedrate for threading on lathes",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
'F': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Feedrate",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
# G-Codes
|
||||
'G': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_CODE,
|
||||
'description': "Address for preparatory commands",
|
||||
'clean_value': CLEAN_CODE,
|
||||
},
|
||||
# Tool Offsets
|
||||
'H': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Defines tool length offset; Incremental axis corresponding to C axis (e.g., on a turn-mill)",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
# Arc radius center coords
|
||||
'I': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Defines arc center in X axis for G02 or G03 arc commands. Also used as a parameter within some fixed cycles.",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
'J': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Defines arc center in Y axis for G02 or G03 arc commands. Also used as a parameter within some fixed cycles.",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
'K': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Defines arc center in Z axis for G02 or G03 arc commands. Also used as a parameter within some fixed cycles, equal to L address.",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
# Loop Count
|
||||
'L': {
|
||||
'class': int,
|
||||
'value_regex': REGEX_POSITIVEINT,
|
||||
'description': "Fixed cycle loop count; Specification of what register to edit using G10",
|
||||
'clean_value': CLEAN_INT,
|
||||
},
|
||||
# Miscellaneous Function
|
||||
'M': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_CODE,
|
||||
'description': "Miscellaneous function",
|
||||
'clean_value': CLEAN_CODE,
|
||||
},
|
||||
# Line Number
|
||||
'N': {
|
||||
'class': int,
|
||||
'value_regex': REGEX_POSITIVEINT,
|
||||
'description': "Line (block) number in program; System parameter number to change using G10",
|
||||
'clean_value': CLEAN_INT,
|
||||
},
|
||||
# Program Name
|
||||
'O': {
|
||||
'class': str,
|
||||
'value_regex': re.compile(r'^.+$'), # all the way to the end
|
||||
'description': "Program name",
|
||||
'clean_value': CLEAN_NONE,
|
||||
},
|
||||
# Parameter (arbitrary parameter)
|
||||
'P': {
|
||||
'class': float, # parameter is often an integer, but can be a float
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Serves as parameter address for various G and M codes",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
# Peck increment
|
||||
'Q': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Depth to increase on each peck; Peck increment in canned cycles",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
# Arc Radius
|
||||
'R': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Defines size of arc radius, or defines retract height in milling canned cycles",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
# Spindle speed
|
||||
'S': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Defines speed, either spindle speed or surface speed depending on mode",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
# Tool Selecton
|
||||
'T': {
|
||||
'class': str,
|
||||
'value_regex': REGEX_POSITIVEINT, # tool string may have leading '0's, but is effectively an index (integer)
|
||||
'description': "Tool selection",
|
||||
'clean_value': CLEAN_NONE,
|
||||
},
|
||||
# Incremental axes
|
||||
'U': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Incremental axis corresponding to X axis (typically only lathe group A controls) Also defines dwell time on some machines (instead of 'P' or 'X').",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
'V': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Incremental axis corresponding to Y axis",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
'W': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Incremental axis corresponding to Z axis (typically only lathe group A controls)",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
# Linear Axes
|
||||
'X': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Absolute or incremental position of X axis.",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
'Y': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Absolute or incremental position of Y axis.",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
'Z': {
|
||||
'class': float,
|
||||
'value_regex': REGEX_FLOAT,
|
||||
'description': "Absolute or incremental position of Z axis.",
|
||||
'clean_value': CLEAN_FLOAT,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Word(object):
|
||||
def __init__(self, *args):
|
||||
if len(args) not in (1, 2):
|
||||
raise AssertionError("input arguments either: (letter, value) or (word_str)")
|
||||
if len(args) == 2:
|
||||
# Word('G', 90)
|
||||
(letter, value) = args
|
||||
else:
|
||||
# Word('G90')
|
||||
letter = args[0][0] # first letter
|
||||
value = args[0][1:] # rest of string
|
||||
letter = letter.upper()
|
||||
|
||||
self._value_class = WORD_MAP[letter]['class']
|
||||
self._value_clean = WORD_MAP[letter]['clean_value']
|
||||
|
||||
self.letter = letter
|
||||
self.value = value
|
||||
|
||||
def __str__(self):
|
||||
return "{letter}{value}".format(
|
||||
letter=self.letter,
|
||||
value=self.value_str,
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return "<{class_name}: {string}>".format(
|
||||
class_name=self.__class__.__name__,
|
||||
string=str(self),
|
||||
)
|
||||
|
||||
# Sorting
|
||||
def __lt__(self, other):
|
||||
return (self.letter, self.value) < (other.letter, other.value)
|
||||
|
||||
def __gt__(self, other):
|
||||
return (self.letter, self.value) > (other.letter, other.value)
|
||||
|
||||
def __le__(self, other):
|
||||
return (self.letter, self.value) <= (other.letter, other.value)
|
||||
|
||||
def __ge__(self, other):
|
||||
return (self.letter, self.value) >= (other.letter, other.value)
|
||||
|
||||
# Equality
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, six.string_types):
|
||||
other = str2word(other)
|
||||
return (self.letter == other.letter) and (self.value == other.value)
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
# Hashing
|
||||
def __hash__(self):
|
||||
return hash((self.letter, self.value))
|
||||
|
||||
@property
|
||||
def value_str(self):
|
||||
"""Clean string representation, for consistent file output"""
|
||||
return self._value_clean(self.value)
|
||||
|
||||
# Value Properties
|
||||
@property
|
||||
def value(self):
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
def value(self, new_value):
|
||||
self._value = self._value_class(new_value)
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return "%s: %s" % (self.letter, WORD_MAP[self.letter]['description'])
|
||||
|
||||
|
||||
def text2words(block_text):
|
||||
"""
|
||||
Iterate through block text yielding Word instances
|
||||
:param block_text: text for given block with comments removed
|
||||
"""
|
||||
next_word = re.compile(r'^.*?(?P<letter>[%s])' % ''.join(WORD_MAP.keys()), re.IGNORECASE)
|
||||
|
||||
index = 0
|
||||
while True:
|
||||
letter_match = next_word.search(block_text[index:])
|
||||
if letter_match:
|
||||
# Letter
|
||||
letter = letter_match.group('letter').upper()
|
||||
index += letter_match.end() # propogate index to start of value
|
||||
|
||||
# Value
|
||||
value_regex = WORD_MAP[letter]['value_regex']
|
||||
value_match = value_regex.search(block_text[index:])
|
||||
if value_match is None:
|
||||
raise GCodeWordStrError("word '%s' value invalid" % letter)
|
||||
value = value_match.group() # matched text
|
||||
|
||||
yield Word(letter, value)
|
||||
|
||||
index += value_match.end() # propogate index to end of value
|
||||
else:
|
||||
break
|
||||
|
||||
remainder = block_text[index:]
|
||||
if remainder and re.search(r'\S', remainder):
|
||||
raise GCodeWordStrError("block code remaining '%s'" % remainder)
|
||||
|
||||
|
||||
def str2word(word_str):
|
||||
words = list(text2words(word_str))
|
||||
if words:
|
||||
if len(words) > 1:
|
||||
raise GCodeWordStrError("more than one word given")
|
||||
return words[0]
|
||||
return None
|
||||
|
||||
|
||||
def words2dict(word_list, limit_word_letters=None):
|
||||
"""
|
||||
Represent a list of words as a dict
|
||||
:param limit_word_letters: iterable containing a white-list of word letters (None allows all)
|
||||
:return: dict of the form: {<letter>: <value>, ... }
|
||||
"""
|
||||
# Remember: duplicate word letters cannot be represented as a dict
|
||||
return dict(
|
||||
(w.letter, w.value) for w in word_list
|
||||
if (limit_word_letters is None) or (w.letter in limit_word_letters)
|
||||
)
|
3
tests/runtests.sh
Executable file
3
tests/runtests.sh
Executable file
@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
python -m unittest discover -s . -p 'test_*.py' --verbose
|
26
tests/test-files/vertical-slot.ngc
Normal file
26
tests/test-files/vertical-slot.ngc
Normal file
@ -0,0 +1,26 @@
|
||||
T5 M06 G43
|
||||
G17 G90 G21
|
||||
G00 Z5 S7000 M03
|
||||
X9.60 Y48.74
|
||||
Z2
|
||||
G01 Z-0.5 F100
|
||||
G02 X10.75 Y47.44 I-0.11 J-1.26 F70
|
||||
G01 X10.75 Y-47.44
|
||||
G02 X9.51 Y-48.69 I-1.25 J0
|
||||
G02 X8.25 Y-47.44 I0 J1.25
|
||||
G01 X8.25 Y-47.44
|
||||
X8.25 Y47.44
|
||||
G02 X9.6 Y48.74 I1.25 J0.05
|
||||
G00 Z5
|
||||
Z1.5
|
||||
G01 Z-1 F100
|
||||
G02 X10.75 Y47.44 I-0.11 J-1.26 F70
|
||||
G01 X10.75 Y-47.44
|
||||
G02 X9.51 Y-48.69 I-1.25 J0
|
||||
G02 X8.25 Y-47.44 I0 J1.25
|
||||
G01 X8.25 Y-47.44
|
||||
X8.25 Y47.44
|
||||
G02 X9.60 Y48.74 I1.25 J0.05
|
||||
G00 Z5
|
||||
T0 M06 M02
|
||||
|
24
tests/test_file.py
Normal file
24
tests/test_file.py
Normal file
@ -0,0 +1,24 @@
|
||||
import sys
|
||||
import os
|
||||
import inspect
|
||||
|
||||
import unittest
|
||||
|
||||
# Add relative pygcode to path
|
||||
from testutils import add_pygcode_to_path, str_lines
|
||||
add_pygcode_to_path()
|
||||
|
||||
# Units under test
|
||||
from pygcode.file import GCodeFile, GCodeParser
|
||||
|
||||
class GCodeParserTest(unittest.TestCase):
|
||||
FILENAME = 'test-files/vertical-slot.ngc'
|
||||
|
||||
def test_parser(self):
|
||||
parser = GCodeParser(self.FILENAME)
|
||||
# count lines
|
||||
line_count = 0
|
||||
for line in parser.iterlines():
|
||||
line_count += 1
|
||||
self.assertEqual(line_count, 26)
|
||||
parser.close()
|
193
tests/test_gcodes.py
Normal file
193
tests/test_gcodes.py
Normal file
@ -0,0 +1,193 @@
|
||||
import sys
|
||||
import os
|
||||
import inspect
|
||||
import re
|
||||
import unittest
|
||||
|
||||
# Add relative pygcode to path
|
||||
from testutils import add_pygcode_to_path, str_lines
|
||||
add_pygcode_to_path()
|
||||
|
||||
# Units under test
|
||||
from pygcode import gcodes
|
||||
from pygcode import words
|
||||
from pygcode import machine
|
||||
|
||||
from pygcode.exceptions import GCodeWordStrError
|
||||
|
||||
class GCodeWordMappingTests(unittest.TestCase):
|
||||
def test_word_map_integrity(self):
|
||||
|
||||
gcodes.build_maps()
|
||||
for (word_maches, fn_class) in gcodes._gcode_function_list:
|
||||
for (word, key_class) in gcodes._gcode_word_map.items():
|
||||
# Verify that no mapped word will yield a True result
|
||||
# from any of the 'word_maches' functions
|
||||
self.assertFalse(
|
||||
word_maches(word),
|
||||
"conflict with %s and %s" % (fn_class, key_class)
|
||||
)
|
||||
|
||||
class GCodeModalGroupTests(unittest.TestCase):
|
||||
def test_modal_groups(self):
|
||||
# Modal groups taken (and slightly modified) from LinuxCNC documentation:
|
||||
# link: http://linuxcnc.org/docs/html/gcode/overview.html#_modal_groups
|
||||
table_rows = ''
|
||||
# Table 5. G-Code Modal Groups
|
||||
# MODAL GROUP MEANING MEMBER WORDS
|
||||
table_rows += '''
|
||||
Non-modal codes (Group 0) G4,G10,G28,G30,G53,G92,G92.1,G92.2,G92.3
|
||||
Motion (Group 1) G0,G1,G2,G3,G33,G38.2,G38.3,G38.4
|
||||
Motion (Group 1) G38.5,G73,G76,G80,G81,G82,G83,G85,G89
|
||||
Plane selection (Group 2) G17, G18, G19, G17.1, G18.1, G19.1
|
||||
Distance Mode (Group 3) G90, G91
|
||||
Arc IJK Distance Mode (Group 4) G90.1, G91.1
|
||||
Feed Rate Mode (Group 5) G93, G94, G95
|
||||
Units (Group 6) G20, G21
|
||||
Cutter Diameter Compensation (Group 7) G40, G41, G42, G41.1, G42.1
|
||||
Tool Length Offset (Group 8) G43, G43.1, G49
|
||||
Canned Cycles Return Mode (Group 10) G98
|
||||
Coordinate System (Group 12) G54,G55,G56,G57,G58,G59,G59.1,G59.2,G59.3
|
||||
Control Mode (Group 13) G61, G61.1, G64
|
||||
Spindle Speed Mode (Group 14) G96, G97
|
||||
Lathe Diameter Mode (Group 15) G7,G8
|
||||
'''
|
||||
|
||||
# Table 6. M-Code Modal Groups
|
||||
# MODAL GROUP MEANING MEMBER WORDS
|
||||
table_rows += re.sub(r'\(Group (\d+)\)', r'(Group 10\1)', '''
|
||||
Stopping (Group 4) M0, M1, M2, M30, M60
|
||||
Spindle (Group 7) M3, M4, M5
|
||||
Coolant (Group 8) M7, M8, M9
|
||||
Override Switches (Group 9) M48, M49
|
||||
''') # groups += 100 (to distinguish "M" GCodes from "G" GCodes)
|
||||
|
||||
for row in table_rows.split('\n'):
|
||||
match = re.search(r'^\s*(?P<title>.*)\s*\(Group (?P<group>\d+)\)\s*(?P<words>.*)$', row, re.I)
|
||||
if match:
|
||||
for word_str in re.split(r'\s*,\s*', match.group('words')):
|
||||
word = list(words.text2words(word_str))[0]
|
||||
gcode_class = gcodes.word_gcode_class(word)
|
||||
# GCode class found for each word in the table
|
||||
self.assertIsNotNone(gcode_class)
|
||||
# GCode's modal group equals that defined in the table
|
||||
expected_group = int(match.group('group'))
|
||||
if expected_group == 0:
|
||||
self.assertIsNone(
|
||||
gcode_class.modal_group,
|
||||
"%s modal_group: %s is not None" % (gcode_class, gcode_class.modal_group)
|
||||
)
|
||||
else:
|
||||
self.assertEqual(
|
||||
gcode_class.modal_group, expected_group,
|
||||
"%s != %s (%r)" % (gcode_class.modal_group, expected_group, word)
|
||||
)
|
||||
|
||||
|
||||
class Words2GCodesTests(unittest.TestCase):
|
||||
def test_stuff(self): # FIXME: function name
|
||||
line = 'G1 X82.6892 Y-38.6339 F1500'
|
||||
word_list = list(words.text2words(line))
|
||||
result = gcodes.words2gcodes(word_list)
|
||||
# result form
|
||||
self.assertIsInstance(result, tuple)
|
||||
self.assertEqual(len(result), 2)
|
||||
# result content
|
||||
(gcode_list, unused_words) = result
|
||||
self.assertEqual(len(gcode_list), 2)
|
||||
self.assertEqual(unused_words, [])
|
||||
# Parsed GCodes
|
||||
# G1
|
||||
self.assertEqual(gcode_list[0].word, words.Word('G', 1))
|
||||
self.assertEqual(gcode_list[0].X, 82.6892)
|
||||
self.assertEqual(gcode_list[0].Y, -38.6339)
|
||||
# F1500
|
||||
self.assertEqual(gcode_list[1].word, words.Word('F', 1500))
|
||||
|
||||
|
||||
class Text2GCodesTests(unittest.TestCase):
|
||||
def test_basic(self):
|
||||
gcs = gcodes.text2gcodes('G1 X1 Y2 G90')
|
||||
self.assertEqual(len(gcs), 2)
|
||||
# G1 X1 Y2
|
||||
self.assertEqual(gcs[0].word, words.Word('G', 1))
|
||||
self.assertEqual(gcs[0].X, 1)
|
||||
self.assertEqual(gcs[0].Y, 2)
|
||||
# G90
|
||||
self.assertEqual(gcs[1].word, words.Word('G', 90))
|
||||
|
||||
def test_modal_params(self):
|
||||
with self.assertRaises(GCodeWordStrError):
|
||||
gcodes.text2gcodes('X1 Y2')
|
||||
|
||||
|
||||
class GCodeSplitTests(unittest.TestCase):
|
||||
|
||||
def test_split(self):
|
||||
g_list = gcodes.text2gcodes('G91 S1000 G1 X1 Y2 M3')
|
||||
split = gcodes.split_gcodes(g_list, gcodes.GCodeStartSpindle)
|
||||
self.assertEqual([len(x) for x in split], [1, 1, 2])
|
||||
self.assertTrue(any(isinstance(g, gcodes.GCodeSpindleSpeed) for g in split[0]))
|
||||
self.assertTrue(isinstance(split[1][0], gcodes.GCodeStartSpindle))
|
||||
self.assertTrue(any(isinstance(g, gcodes.GCodeDistanceMode) for g in split[2]))
|
||||
self.assertTrue(any(isinstance(g, gcodes.GCodeMotion) for g in split[2]))
|
||||
|
||||
def test_split_unsorted(self):
|
||||
g_list = gcodes.text2gcodes('G91 G1 X1 Y2 M3 S1000')
|
||||
split = gcodes.split_gcodes(g_list, gcodes.GCodeStartSpindle, sort_list=False)
|
||||
self.assertEqual([len(x) for x in split], [2, 1, 1])
|
||||
self.assertTrue(any(isinstance(g, gcodes.GCodeDistanceMode) for g in split[0]))
|
||||
self.assertTrue(any(isinstance(g, gcodes.GCodeMotion) for g in split[0]))
|
||||
self.assertTrue(isinstance(split[1][0], gcodes.GCodeStartSpindle))
|
||||
self.assertTrue(any(isinstance(g, gcodes.GCodeSpindleSpeed) for g in split[2]))
|
||||
|
||||
|
||||
class GCodeAbsoluteToRelativeDecoratorTests(unittest.TestCase):
|
||||
|
||||
def test_gcodes_abs2rel(self):
|
||||
# setup gcode testlist
|
||||
L = gcodes.GCodeLinearMove
|
||||
R = gcodes.GCodeRapidMove
|
||||
args = lambda x, y, z: dict(a for a in zip('XYZ', [x,y,z]) if a[1] is not None)
|
||||
gcode_list = [
|
||||
# GCode instances Expected incremental output
|
||||
(L(**args(0, 0, 0)), L(**args(-10, -20, -30))),
|
||||
(L(**args(1, 2, 0)), L(**args(1, 2, None))),
|
||||
(L(**args(3, 4, 0)), L(**args(2, 2, None))),
|
||||
(R(**args(1, 2, 0)), R(**args(-2, -2, None))),
|
||||
(R(**args(3, 4, 0)), R(**args(2, 2, None))),
|
||||
(L(**args(3, 4, 0)), None),
|
||||
(L(**args(3, 4, 8)), L(**args(None, None, 8))),
|
||||
]
|
||||
|
||||
m = machine.Machine()
|
||||
|
||||
# Incremental Output
|
||||
m.set_mode(gcodes.GCodeAbsoluteDistanceMode())
|
||||
m.move_to(X=10, Y=20, Z=30) # initial position (absolute)
|
||||
m.set_mode(gcodes.GCodeIncrementalDistanceMode())
|
||||
|
||||
@gcodes._gcodes_abs2rel(m.pos, dist_mode=m.mode.distance, axes=m.axes)
|
||||
def expecting_rel():
|
||||
return [g[0] for g in gcode_list]
|
||||
|
||||
trimmed_expecting_list = [x[1] for x in gcode_list if x[1] is not None]
|
||||
for (i, g) in enumerate(expecting_rel()):
|
||||
expected = trimmed_expecting_list[i]
|
||||
self.assertEqual(type(g), type(expected))
|
||||
self.assertEqual(g.word, expected.word)
|
||||
self.assertEqual(g.params, expected.params)
|
||||
|
||||
# Absolute Output
|
||||
m.set_mode(gcodes.GCodeAbsoluteDistanceMode())
|
||||
m.move_to(X=10, Y=20, Z=30) # initial position
|
||||
|
||||
@gcodes._gcodes_abs2rel(m.pos, dist_mode=m.mode.distance, axes=m.axes)
|
||||
def expecting_abs():
|
||||
return [g[0] for g in gcode_list]
|
||||
|
||||
for (i, g) in enumerate(expecting_abs()):
|
||||
expected = gcode_list[i][0] # expecting passthrough
|
||||
self.assertEqual(type(g), type(expected))
|
||||
self.assertEqual(g.word, expected.word)
|
||||
self.assertEqual(g.params, expected.params)
|
29
tests/test_line.py
Normal file
29
tests/test_line.py
Normal file
@ -0,0 +1,29 @@
|
||||
import sys
|
||||
import os
|
||||
import inspect
|
||||
|
||||
import unittest
|
||||
|
||||
# Add relative pygcode to path
|
||||
from testutils import add_pygcode_to_path, str_lines
|
||||
add_pygcode_to_path()
|
||||
|
||||
# Units under test
|
||||
from pygcode.line import Line
|
||||
|
||||
|
||||
class LineCommentTests(unittest.TestCase):
|
||||
def test_line_comment_semicolon(self):
|
||||
line = Line('G02 X10.75 Y47.44 I-0.11 J-1.26 F70 ; blah blah')
|
||||
self.assertEqual(line.comment.text, 'blah blah')
|
||||
self.assertEqual(len(line.block.words), 6)
|
||||
|
||||
def test_line_comment_brackets(self):
|
||||
line = Line('G02 X10.75 Y47.44 I-0.11 J-1.26 F70 (blah blah)')
|
||||
self.assertEqual(line.comment.text, 'blah blah')
|
||||
self.assertEqual(len(line.block.words), 6)
|
||||
|
||||
def test_line_comment_brackets_multi(self):
|
||||
line = Line('G02 X10.75 (x coord) Y47.44 (y coord) I-0.11 J-1.26 F70 (eol)')
|
||||
self.assertEqual(line.comment.text, 'x coord. y coord. eol')
|
||||
self.assertEqual(len(line.block.words), 6)
|
123
tests/test_machine.py
Normal file
123
tests/test_machine.py
Normal file
@ -0,0 +1,123 @@
|
||||
import unittest
|
||||
|
||||
# Add relative pygcode to path
|
||||
from testutils import add_pygcode_to_path, str_lines
|
||||
add_pygcode_to_path()
|
||||
|
||||
# Units under test
|
||||
from pygcode.machine import Position, Machine
|
||||
from pygcode.line import Line
|
||||
from pygcode.exceptions import MachineInvalidAxis
|
||||
|
||||
|
||||
class PositionTests(unittest.TestCase):
|
||||
def test_basics(self):
|
||||
p = Position()
|
||||
#
|
||||
|
||||
def test_default_axes(self):
|
||||
p = Position() # no instantiation parameters
|
||||
# all initialized to zero
|
||||
for axis in 'XYZABCUVW':
|
||||
self.assertEqual(getattr(p, axis), 0)
|
||||
|
||||
for axis in 'XYZABCUVW':
|
||||
# set to 100
|
||||
setattr(p, axis, 100)
|
||||
self.assertEqual(getattr(p, axis), 100)
|
||||
for inner_axis in set('XYZABCUVW') - {axis}: # no other axis has changed
|
||||
self.assertEqual(getattr(p, inner_axis), 0), "axis '%s'" % inner_axis
|
||||
# revert back to zero
|
||||
setattr(p, axis, 0)
|
||||
self.assertEqual(getattr(p, axis), 0)
|
||||
|
||||
# Equality
|
||||
def test_equality(self):
|
||||
p1 = Position(axes='XYZ', X=1, Y=2)
|
||||
p2 = Position(axes='XYZ', X=1, Y=2, Z=0)
|
||||
p3 = Position(axes='XYZ', X=1, Y=2, Z=1000)
|
||||
p4 = Position(axes='XYZA', X=1, Y=2, Z=0)
|
||||
|
||||
# p1 <--> p2
|
||||
self.assertTrue(p1 == p2)
|
||||
self.assertFalse(p1 != p2) # negative case
|
||||
|
||||
# p2 <--> p3
|
||||
self.assertTrue(p2 != p3)
|
||||
self.assertFalse(p2 == p3) # negative case
|
||||
|
||||
# p2 <--> p4
|
||||
self.assertTrue(p2 != p4)
|
||||
self.assertFalse(p2 == p4) # negative case
|
||||
|
||||
# Arithmetic
|
||||
def test_arithmetic_add(self):
|
||||
p1 = Position(axes='XYZ', X=1, Y=2)
|
||||
p2 = Position(axes='XYZ', Y=10, Z=-20)
|
||||
self.assertEqual(p1 + p2, Position(axes='XYZ', X=1, Y=12, Z=-20))
|
||||
|
||||
p3 = Position(axes='XYZA')
|
||||
with self.assertRaises(MachineInvalidAxis):
|
||||
p1 + p3 # mismatched axes
|
||||
with self.assertRaises(MachineInvalidAxis):
|
||||
p3 + p1 # mismatched axes
|
||||
|
||||
def test_arithmetic_sub(self):
|
||||
p1 = Position(axes='XYZ', X=1, Y=2)
|
||||
p2 = Position(axes='XYZ', Y=10, Z=-20)
|
||||
self.assertEqual(p1 - p2, Position(axes='XYZ', X=1, Y=-8, Z=20))
|
||||
|
||||
p3 = Position(axes='XYZA')
|
||||
p3 - p1 # fine
|
||||
with self.assertRaises(MachineInvalidAxis):
|
||||
p1 - p3 # mismatched axes
|
||||
|
||||
def test_arithmetic_multiply(self):
|
||||
p = Position(axes='XYZ', X=2, Y=10)
|
||||
self.assertEqual(p * 2, Position(axes='XYZ', X=4, Y=20))
|
||||
|
||||
def test_arithmetic_divide(self):
|
||||
p = Position(axes='XYZ', X=2, Y=10)
|
||||
self.assertEqual(p / 2, Position(axes='XYZ', X=1, Y=5))
|
||||
|
||||
|
||||
|
||||
class MachineGCodeProcessingTests(unittest.TestCase):
|
||||
def test_linear_movement(self):
|
||||
m = Machine()
|
||||
test_str = '''; move in a 10mm square
|
||||
F100 M3 S1000 ; 0
|
||||
g1 x0 y10 ; 1
|
||||
g1 x10 y10 ; 2
|
||||
g1 x10 y0 ; 3
|
||||
g1 x0 y0 ; 4
|
||||
'''
|
||||
expected_pos = {
|
||||
'0': m.Position(),
|
||||
'1': m.Position(X=0, Y=10),
|
||||
'2': m.Position(X=10, Y=10),
|
||||
'3': m.Position(X=10, Y=0),
|
||||
'4': m.Position(X=0, Y=0),
|
||||
}
|
||||
#print("\n%r\n%r" % (m.mode, m.state))
|
||||
for line_text in str_lines(test_str):
|
||||
line = Line(line_text)
|
||||
if line.block:
|
||||
#print("\n%s" % line.block)
|
||||
m.process_block(line.block)
|
||||
# Assert possition change correct
|
||||
comment = line.comment.text
|
||||
if comment in expected_pos:
|
||||
self.assertEqual(m.pos, expected_pos[comment])
|
||||
#print("%r\n%r\npos=%r" % (m.mode, m.state, m.pos))
|
||||
|
||||
|
||||
#m = Machine()
|
||||
#
|
||||
#file = GCodeParser('part1.gcode')
|
||||
#for line in file.iterlines():
|
||||
# for (i, gcode) in enumerate(line.block.gcode):
|
||||
# if isinstance(gcode, GCodeArcMove):
|
||||
# arc = gcode
|
||||
# line_params = arc.line_segments(precision=0.0005)
|
||||
# for
|
33
tests/test_utils.py
Normal file
33
tests/test_utils.py
Normal file
@ -0,0 +1,33 @@
|
||||
import unittest
|
||||
import re
|
||||
|
||||
# Add relative pygcode to path
|
||||
from testutils import add_pygcode_to_path, str_lines
|
||||
add_pygcode_to_path()
|
||||
|
||||
# Units under test
|
||||
from pygcode.utils import omit_redundant_modes
|
||||
from pygcode import text2gcodes, Line
|
||||
|
||||
class UtilityTests(unittest.TestCase):
|
||||
def test_omit_redundant_modes(self):
|
||||
lines = [
|
||||
Line(line_str)
|
||||
for line_str in re.split(r'\s*\n\s*', '''
|
||||
g1 x0 y0 ; yes
|
||||
g1 x10 y-20 ; no
|
||||
g0 x-3 y2 ; yes
|
||||
g0 x0 y0 ; no
|
||||
g0 x1 y1 ; no
|
||||
g1 x20 y20 z5 ; yes
|
||||
''')
|
||||
if line_str
|
||||
]
|
||||
gcodes = [l.gcodes[0] for l in lines]
|
||||
comments = [l.comment for l in lines]
|
||||
for (i, g) in enumerate(omit_redundant_modes(gcodes)):
|
||||
comment = comments[i].text if comments[i] else None
|
||||
if comment == 'no':
|
||||
self.assertIsNotNone(re.search(r'^\s', str(g)))
|
||||
elif comment == 'yes':
|
||||
self.assertIsNone(re.search(r'^\s', str(g)))
|
74
tests/test_words.py
Normal file
74
tests/test_words.py
Normal file
@ -0,0 +1,74 @@
|
||||
import unittest
|
||||
|
||||
# Add relative pygcode to path
|
||||
from testutils import add_pygcode_to_path, str_lines
|
||||
add_pygcode_to_path()
|
||||
|
||||
# Units under test
|
||||
from pygcode import words
|
||||
|
||||
|
||||
class WordIterTests(unittest.TestCase):
|
||||
def test_iter1(self):
|
||||
block_str = 'G01 Z-0.5 F100'
|
||||
w = list(words.text2words(block_str))
|
||||
# word length
|
||||
self.assertEqual(len(w), 3)
|
||||
# word values
|
||||
self.assertEqual(w[0], words.Word('G', 1))
|
||||
self.assertEqual(w[1], words.Word('Z', -0.5))
|
||||
self.assertEqual(w[2], words.Word('F', 100))
|
||||
|
||||
def test_iter2(self):
|
||||
block_str = 'G02 X10.75 Y47.44 I-0.11 J-1.26 F70'
|
||||
w = list(words.text2words(block_str))
|
||||
# word length
|
||||
self.assertEqual(len(w), 6)
|
||||
# word values
|
||||
self.assertEqual([w[0].letter, w[0].value], ['G', 2])
|
||||
self.assertEqual([w[1].letter, w[1].value], ['X', 10.75])
|
||||
self.assertEqual([w[2].letter, w[2].value], ['Y', 47.44])
|
||||
self.assertEqual([w[3].letter, w[3].value], ['I', -0.11])
|
||||
self.assertEqual([w[4].letter, w[4].value], ['J', -1.26])
|
||||
self.assertEqual([w[5].letter, w[5].value], ['F', 70])
|
||||
|
||||
|
||||
class WordValueMatchTests(unittest.TestCase):
|
||||
|
||||
def regex_assertions(self, regex, positive_list, negative_list):
|
||||
# Assert all elements of positive_list match regex
|
||||
for (value_str, expected_match) in positive_list:
|
||||
match = regex.search(value_str)
|
||||
self.assertIsNotNone(match, "failed to match '%s'" % value_str)
|
||||
self.assertEqual(match.group(), expected_match)
|
||||
|
||||
# Asesrt all elements of negative_list do not match regex
|
||||
for value_str in negative_list:
|
||||
match = regex.search(value_str)
|
||||
self.assertIsNone(match, "matched for '%s'" % value_str)
|
||||
|
||||
def test_float(self):
|
||||
self.regex_assertions(
|
||||
regex=words.REGEX_FLOAT,
|
||||
positive_list=[
|
||||
('1.2', '1.2'), ('1', '1'), ('200', '200'), ('0092', '0092'),
|
||||
('1.', '1.'), ('.2', '.2'), ('-1.234', '-1.234'),
|
||||
('-1.', '-1.'), ('-.289', '-.289'),
|
||||
# error cases (only detectable in gcode context)
|
||||
('1.2e3', '1.2'),
|
||||
],
|
||||
negative_list=['.', ' 1.2']
|
||||
)
|
||||
|
||||
def test_code(self):
|
||||
self.regex_assertions(
|
||||
regex=words.REGEX_CODE,
|
||||
positive_list=[
|
||||
('1.2', '1.2'), ('1', '1'), ('10', '10'),
|
||||
('02', '02'), ('02.3', '02.3'),
|
||||
('1.', '1'), ('03 ', '03'),
|
||||
# error cases (only detectable in gcode context)
|
||||
('30.12', '30.1'),
|
||||
],
|
||||
negative_list=['.2', '.', ' 2']
|
||||
)
|
26
tests/testutils.py
Normal file
26
tests/testutils.py
Normal file
@ -0,0 +1,26 @@
|
||||
# utilities for the testing suite (as opposed to the tests for utils.py)
|
||||
import sys
|
||||
import os
|
||||
import inspect
|
||||
|
||||
import re
|
||||
|
||||
# Units Under Test
|
||||
_pygcode_in_path = False
|
||||
def add_pygcode_to_path():
|
||||
global _pygcode_in_path
|
||||
if not _pygcode_in_path:
|
||||
# Add pygcode (relative to this test-path) to the system path
|
||||
_this_path = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
|
||||
sys.path.insert(0, os.path.join(_this_path, '..'))
|
||||
|
||||
_pygcode_in_path = True
|
||||
|
||||
add_pygcode_to_path()
|
||||
|
||||
|
||||
# String Utilities
|
||||
def str_lines(text):
|
||||
"""Split given string into lines (ignore blank lines, and automagically strip)"""
|
||||
for match in re.finditer(r'\s*(?P<content>.*?)\s*\n', text):
|
||||
yield match.group('content')
|
Loading…
x
Reference in New Issue
Block a user