#-----------------------------------------------------------------------------
# Copyright 2012-2016 Claude Zervas
# email: claude@utlco.com
#-----------------------------------------------------------------------------
"""
A G-code generator that is suitable for a four axis (or 3.5 axis)
machine with X, Y, and Z axes along with an angular A
axis that rotates about the Z axis. It is more general but
that's the machine I have and the code might reflect that.
The generated G code is currently intended for a LinuxCNC
interpreter, but probably works fine for others as well.
====
"""
# Python 3 compatibility boilerplate
from __future__ import (absolute_import, division,
print_function, unicode_literals)
from future_builtins import *
import math
import datetime
import logging
import gettext
_ = gettext.gettext
[docs]class PreviewPlotter(object):
"""Base interface that can be implemented by users of the GCode
class to provide a graphical preview of the G-code output.
See :py:mod:`cam.gcodesvg` for an example of an SVG implemention.
"""
def __init__(self):
# An instance of GCode. Set by GCodeGenerator.
self.gcgen = None
[docs] def plot_move(self, endp):
"""Plot G00 - rapid move from current tool location to to ``endp``.
Args:
endp: Endpoint of move as a 4-tuple (x, y, z, a).
"""
pass
[docs] def plot_feed(self, endp):
"""Plot G01 - linear feed from current tool location to ``endp``.
Args:
endp: Endpoint of feed as a 4-tuple (x, y, z, a).
"""
pass
[docs] def plot_arc(self, center, endp, clockwise):
"""Plot G02/G03 - arc feed from current tool location to to ``endp``.
Args:
center: Center of arc as a 2-tuple (x, y)
endp: Endpoint of feed as a 4-tuple (x, y, z, a).
clockwise: True if the arc moves in a clockwise direction.
"""
pass
[docs]class GCodeException(Exception):
"""Exception raised by gcode generator."""
pass
[docs]class GCodeGenerator(object):
"""GCode generator class that describes a basic two axis (XY),
three axis (XYZ), or four axis (XYZA)
machine. The G code output is compatible with LinuxCNC.
Angles are always specified in radians but output as degrees.
Axis values are always specified in user/world coordinates and output
as machine units (ie inches or millimeters) using ``GCode.unit_scale``
as the scaling factor.
"""
#: Current machine target
TARGET = 'linuxcnc'
# Target machine info - machine name, version
_TARGET_INFO = {'linuxcnc': ('LinuxCNC', '2.4+'), }
# Order in which G code parameters are specified in a line of G code
_GCODE_ORDERED_PARAMS = 'XYZUVWABCIJKRDHLPQSF'
# Non-modal G codes (LinuxCNC.)
_GCODE_NONMODAL_GROUP = ('G04', 'G10', 'G28', 'G30', 'G53', 'G92')
# G codes where a feed rate is required
_GCODE_FEED = ('G01', 'G02', 'G03')
# G codes that change the position of the tool
_GCODE_MOTION = ('G00', 'G01', 'G02', 'G03')
# G codes that are suppressed if the parameters remain unchanged
_GCODE_MODAL_MOTION = ('G00', 'G01', 'G02', 'G03')
# Default tolerance for floating point comparison
_DEFAULT_TOLERANCE = 1e-6
# Maximum reasonable precision
_MAX_PRECISION = 15
# Minimum reasonable precision
_MIN_PRECISION = 2
def __init__(self, xyfeed, zsafe, zfeed=None, afeed=None,
output=None, plotter=None):
"""
Args:
xyfeed: Default feed rate along X and Y axes,
in machine units per minute.
zsafe: The safe height of the Z axis for rapid XY moves.
zfeed: Feed rate along Z axis
in machine units per minute.
(Defaults to `xyfeed`.)
afeed: Feed rate along A axis in degrees per minute.
(Defaults to `xyfeed`.)
output: Output stream for generated G code.
Must implement ``write()`` method.
Defaults to a StringIO if None (default).
plotter: Preview plotter. Should be a subclass of
``gcode.PreviewPlotter``.
"""
#: Feed rate along X and Y axes
self.xyfeed = xyfeed
#: Z axis safe height for rapid moves
self.zsafe = zsafe
#: Z axis feed rate
self.zfeed = zfeed
#: Angular axis feed rate
self.afeed = afeed
#: Current line number
self.line_number = 0
#: The G code output stream
self.output = output
#: The current preview plotter
self.preview_plotter = plotter
#: User to machine unit scale
self.unit_scale = 1.0
#: Tolerance for float comparisons
self.tolerance = self._DEFAULT_TOLERANCE
#: Tolerance for angle comparisons
self.angle_tolerance = self._DEFAULT_TOLERANCE
#: Delay time in millis for tool-down
self.tool_wait_down = 0.0
#: Delay time in millis for tool-up
self.tool_wait_up = 0.0
#: Alternate G code for Tool Up
self.alt_tool_up = None
#: Alternate G code for Too Down
self.alt_tool_down = None
#: Default delay time in milliseconds after spindle is turned on.
self.spindle_wait_on = 0.0
#: Default delay time in milliseconds after spindle is shut off.
self.spindle_wait_off = 0.0
#: Spindle direction flag
self.spindle_clockwise = True
#: Default spindle speed
self.spindle_speed = 0.0
#: Turn spindle on/off automatically on tool_up/tool_down
self.spindle_auto = False
#: Angles < 360 ?
self.wrap_angles = False
#: Show comments if True
self.show_comments = True
#: Show line numbers if True
self.show_line_numbers = False
#: Extra header comments
self.header_comments = []
#: Blend mode. Can be None, 'blend', or 'exact'.
# See <http://linuxcnc.org/docs/2.4/html/common_User_Concepts.html#r1_1_1>
self.blend_mode = None
#: Blend tolerance. P value for G64 blend directive.
self.blend_tolerance = None
#: Naive cam detector tolerance value. Q value for G64 blend directive.
self.blend_qtolerance = None
#: Output code comments
self.verbose = False
if self.zfeed is None:
self.zfeed = self.xyfeed
if self.afeed is None:
self.afeed = self.xyfeed
if self.output is None:
import StringIO
self.output = StringIO.StringIO()
if self.preview_plotter is None:
self.preview_plotter = PreviewPlotter()
# Pass this instance along to the preview plotter
if self.preview_plotter is not None:
self.preview_plotter.gcgen = self
# GCode output units
self.gc_unit = 'in'
# Last value for G code parameters
self._last_val = {} #{'X': 0.0, 'Y': 0.0, 'Z': 0.0, 'A': 0.0, 'F': 0.0}
for param in self._GCODE_ORDERED_PARAMS:
self._last_val[param] = None
# True if the tool is above the Z axis safe height for rapid moves
self._is_tool_up = False
# Default epsilon and output precision
self.set_tolerance(self._DEFAULT_TOLERANCE)
precision = int(round(abs(math.log(self._DEFAULT_TOLERANCE, 10))))
self.set_output_precision(precision)
# Axis scale factors
self._axis_scale = {}
# Axis offsets
self._axis_offset = {}
# Map canonical axis names to G code output names.
# Values can be changed to accomodate machines that expect
# different axis names (ie. using C instead of A or UVW instead of XYZ)
self._axis_map = {}
# Flag set when the axis angle has been normalized
self._axis_offset_reset = False
@property
def units(self):
"""GCode output units. Can be 'in' or 'mm'."""
return self.gc_unit
@units.setter
def units(self, value):
if not value in ('in', 'mm'):
raise GCodeException('Units must be "in" or "mm".')
self.gc_unit = value
@property
def X(self):
"""The current X axis value or none if unknown."""
return self._last_val['X']
@property
def Y(self):
"""The current Y axis value or none if unknown."""
return self._last_val['Y']
@property
def Z(self):
"""The current Z axis value or none if unknown."""
return self._last_val['Z']
@property
def A(self):
"""The current A axis value or none if unknown."""
return self._last_val['A']
[docs] def set_tolerance(self, tolerance, angle_tolerance=None):
"""Set tolerance (epsilon) for floating point comparisons.
Args:
tolerance: The tolerance for scalar floating point comparisons
except angular values.
angle_tolerance: The tolerance for comparing angle values. Set to
``tolerance`` if None (default).
"""
self.tolerance = tolerance
if angle_tolerance is None:
angle_tolerance = tolerance
self.angle_tolerance = angle_tolerance
[docs] def set_output_precision(self, precision):
"""Set numeric output precision. This determines the
number of digits after the decimal point.
This can be different from the precision implied
by the `tolerance` value. The default is derived
from the `tolerance` value.
Args:
precision: The number of digits after the decimal point.
"""
ndigits = max(min(int(precision),
self._MAX_PRECISION), self._MIN_PRECISION)
self._fmt_float = '%%.%df' % ndigits
[docs] def set_units(self, units, unit_scale=1.0):
"""Set G code units and unit scale factor.
Note:
Linear axis values are specified in user/world coordinates
and output as machine units (ie inches or millimeters)
using ``unit_scale`` as the scaling factor to scale from
user/world units to G-code units.
Args:
units: Unit specifier. Must be `'in'` or `'mm'`.
unit_scale: Scale factor to apply to linear axis values.
Default is 1.0.
"""
if units not in ('in', 'mm'):
raise GCodeException(_('Units must be mm or in.'))
self.units = units
self.unit_scale = unit_scale
[docs] def set_spindle_defaults(self, speed, clockwise=True,
wait_on=0, wait_off=0, auto=False):
"""Set spindle parameter defaults.
Args:
speed: Spindle speed in RPM
clockwise: Spindle direction. True if clockwise (default).
wait_on: Number of milliseconds to wait for the spindle to reach
full speed.
wait_off: the number of milliseconds to wait for the spindle
to stop.
auto: Turn on/off spindle automatically on
:py:meth:`tool_up()`/:py:meth:`tool_down()`.
Default is False.
"""
self.spindle_speed = speed
self.spindle_clockwise = clockwise
self.spindle_wait_on = wait_on
self.spindle_wait_off = wait_off
self.spindle_auto = auto
[docs] def set_path_blending(self, mode, tolerance=None, qtolerance=None):
"""Set path trajectory blending mode and optional tolerance.
Args:
mode: Path blending mode. Can be 'exact' or 'blend'.
Uses *G64* in 'blend' mode and *G61* in 'exact' mode.
tolerance: Blending tolerance. Only used in 'blend' mode.
This is the value for the G64 *P* parameter.
Default is None.
qtolerance: Naive cam detector tolerance value.
This is the value for the G64 *Q* parameter.
Default is None.
"""
if mode.upper() == 'G61':
mode = 'exact'
elif mode.upper() == 'G64':
mode = 'blend'
if mode in ('exact', 'blend'):
self.blend_mode = mode
self.blend_tolerance = tolerance
self.blend_qtolerance = qtolerance
# ignore anything else...
[docs] def set_axis_offset(self, **kwargs):
"""Set the offset for the specified axes.
Axis offsets are always specified in *machine units*.
Angular offsets are always in *degrees*.
This is a 'soft' offset, not a G92 offset. The offset
value will be added to the current axis value when
a move is performed.
Example::
gcode_gen = gcode.GCodeGenerator(...)
gcode_gen.set_axis_offset(x=10, y=10)
Args:
x: X axis offset value (optional)
y: Y axis offset value (optional)
z: Z axis offset value (optional)
a: A axis offset value (optional)
"""
for axis in kwargs:
self._axis_offset[axis.upper()] = float(kwargs.get(axis))
[docs] def set_axis_scale(self, **kwargs):
"""Set the scaling factors for the specified axes.
The scaling is applied before the world/machine unit scaling.
Example::
gcode_gen = gcode.GCodeGenerator(...)
gcode_gen.set_axis_scale(x=10, y=10)
Args:
x: X axis scale value (optional)
y: Y axis scale value (optional)
z: Z axis scale value (optional)
a: A axis scale value (optional)
"""
for axis in kwargs:
self._axis_scale[axis.upper()] = float(kwargs.get(axis))
[docs] def map_axis(self, canonical_name, output_name):
"""Map canonical axis names to G code output names.
Mapping can be used to accommodate machines that expect
different axis names (ie. using C instead of A or UVW instead of XYZ).
Args:
canonical_name: Canonical axis name.
(ie 'X', 'Y', 'Z', or 'A')
output_name: Output name.
(ie 'U', 'V', 'W', or 'C')
"""
self._axis_map[canonical_name.upper()] = output_name.upper()
[docs] def feed_rate(self, feed_rate):
"""Set the specified feed rate. Outputs the *F* G code directive
if the feed rate has changed since the last feed value.
Args:
feed_rate: The feed rate in machine units per minute.
"""
if self._last_val['F'] is None \
or not self.float_eq(feed_rate - self._last_val['F']):
self._write_line('F%s' % (self._fmt_float % feed_rate))
self._last_val['F'] = feed_rate
[docs] def pause(self, conditional=False, comment='Pause'):
"""Pause the G code interpreter.
Outputs *M1* or *M0* G code.
Note:
Normally, pressing the start button in LinuxCNC/Axis
will restart the interpreter after a pause.
Args:
conditional: use conditional stop if True.
comment: Optional comment string.
"""
mcode = 'M1' if conditional else 'M0'
self.gcode_command(mcode, comment=comment)
[docs] def dwell(self, milliseconds, comment=None):
"""Output a dwell command which pauses the tool for the specified
number of milliseconds.
Args:
milliseconds: Number of milliseconds to pause.
comment: Optional comment string.
"""
# LinuxCNC interprets P as seconds whereas pretty much everything else
# (ie Fanuc) interprets the parameter as milliseconds...
if milliseconds > 0:
if self.TARGET == 'linuxcnc':
seconds = milliseconds / 1000.0
if comment is None:
comment = _('Pause tool for %.4f seconds') % seconds
self._write_line('G04 P%.4f' % seconds, comment=comment)
else:
if comment is None:
comment = _('Pause tool for %d milliseconds') % milliseconds
self._write_line('G04 P%d' % milliseconds, comment=comment)
[docs] def spindle_on(self, speed=None, clockwise=None, wait=None, comment=None):
"""Turn on the spindle.
Args:
speed: Spindle speed in RPM.
If None use default speed.
clockwise: Spindle turns clockwise if True.
If None use default value.
wait: Number of milliseconds to wait for the spindle to reach
full speed. Uses ``GCode.spindle_wait_on`` value by default.
comment: Optional comment string.
"""
if speed is None:
speed = self.spindle_speed
if clockwise is None:
clockwise = self.spindle_clockwise
if wait is None:
wait = self.spindle_wait_on
mcode = 'M3' if clockwise else 'M4'
self._write_line('%s S%d' % (mcode, int(speed)), comment=comment)
if wait > 0.0:
self.dwell(wait)
[docs] def spindle_off(self, wait=None, comment=None):
"""Turn off the spindle.
Args:
wait: the number of milliseconds to wait for the spindle
to stop. Uses ``GCode.spindle_wait_off`` value by default.
comment: Optional comment string.
"""
self._write_line('M5', comment=comment)
if wait is None:
wait = self.spindle_wait_off
if wait > 0.0:
self.dwell(wait)
[docs] def normalize_axis_angle(self, axis='A'):
"""Unwrap (normalize) a rotational axis.
If the current angular position of the axis is > 360 this will
reset the rotary axis origin so that 0 < angle < 360.
Useful when cutting large spirals with a tangent knife to minimize
long unwinding moves between paths.
Args:
axis: Name of axis to unwrap. Default is 'A'.
"""
axis = axis.upper()
if axis not in 'ABC':
raise GCodeException(_('Can only normalize a rotational axis.'))
angle = self._last_val[axis]
if abs(angle) > 2 * math.pi:
# normalize the angle
angle = angle - 2 * math.pi * math.floor(angle / 2 * math.pi)
val = self._fmt_float % math.degrees(angle)
self._write_line('G92 %s=%s' % (axis, val),
comment=_('Normalize axis angle'))
self._last_val[axis] = angle
self._axis_offset_reset = True
[docs] def rapid_move(self, x=None, y=None, z=None, a=None, comment=None):
"""Perform a rapid *G0* move to the specified location.
At least one axis should be specified.
If the tool is below the safe 'Z' height it will be raised before
the rapid move is performed.
Args:
x: X axis value (optional)
y: Y axis value (optional)
z: Z axis value (optional)
a: A axis value (optional)
comment: Optional comment string.
"""
# Make sure the tool is at a safe height for a rapid move.
z_position = self.position('Z')
if z_position is None or z_position < self.zsafe or self._is_tool_up:
self.tool_up()
z = max(self.zsafe, z)
self.gcode_command('G00', X=x, Y=y, Z=z, A=a, comment=comment)
self.preview_plotter.plot_move(self._endp(x, y, z, a))
[docs] def feed(self, x=None, y=None, z=None, a=None, feed=None, comment=None):
"""Perform a *G1* linear tool feed to the specified location.
At least one axis should be specified.
Args:
x: X axis value (optional)
y: Y axis value (optional)
z: Z axis value (optional)
a: A axis value (optional)
feed: Feed rate (optional - default feed rate used if None)
comment: Optional comment string.
"""
# Determine default feed rate appropriate for the move
if feed is None:
if x is not None or y is not None:
feed = self.xyfeed
elif z is not None:
feed = self.zfeed
elif a is not None:
feed = self.afeed
else:
# No feed rate, no axis specified - nothing to feed
return
self.gcode_command('G01', X=x, Y=y, Z=z, A=a, F=feed, comment=comment)
self.preview_plotter.plot_feed(self._endp(x, y, z, a))
[docs] def feed_arc(self, clockwise, x, y, arc_x, arc_y, a=None, z=None,
feed=None, comment=None):
"""Perform a *G2*/*G3* arc feed.
This will raise a GCodeException if the beginning and ending arc radii
do not match, ie if one of the end points does not lie on the arc.
Args:
clockwise: True if the arc moves in a clockwise direction.
x: X value of arc end point
y: Y value of arc end point
arc_x: Center of arc relative to ``x``
arc_y: Center of arc relative to ``y``
a: A axis value at endpoint (in radians)
feed: Feed rate (optional - default feed rate used if None)
comment: Optional comment string.
"""
# Make sure that both the start and end points lie on the arc.
current_xy = self.get_current_position_xy()
center = (arc_x + current_xy[0], arc_y + current_xy[1])
# Distance from center to current position
start_radius = math.hypot(current_xy[0] - center[0],
current_xy[1] - center[1])
end_radius = math.hypot(arc_x, arc_y)
if not self.float_eq(start_radius, end_radius):
logger = logging.getLogger(__name__)
logger.debug('Degenerate arc:')
logger.debug(' start point = (%f, %f), end point = (%f, %f)',
current_xy[0], current_xy[1], x, y)
logger.debug(' start radius = %f, end radius = %f',
start_radius, end_radius)
raise GCodeException('Mismatching arc radii.')
gcode = 'G02' if clockwise else 'G03'
self.gcode_command(gcode, X=x, Y=y, Z=z, I=arc_x, J=arc_y, A=a,
F=(feed if feed is not None else self.xyfeed),
force_value='IJ',
comment=comment)
self.preview_plotter.plot_arc(center, self._endp(x, y, z, a), clockwise)
[docs] def get_current_position_xy(self):
"""The last known tool position on the XY plane.
Returns:
A 2-tuple containing coordinates of X and Y axes
of the form (X, Y). An axis value will be
None if the position is unknown.
"""
return (self._last_val['X'], self._last_val['Y'])
[docs] def get_current_position(self):
"""The last known tool position.
Returns:
A 4-tuple containing coordinates of all four axes
of the form (X, Y, Z, A). An axis value will be
None if the position is unknown.
"""
return (self._last_val['X'], self._last_val['Y'],
self._last_val['Z'], self._last_val['A'],)
[docs] def position(self, axis):
"""The current position of the specified axis.
Args:
axis: The axis name - i.e. 'X', 'Y', 'Z', 'A', etc.
Returns:
The current position of the named axis as a float value.
"""
axis = axis.upper()
if axis not in self._last_val:
raise GCodeException('Undefined axis %s' % axis)
return self._last_val[axis]
[docs] def gcode_command(self, command, **kwargs):
"""Output a line of gcode.
This is mainly for internal use and should be
used with extreme caution.
Use the higher level methods if at all possible.
Args:
command: G code command. Required.
params: Parameter string that will be output as is.
This `must not` be used with commands that
that may change the position of the machine. Optional.
X: The X axis value. Optional.
Y: The Y axis value. Optional.
Z: The Z axis value. Optional.
I: Center (x) of arc relative to X,Y. Optional.
J: Center (y) of arc relative to X,Y. Optional.
R: Arc radius. Optional.
A: The A axis value in radians. Optional.
force_value: A string containing the modal parameter names
whose values will be output regardless of whether their
values have changed.
By default if the specified value of a modal parameter has not
changed since its last value then it will not be output.
comment: Optional inline comment string.
Raises:
GCodeException
"""
if command is None or not command:
return
command = _canonical_cmd(command)
base_cmd = command.split('.')[0]
params = kwargs.get('params')
comment = kwargs.get('comment')
# Make sure motion can be tracked.
if command in self._GCODE_MOTION and params is not None and params:
raise GCodeException('Motion command with opaque parameters.')
pos_params = dict()
force_value = kwargs.get('force_value', '')
# Extract machine position parameters and update
# internal position coordinates.
for k in ('X', 'Y', 'Z', 'I', 'J', 'R', 'A', 'F'):
value = kwargs.get(k)
if value is not None:
if k in 'ABC':
# Use angle tolerance for comparing angle values
tolerance = self.angle_tolerance
if self.wrap_angles:
value = math.fmod(value, 2 * math.pi)
else:
# Otherwise use float tolerance
tolerance = self.tolerance
value_has_changed = (self._last_val[k] is None or
abs(value - self._last_val[k]) > tolerance)
gcode_is_nonmodal = base_cmd in self._GCODE_NONMODAL_GROUP
if k in force_value or value_has_changed or gcode_is_nonmodal:
self._last_val[k] = value
# Apply any axis transforms
value *= self._axis_scale.get(k, 1.0)
value += self._axis_offset.get(k, 0.0)
if k in 'ABC':
value = math.degrees(value)
elif k in 'XYZIJ':
# Tool height safety check
if k == 'Z' and self._is_tool_up and value < self.zsafe:
self._is_tool_up = False
# Apply unit scale (user/world to machine units)
value *= self.unit_scale
pos_params[k] = value
gcode_line = None
if len(pos_params) > 0:
# Suppress redundant feedrate-only lines
if len(pos_params) > 1 or 'F' not in pos_params:
line_parts = [command, ]
# Arrange the parameters in a readable order
for k in self._GCODE_ORDERED_PARAMS:
value = pos_params.get(k)
if value is not None:
k = self._axis_map.get(k, k)
line_parts.append('%s%s' % (k, self._fmt_float % value))
gcode_line = ' '.join(line_parts)
# Note: this check will suppress output of modal commands
# with unchanged parameter values.
elif base_cmd not in self._GCODE_MODAL_MOTION:
gcode_line = command
if params is not None and params:
gcode_line += ' ' + params
if gcode_line is not None:
self._write_line(gcode_line, comment=comment)
def _write_line(self, line='', comment=None):
"""Write a (optionally numbered) line to the G code output.
A newline character is always appended, even if the string is empty.
Empty lines and comment-only lines are not numbered.
"""
if line is not None and line:
if self.show_line_numbers:
self._write('N%d ' % self.line_number)
self.line_number += 1
self._write(line)
if self.show_comments and comment:
if line is not None and line:
self._write(' ')
self._write('(%s)' % comment)
self._write('\n')
def _write_comment(self, comment, use_semi):
"""Write a comment string and newline to the gcode output stream."""
if comment is None:
self._write('\n')
elif not comment:
self._write(';\n')
else:
if use_semi:
self._write('; %s\n' % comment)
else:
self._write('(%s)\n' % comment)
def _write(self, text):
"""Write the string to the gcode output stream."""
self.output.write(text)
def _endp(self, x, y, z, a):
"""Return the end point of the current trajectory.
Used for preview plotting."""
return (x if x is not None else self._last_val['X'],
y if y is not None else self._last_val['Y'],
z if z is not None else self._last_val['Z'],
a if a is not None else self._last_val['A'])
[docs] def float_eq(self, a, b):
"""Compare two floats for approximate equality within the tolerance
specified for the GCodeGenerator class.
"""
return abs(a - b) < self.tolerance
def _canonical_cmd(cmd):
"""Canonicalize a G code command.
Converts to upper case and expands shorthand (ie. G1 to G01)."""
cmd = cmd.upper()
if len(cmd) == 2 and cmd[1].isdigit():
cmd = cmd[0] + '0' + cmd[1]
return cmd