This repository has been archived on 2023-03-25. You can view files and clone it, but cannot push or open issues or pull requests.
mightyscape-0.92-deprecated/extensions/fablabchemnitz_thunderlaser.py

1941 lines
78 KiB
Python
Raw Normal View History

2019-11-14 20:05:10 +01:00
#! /usr/bin/python3
#
# thunderlaser.py -- driver for exporting an SVG drawing as an RDCAM file for a Thunderlaser RUIDA machine.
#
# This is an Inkscape extension to output paths in rdcam format.
# recursivelyTraverseSvg() is originally from eggbot. Thank!
# inkscape-paths2openscad and inkscape-silhouette contain copies of recursivelyTraverseSvg()
# with almost identical features, but different inmplementation details. The version used here is derived from
# inkscape-paths2openscad.
#
# 1.5a - cut/mark color filtering implemented via colorname2rgb() and svg.matchStrokeColor().
# Works with dummy device. TODO: ruida output using Layers.
# 1.5b - using class Ruida through the new layer interface.
# TODO: ruida output using multiple layers, currently only layer 0 is used.
# 1.5c - _removed _before _tags and _attributes in *.inx, to disable false automatic translations.
# That does not seem to work. Strings are still subject to automatic translations.
# Replaced all empty gui-text="" with repetitive noise, to avoid 0.91
# translating "" into a 16 lines boiler plate text.
# 1.6 - juergen@fabmail.org
# multi layer support added. Can now mark and cut in one job.
# 1.6b - bugfix release: [ ] bbox, [ ] move only, did always cut.
# Updated InkSvg() class preserves native order of SVG elements.
# 1.7 - Updated InkSvg() class to use inline style defs by class name, tag or id.
# 1.7a - Survive SVG with comments.
# 1.7b - allow empty path_lists if one of the colors is 'any'.
# 1.8 - Support bodor laser.
#
# python2 compatibility:
from __future__ import print_function
import sys
sys_platform = sys.platform.lower()
if sys_platform.startswith('win'):
sys.path.append('C:\Program Files\Inkscape\share\extensions')
elif sys_platform.startswith('darwin'):
sys.path.append('~/.config/inkscape/extensions')
else: # Linux
sys.path.append('/usr/share/inkscape/extensions/')
#! /usr/bin/python
#
# inksvg.py - parse an svg file into a plain list of paths.
#
# (C) 2017 juergen@fabmail.org, authors of eggbot and others.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
#################
# 2017-12-04 jw, v1.0 Refactored class InkSvg from cookiecutter extension
# 2017-12-07 jw, v1.1 Added roundedRectBezier()
# 2017-12-10 jw, v1.3 Added styleDasharray() with stroke-dashoffset
# 2017-12-14 jw, v1.4 Added matchStrokeColor()
# 2017-12-21 jw, v1.5 Changed getPathVertices() to construct a to self.paths list, instead of
# a dictionary. (Preserving native ordering)
# 2017-12-22 jw, v1.6 fixed "use" to avoid errors with unknown global symbal 'composeTransform'
# 2017-12-25 jw, v1.7 Added getNodeStyle(), cssDictAdd(), expanded matchStrokeColor() to use
# inline style defs. Added a warning message for not-implemented CSS styles.
# v1.7a Added getNodeStyleOne() made getNodeStyle() recurse through parents.
import inkex
import simplepath
import simplestyle
import simpletransform
import cubicsuperpath
import cspsubdiv
import bezmisc
import gettext
import re
class InkSvg():
"""
"""
__version__ = "1.7a"
DEFAULT_WIDTH = 100
DEFAULT_HEIGHT = 100
def getNodeStyleOne(self, node):
"""
Finds style declarations by .class, #id or by tag.class syntax,
and of course by a direct style='...' attribute.
"""
sheet = ''
selectors = []
classes = node.get('class', '') # classes == None can happen here.
if classes is not None and classes != '':
selectors = ["."+cls for cls in re.split('[\s,]+', classes)]
selectors += [node.tag+sel for sel in selectors]
node_id = node.get('id', '')
if node_id is not None and node_id != '':
selectors += [ "#"+node_id ]
for sel in selectors:
if sel in self.css_dict:
sheet += '; '+self.css_dict[sel]
style = node.get('style', '')
if style is not None and style != '':
sheet += '; '+style
return simplestyle.parseStyle(sheet)
def getNodeStyle(self, node):
"""
Recurse into parent group nodes, like simpletransform.ComposeParents
Calling getNodeStyleOne() for each.
"""
combined_style = {}
parent = node.getparent()
if parent.tag == inkex.addNS('g','svg') or parent.tag == 'g':
combined_style = self.getNodeStyle(parent)
style = self.getNodeStyleOne(node)
for s in style:
combined_style[s] = style[s] # overwrite or add
return combined_style
def styleDasharray(self, path_d, node):
"""
Check the style of node for a stroke-dasharray, and apply it to the
path d returning the result. d is returned unchanged, if no
stroke-dasharray was found.
## Extracted from inkscape extension convert2dashes; original
## comments below.
## Added stroke-dashoffset handling, made it a universal operator
## on nodes and 'd' paths.
This extension converts a path into a dashed line using 'stroke-dasharray'
It is a modification of the file addnodes.py
Copyright (C) 2005,2007 Aaron Spike, aaron@ekips.org
Copyright (C) 2009 Alvin Penner, penner@vaxxine.com
"""
def tpoint((x1,y1), (x2,y2), t = 0.5):
return [x1+t*(x2-x1),y1+t*(y2-y1)]
def cspbezsplit(sp1, sp2, t = 0.5):
m1=tpoint(sp1[1],sp1[2],t)
m2=tpoint(sp1[2],sp2[0],t)
m3=tpoint(sp2[0],sp2[1],t)
m4=tpoint(m1,m2,t)
m5=tpoint(m2,m3,t)
m=tpoint(m4,m5,t)
return [[sp1[0][:],sp1[1][:],m1], [m4,m,m5], [m3,sp2[1][:],sp2[2][:]]]
def cspbezsplitatlength(sp1, sp2, l = 0.5, tolerance = 0.001):
bez = (sp1[1][:],sp1[2][:],sp2[0][:],sp2[1][:])
t = bezmisc.beziertatlength(bez, l, tolerance)
return cspbezsplit(sp1, sp2, t)
def cspseglength(sp1,sp2, tolerance = 0.001):
bez = (sp1[1][:],sp1[2][:],sp2[0][:],sp2[1][:])
return bezmisc.bezierlength(bez, tolerance)
style = self.getNodeStyle(node)
if not style.has_key('stroke-dasharray'):
return path_d
dashes = []
if style['stroke-dasharray'].find(',') > 0:
dashes = [float (dash) for dash in style['stroke-dasharray'].split(',') if dash]
if not dashes:
return path_d
dashoffset = 0.0
if style.has_key('stroke-dashoffset'):
dashoffset = float(style['stroke-dashoffset'])
if dashoffset < 0.0: dashoffset = 0.0
if dashoffset > dashes[0]: dashoffset = dashes[0] # avoids a busy-loop below!
p = cubicsuperpath.parsePath(path_d)
new = []
for sub in p:
idash = 0
dash = dashes[0]
# print("initial dash length: ", dash, dashoffset)
dash = dash - dashoffset
length = 0
new.append([sub[0][:]])
i = 1
while i < len(sub):
dash = dash - length
length = cspseglength(new[-1][-1], sub[i])
while dash < length:
new[-1][-1], next, sub[i] = cspbezsplitatlength(new[-1][-1], sub[i], dash/length)
if idash % 2: # create a gap
new.append([next[:]])
else: # splice the curve
new[-1].append(next[:])
length = length - dash
idash = (idash + 1) % len(dashes)
dash = dashes[idash]
if idash % 2:
new.append([sub[i]])
else:
new[-1].append(sub[i])
i+=1
return cubicsuperpath.formatPath(new)
def matchStrokeColor(self, node, rgb, eps=None, avg=True):
"""
Return True if the line color found in the style attribute of elem
does not differ from rgb in any of the components more than eps.
The default eps with avg=True is 64.
With avg=False the default is eps=85 (33% on a 0..255 scale).
In avg mode, the average of all three color channel differences is
compared against eps. Otherwise each color channel difference is
compared individually.
The special cases None, False, True for rgb are interpreted logically.
Otherwise rgb is expected as a list of three integers in 0..255 range.
Missing style attribute or no stroke element is interpreted as False.
Unparseable stroke elements are interpreted as 'black' (0,0,0).
Hexadecimal stroke formats of '#RRGGBB' or '#RGB' are understood
as well as 'rgb(100%, 0%, 0%) or 'red' relying on simplestyle.
"""
if eps is None:
eps = 64 if avg == True else 85
if rgb is None or rgb is False: return False
if rgb is True: return True
style = self.getNodeStyle(node)
s = style.get('stroke', '')
if s == '': return False
c = simplestyle.parseColor(s)
if sum:
s = abs(rgb[0]-c[0]) + abs(rgb[1]-c[1]) + abs(rgb[2]-c[2])
if s < 3*eps:
return True
return False
if abs(rgb[0]-c[0]) > eps: return False
if abs(rgb[1]-c[1]) > eps: return False
if abs(rgb[2]-c[2]) > eps: return False
return True
def cssDictAdd(self, text):
"""
Represent css cdata as a hash in css_dict.
Implements what is seen on: http://www.blooberry.com/indexdot/css/examples/cssembedded.htm
"""
text=re.sub('^\s*(<!--)?\s*', '', text)
while True:
try:
(keys, rest) = text.split('{', 1)
except:
break
keys = re.sub('/\*.*?\*/', ' ', keys) # replace comments with whitespace
keys = re.split('[\s,]+', keys) # convert to list
while '' in keys:
keys.remove('') # remove empty elements (at start or end)
(val,text) = rest.split('}', 1)
val = re.sub('/\*.*?\*/', '', val) # replace comments nothing in values
val = re.sub('\s+', ' ', val).strip() # normalize whitespace
for k in keys:
if not k in self.css_dict:
self.css_dict[k] = val
else:
self.css_dict[k] += '; '+val
def roundedRectBezier(self, x, y, w, h, rx, ry=0):
"""
Draw a rectangle of size w x h, at start point x, y with the corners rounded by radius
rx and ry. Each corner is a quarter of an ellipsis, where rx and ry are the horizontal
and vertical dimenstion.
A pathspec according to https://www.w3.org/TR/SVG/paths.html#PathDataEllipticalArcCommands
is returned. Very similar to what inkscape would do when converting object to path.
Inkscape seems to use a kappa value of 0.553, higher precision is used here.
x=0, y=0, w=200, h=100, rx=50, ry=30 produces in inkscape
d="m 50,0 h 100 c 27.7,0 50,13.38 50,30 v 40 c 0,16.62 -22.3,30 -50,30
H 50 C 22.3,100 0,86.62 0,70 V 30 C 0,13.38 22.3,0 50,0 Z"
It is unclear, why there is a Z, the last point is identical with the first already.
It is unclear, why half of the commands use relative and half use absolute coordinates.
We do it all in relative coords, except for the initial M, and we ommit the Z.
"""
if rx < 0: rx = 0
if rx > 0.5*w: rx = 0.5*w
if ry < 0: ry = 0
if ry > 0.5*h: ry = 0.5*h
if ry < 0.0000001: ry = rx
k = 0.5522847498307933984022516322796 # kappa, handle length for a 4-point-circle.
d = "M %f,%f h %f " % (x+rx, y, w-rx-rx) # top horizontal to right
d += "c %f,%f %f,%f %f,%f " % (rx*k,0, rx,ry*(1-k), rx,ry) # top right ellipse
d += "v %f " % (h-ry-ry) # right vertical down
d += "c %f,%f %f,%f %f,%f " % (0,ry*k, rx*(k-1),ry, -rx,ry) # bottom right ellipse
d += "h %f " % (-w+rx+rx) # bottom horizontal to left
d += "c %f,%f %f,%f %f,%f " % (-rx*k,0, -rx,ry*(k-1), -rx,-ry) # bottom left ellipse
d += "v %f " % (-h+ry+ry) # left vertical up
d += "c %f,%f %f,%f %f,%f" % (0,-ry*k, rx*(1-k),-ry, rx,-ry) # top left ellipse
return d
def subdivideCubicPath(self, sp, flat, i=1):
'''
[ Lifted from eggbot.py with impunity ]
Break up a bezier curve into smaller curves, each of which
is approximately a straight line within a given tolerance
(the "smoothness" defined by [flat]).
This is a modified version of cspsubdiv.cspsubdiv(): rewritten
because recursion-depth errors on complicated line segments
could occur with cspsubdiv.cspsubdiv().
'''
while True:
while True:
if i >= len(sp):
return
p0 = sp[i - 1][1]
p1 = sp[i - 1][2]
p2 = sp[i][0]
p3 = sp[i][1]
b = (p0, p1, p2, p3)
if cspsubdiv.maxdist(b) > flat:
break
i += 1
one, two = bezmisc.beziersplitatt(b, 0.5)
sp[i - 1][2] = one[1]
sp[i][0] = two[2]
p = [one[2], one[3], two[1]]
sp[i:1] = [p]
def parseLengthWithUnits(self, str, default_unit='px'):
'''
Parse an SVG value which may or may not have units attached
This version is greatly simplified in that it only allows: no units,
units of px, and units of %. Everything else, it returns None for.
There is a more general routine to consider in scour.py if more
generality is ever needed.
With inkscape 0.91 we need other units too: e.g. svg:width="400mm"
'''
u = default_unit
s = str.strip()
if s[-2:] in ('px', 'pt', 'pc', 'mm', 'cm', 'in', 'ft'):
u = s[-2:]
s = s[:-2]
elif s[-1:] in ('m', '%'):
u = s[-1:]
s = s[:-1]
try:
v = float(s)
except:
return None, None
return v, u
def __init__(self, document, smoothness=0.0):
self.dpi = 90.0 # factored out for inkscape-0.92
self.px_used = False # raw px unit depends on correct dpi.
self.xmin, self.xmax = (1.0E70, -1.0E70)
self.ymin, self.ymax = (1.0E70, -1.0E70)
self.document = document # from inkex.Effect.parse()
self.smoothness = smoothness # 0.0001 .. 5.0
# List of paths we will construct. Path lists are paired with the SVG node
# they came from. Such pairing can be useful when you actually want
# to go back and update the SVG document, or retrieve e.g. style information.
self.paths = []
# cssDictAdd collects style definitions here:
self.css_dict = {}
# For handling an SVG viewbox attribute, we will need to know the
# values of the document's <svg> width and height attributes as well
# as establishing a transform from the viewbox to the display.
self.docWidth = float(self.DEFAULT_WIDTH)
self.docHeight = float(self.DEFAULT_HEIGHT)
self.docTransform = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
# Dictionary of warnings issued. This to prevent from warning
# multiple times about the same problem
self.warnings = {}
def getLength(self, name, default):
'''
Get the <svg> attribute with name "name" and default value "default"
Parse the attribute into a value and associated units. Then, accept
units of cm, ft, in, m, mm, pc, or pt. Convert to pixels.
Note that SVG defines 90 px = 1 in = 25.4 mm.
Note: Since inkscape 0.92 we use the CSS standard of 96 px = 1 in.
'''
str = self.document.getroot().get(name)
if str:
return self.LengthWithUnit(str)
else:
# No width specified; assume the default value
return float(default)
def LengthWithUnit(self, strn, default_unit='px'):
v, u = self.parseLengthWithUnits(strn, default_unit)
if v is None:
# Couldn't parse the value
return None
elif (u == 'mm'):
return float(v) * (self.dpi / 25.4)
elif (u == 'cm'):
return float(v) * (self.dpi * 10.0 / 25.4)
elif (u == 'm'):
return float(v) * (self.dpi * 1000.0 / 25.4)
elif (u == 'in'):
return float(v) * self.dpi
elif (u == 'ft'):
return float(v) * 12.0 * self.dpi
elif (u == 'pt'):
# Use modern "Postscript" points of 72 pt = 1 in instead
# of the traditional 72.27 pt = 1 in
return float(v) * (self.dpi / 72.0)
elif (u == 'pc'):
return float(v) * (self.dpi / 6.0)
elif (u == 'px'):
self.px_used = True
return float(v)
else:
# Unsupported units
return None
def getDocProps(self):
'''
Get the document's height and width attributes from the <svg> tag.
Use a default value in case the property is not present or is
expressed in units of percentages.
'''
inkscape_version = self.document.getroot().get(
"{http://www.inkscape.org/namespaces/inkscape}version")
sodipodi_docname = self.document.getroot().get(
"{http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd}docname")
if sodipodi_docname is None:
sodipodi_docname = "inkscape"
self.basename = re.sub(r"\.SVG", "", sodipodi_docname, flags=re.I)
# a simple 'inkscape:version' does not work here. sigh....
#
# BUG:
# inkscape 0.92 uses 96 dpi, inkscape 0.91 uses 90 dpi.
# From inkscape 0.92 we receive an svg document that has
# both inkscape:version and sodipodi:docname if the document
# was ever saved before. If not, both elements are missing.
#
import lxml.etree
# inkex.errormsg(lxml.etree.tostring(self.document.getroot()))
if inkscape_version:
'''
inkscape:version="0.91 r"
inkscape:version="0.92.0 ..."
See also https://github.com/fablabnbg/paths2openscad/issues/1
'''
# inkex.errormsg("inkscape:version="+inkscape_version)
m = re.match(r"(\d+)\.(\d+)", inkscape_version)
if m:
if int(m.group(1)) > 0 or int(m.group(2)) > 91:
self.dpi = 96 # 96dpi since inkscape 0.92
# inkex.errormsg("switching to 96 dpi")
# BUGFIX https://github.com/fablabnbg/inkscape-paths2openscad/issues/1
# get height and width after dpi. This is needed for e.g. mm units.
self.docHeight = self.getLength('height', self.DEFAULT_HEIGHT)
self.docWidth = self.getLength('width', self.DEFAULT_WIDTH)
if (self.docHeight is None) or (self.docWidth is None):
return False
else:
return True
def handleViewBox(self):
'''
Set up the document-wide transform in the event that the document has
an SVG viewbox
'''
if self.getDocProps():
viewbox = self.document.getroot().get('viewBox')
if viewbox:
vinfo = viewbox.strip().replace(',', ' ').split(' ')
if (vinfo[2] != 0) and (vinfo[3] != 0):
sx = self.docWidth / float(vinfo[2])
sy = self.docHeight / float(vinfo[3])
self.docTransform = simpletransform.parseTransform('scale(%f,%f)' % (sx, sy))
def getPathVertices(self, path, node=None, transform=None):
'''
Decompose the path data from an SVG element into individual
subpaths, each subpath consisting of absolute move to and line
to coordinates. Place these coordinates into a list of polygon
vertices.
The result is appended to self.paths as a two-element tuple of the
form (node, path_list). This preserves the native ordering of
the SVG file as much as possible, while still making all attributes
if the node available when processing the path list.
'''
if (not path) or (len(path) == 0):
# Nothing to do
return None
if node is not None:
path = self.styleDasharray(path, node)
# parsePath() may raise an exception. This is okay
sp = simplepath.parsePath(path)
if (not sp) or (len(sp) == 0):
# Path must have been devoid of any real content
return None
# Get a cubic super path
p = cubicsuperpath.CubicSuperPath(sp)
if (not p) or (len(p) == 0):
# Probably never happens, but...
return None
if transform:
simpletransform.applyTransformToPath(transform, p)
# Now traverse the cubic super path
subpath_list = []
subpath_vertices = []
for sp in p:
# We've started a new subpath
# See if there is a prior subpath and whether we should keep it
if len(subpath_vertices):
subpath_list.append([subpath_vertices, [sp_xmin, sp_xmax, sp_ymin, sp_ymax]])
subpath_vertices = []
self.subdivideCubicPath(sp, float(self.smoothness))
# Note the first point of the subpath
first_point = sp[0][1]
subpath_vertices.append(first_point)
sp_xmin = first_point[0]
sp_xmax = first_point[0]
sp_ymin = first_point[1]
sp_ymax = first_point[1]
n = len(sp)
# Traverse each point of the subpath
for csp in sp[1:n]:
# Append the vertex to our list of vertices
pt = csp[1]
subpath_vertices.append(pt)
# Track the bounding box of this subpath
if pt[0] < sp_xmin:
sp_xmin = pt[0]
elif pt[0] > sp_xmax:
sp_xmax = pt[0]
if pt[1] < sp_ymin:
sp_ymin = pt[1]
elif pt[1] > sp_ymax:
sp_ymax = pt[1]
# Track the bounding box of the overall drawing
# This is used for centering the polygons in OpenSCAD around the
# (x,y) origin
if sp_xmin < self.xmin:
self.xmin = sp_xmin
if sp_xmax > self.xmax:
self.xmax = sp_xmax
if sp_ymin < self.ymin:
self.ymin = sp_ymin
if sp_ymax > self.ymax:
self.ymax = sp_ymax
# Handle the final subpath
if len(subpath_vertices):
subpath_list.append([subpath_vertices, [sp_xmin, sp_xmax, sp_ymin, sp_ymax]])
if len(subpath_list) > 0:
self.paths.append( (node, subpath_list) )
def recursivelyTraverseSvg(self, aNodeList, matCurrent=[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
parent_visibility='visible'):
'''
[ This too is largely lifted from eggbot.py ]
Recursively walk the SVG document aNodeList, building polygon vertex lists
for each graphical element we support. The list is generated in self.paths
as a list of tuples [ (node, path_list), (node, path_list), ...] ordered
natively by their order of appearance in the SVG document.
Rendered SVG elements:
<circle>, <ellipse>, <line>, <path>, <polygon>, <polyline>, <rect>
Supported SVG elements:
<group>, <use>
Ignored SVG elements:
<defs>, <eggbot>, <metadata>, <namedview>, <pattern>,
processing directives
All other SVG elements trigger an error (including <text>)
'''
for node in aNodeList:
# Ignore invisible nodes
visibility = node.get('visibility', parent_visibility)
if visibility == 'inherit':
visibility = parent_visibility
if visibility == 'hidden' or visibility == 'collapse':
continue
# FIXME: should we inherit styles from parents?
s = self.getNodeStyle(node)
if s.get('display', '') == 'none': continue
# First apply the current matrix transform to this node's tranform
matNew = simpletransform.composeTransform(
matCurrent, simpletransform.parseTransform(node.get("transform")))
if node.tag == inkex.addNS('g', 'svg') or node.tag == 'g':
self.recursivelyTraverseSvg(node, matNew, visibility)
elif node.tag == inkex.addNS('use', 'svg') or node.tag == 'use':
# A <use> element refers to another SVG element via an
# xlink:href="#blah" attribute. We will handle the element by
# doing an XPath search through the document, looking for the
# element with the matching id="blah" attribute. We then
# recursively process that element after applying any necessary
# (x,y) translation.
#
# Notes:
# 1. We ignore the height and width attributes as they do not
# apply to path-like elements, and
# 2. Even if the use element has visibility="hidden", SVG
# still calls for processing the referenced element. The
# referenced element is hidden only if its visibility is
# "inherit" or "hidden".
refid = node.get(inkex.addNS('href', 'xlink'))
if not refid:
continue
# [1:] to ignore leading '#' in reference
path = '//*[@id="%s"]' % refid[1:]
refnode = node.xpath(path)
if refnode:
x = float(node.get('x', '0'))
y = float(node.get('y', '0'))
# Note: the transform has already been applied
if (x != 0) or (y != 0):
matNew2 = simpletransform.composeTransform(matNew, simpletransform.parseTransform('translate(%f,%f)' % (x, y)))
else:
matNew2 = matNew
visibility = node.get('visibility', visibility)
self.recursivelyTraverseSvg(refnode, matNew2, visibility)
elif node.tag == inkex.addNS('path', 'svg'):
path_data = node.get('d')
if path_data:
self.getPathVertices(path_data, node, matNew)
elif node.tag == inkex.addNS('rect', 'svg') or node.tag == 'rect':
# Manually transform
#
# <rect x="X" y="Y" width="W" height="H"/>
#
# into
#
# <path d="MX,Y lW,0 l0,H l-W,0 z"/>
#
# I.e., explicitly draw three sides of the rectangle and the
# fourth side implicitly
# Create a path with the outline of the rectangle
# Adobe Illustrator leaves out 'x'='0'.
x = float(node.get('x', '0'))
y = float(node.get('y', '0'))
w = float(node.get('width', '0'))
h = float(node.get('height', '0'))
rx = float(node.get('rx', '0'))
ry = float(node.get('ry', '0'))
if rx > 0.0 or ry > 0.0:
if ry < 0.0000001: ry = rx
elif rx < 0.0000001: rx = ry
d = self.roundedRectBezier(x, y, w, h, rx, ry)
self.getPathVertices(d, node, matNew)
else:
a = []
a.append(['M ', [x, y]])
a.append([' l ', [w, 0]])
a.append([' l ', [0, h]])
a.append([' l ', [-w, 0]])
a.append([' Z', []])
self.getPathVertices(simplepath.formatPath(a), node, matNew)
elif node.tag == inkex.addNS('line', 'svg') or node.tag == 'line':
# Convert
#
# <line x1="X1" y1="Y1" x2="X2" y2="Y2/>
#
# to
#
# <path d="MX1,Y1 LX2,Y2"/>
x1 = float(node.get('x1'))
y1 = float(node.get('y1'))
x2 = float(node.get('x2'))
y2 = float(node.get('y2'))
if (not x1) or (not y1) or (not x2) or (not y2):
continue
a = []
a.append(['M ', [x1, y1]])
a.append([' L ', [x2, y2]])
self.getPathVertices(simplepath.formatPath(a), node, matNew)
elif node.tag == inkex.addNS('polyline', 'svg') or node.tag == 'polyline':
# Convert
#
# <polyline points="x1,y1 x2,y2 x3,y3 [...]"/>
#
# to
#
# <path d="Mx1,y1 Lx2,y2 Lx3,y3 [...]"/>
#
# Note: we ignore polylines with no points
pl = node.get('points', '').strip()
if pl == '':
continue
pa = pl.split()
d = "".join(["M " + pa[i] if i == 0 else " L " + pa[i] for i in range(0, len(pa))])
self.getPathVertices(d, node, matNew)
elif node.tag == inkex.addNS('polygon', 'svg') or node.tag == 'polygon':
# Convert
#
# <polygon points="x1,y1 x2,y2 x3,y3 [...]"/>
#
# to
#
# <path d="Mx1,y1 Lx2,y2 Lx3,y3 [...] Z"/>
#
# Note: we ignore polygons with no points
pl = node.get('points', '').strip()
if pl == '':
continue
pa = pl.split()
d = "".join(["M " + pa[i] if i == 0 else " L " + pa[i] for i in range(0, len(pa))])
d += " Z"
self.getPathVertices(d, node, matNew)
elif node.tag == inkex.addNS('ellipse', 'svg') or node.tag == 'ellipse' or \
node.tag == inkex.addNS('circle', 'svg') or node.tag == 'circle':
# Convert circles and ellipses to a path with two 180 degree
# arcs. In general (an ellipse), we convert
#
# <ellipse rx="RX" ry="RY" cx="X" cy="Y"/>
#
# to
#
# <path d="MX1,CY A RX,RY 0 1 0 X2,CY A RX,RY 0 1 0 X1,CY"/>
#
# where
#
# X1 = CX - RX
# X2 = CX + RX
#
# Note: ellipses or circles with a radius attribute of value 0
# are ignored
if node.tag == inkex.addNS('ellipse', 'svg') or node.tag == 'ellipse':
rx = float(node.get('rx', '0'))
ry = float(node.get('ry', '0'))
else:
rx = float(node.get('r', '0'))
ry = rx
if rx == 0 or ry == 0:
continue
cx = float(node.get('cx', '0'))
cy = float(node.get('cy', '0'))
x1 = cx - rx
x2 = cx + rx
d = 'M %f,%f ' % (x1, cy) + \
'A %f,%f ' % (rx, ry) + \
'0 1 0 %f,%f ' % (x2, cy) + \
'A %f,%f ' % (rx, ry) + \
'0 1 0 %f,%f' % (x1, cy)
self.getPathVertices(d, node, matNew)
elif node.tag == inkex.addNS('pattern', 'svg') or node.tag == 'pattern':
pass
elif node.tag == inkex.addNS('metadata', 'svg') or node.tag == 'metadata':
pass
elif node.tag == inkex.addNS('defs', 'svg') or node.tag == 'defs':
self.recursivelyTraverseSvg(node, matNew, visibility)
elif node.tag == inkex.addNS('desc', 'svg') or node.tag == 'desc':
pass
elif node.tag == inkex.addNS('namedview', 'sodipodi') or node.tag == 'namedview':
pass
elif node.tag == inkex.addNS('eggbot', 'svg') or node.tag == 'eggbot':
pass
elif node.tag == inkex.addNS('text', 'svg') or node.tag == 'text':
texts = []
plaintext = ''
for tnode in node.iterfind('.//'): # all subtree
if tnode is not None and tnode.text is not None:
texts.append(tnode.text)
if len(texts):
plaintext = "', '".join(texts).encode('latin-1')
inkex.errormsg('Warning: text "%s"' % plaintext)
inkex.errormsg('Warning: unable to draw text, please convert it to a path first.')
elif node.tag == inkex.addNS('title', 'svg') or node.tag == 'title':
pass
elif node.tag == inkex.addNS('image', 'svg') or node.tag == 'image':
if 'image' not in self.warnings:
inkex.errormsg(
gettext.gettext(
'Warning: unable to draw bitmap images; please convert them to line art first. '
'Consider using the "Trace bitmap..." tool of the "Path" menu. Mac users please '
'note that some X11 settings may cause cut-and-paste operations to paste in bitmap copies.'))
self.warnings['image'] = 1
elif node.tag == inkex.addNS('pattern', 'svg') or node.tag == 'pattern':
pass
elif node.tag == inkex.addNS('radialGradient', 'svg') or node.tag == 'radialGradient':
# Similar to pattern
pass
elif node.tag == inkex.addNS('linearGradient', 'svg') or node.tag == 'linearGradient':
# Similar in pattern
pass
elif node.tag == inkex.addNS('style', 'svg') or node.tag == 'style':
# This is a reference to an external style sheet and not the
# value of a style attribute to be inherited by child elements
#
# <style type="text/css">
# <![CDATA[
# .str0 {stroke:red;stroke-width:20}
# .fil0 {fill:none}
# ]]>
#
# FIXME: test/test_styles.sh fails without this.
# This is input for self.getNodeStyle()
if node.get('type', '') == "text/css":
self.cssDictAdd(node.text)
else:
inkex.errormsg("Warning: Corel-style CSS definitions ignored. Parsing element 'style' with type='%s' not implemented." % node.get('type', ''))
elif node.tag == inkex.addNS('cursor', 'svg') or node.tag == 'cursor':
pass
elif node.tag == inkex.addNS('color-profile', 'svg') or node.tag == 'color-profile':
# Gamma curves, color temp, etc. are not relevant to single
# color output
pass
elif not isinstance(node.tag, basestring):
# This is likely an XML processing instruction such as an XML
# comment. lxml uses a function reference for such node tags
# and as such the node tag is likely not a printable string.
# Further, converting it to a printable string likely won't
# be very useful.
pass
else:
inkex.errormsg('Warning: unable to draw object <%s>, please convert it to a path first.' % node.tag)
pass
def recursivelyGetEnclosingTransform(self, node):
'''
Determine the cumulative transform which node inherits from
its chain of ancestors.
'''
node = node.getparent()
if node is not None:
parent_transform = self.recursivelyGetEnclosingTransform(node)
node_transform = node.get('transform', None)
if node_transform is None:
return parent_transform
else:
tr = simpletransform.parseTransform(node_transform)
if parent_transform is None:
return tr
else:
return simpletransform.composeTransform(parent_transform, tr)
else:
return self.docTransform
#! /usr/bin/python3
#
# (c) 2017 Patrick Himmelmann et.al.
# 2017-05-21
#
# (c) 2017-11-24, juergen@fabmail.org
#
# The code is fully compatible with python 2.7 and 3.5
#
# High level methods:
# set(paths=[[..]], speed=.., power=[..], ...)
# write(fd)
#
# Intermediate methods:
# header(), body(), trailer()
#
# Low level methods:
# encode_hex(), encode_relcoord(), encode_percent()
# encode_number() for e.g.: # Bottom_Right_E7_51 452.84mm 126.8mm e7 51 00 00 1b 51 68 00 00 07 5e 50
# scramble()
#
# Some, but not all unscramble() decode_..() methods are also included.
# A self test example (cutting the triange in a square) is included at the end.
#
# 2017-12-03, jw@fabmail.org
# v1.0 -- The test code produces a valid square_tri_test.rd, according to
# githb.com/kkaempf/rudida/bin/decode
# 2017-12-11, jw@fabmail.org
# v1.2 -- Correct maxrel 8.191 found.
# Implemented Cut_Horiz, Cut_Vert, Move_Horiz, Move_Vert
# Updated encode_relcoord() to use encode_number(2)
# 2017-12-13, jw@fabmail.org
# v1.3 -- added _forceabs = 100. Limit possible precision loss.
# 2017-12-14, jw@fabmail.org
# v1.4 -- added bbox2moves() and paths2moves()
# 2017-12-16, jw@fabmail.org
# v1.5 -- added interface to support multiple layer
# 2017-12-18, jw@fabmail.org
# v1.6 -- encode_byte() encode_color() added.
# multi layer support in header() and body() done.
import sys, re, math, copy
# python2 has a completely useless alias bytes = str. Fix this:
if sys.version_info.major < 3:
def bytes(tupl):
"Minimalistic python3 compatible implementation used in python2."
return "".join(map(chr, tupl))
class RuidaLayer():
"""
"""
def __init__(self, paths=None, speed=None, power=None, bbox=None, color=[0,0,0], freq=20.0):
self._paths = paths
self._bbox = bbox
self._speed = speed
self._power = power
self._color = color
self._freq = freq
def set(self, paths=None, speed=None, power=None, bbox=None, color=None, freq=None):
if paths is not None: self._paths = paths
if speed is not None: self._speed = speed
if power is not None: self._power = power
if bbox is not None: self._bbox = bbox
if color is not None: self._color = color
if freq is not None: self._freq = freq
class Ruida():
"""
Assemble a valid *.rd file with multiple layers. Each layer has the following parameters:
paths = [
[[0,0], [50,0], [50,50], [0,50], [0,0]],
[[12,10], [38,25], [12,40], [12,10]]
]
This example is a 50 mm square, with a 30 mm triangle inside.
speed = 30
speed = [ 1000, 30 ]
Movement speed in mm/sec.
Can be scalar or sequence. A scalar NUMBER is the same as a
sequence of [1000, NUMBER]. The first value of the sequence is
used for travelling with lasers off.
The second value is with laser1 on.
power = [ 50, 70 ]
Values in percent. The first value is the minimum power used near
corners or start and end of lines.
The second value is the maximum power used in the middle of long
straight lines, this compensates for accellerated machine
movements. Additional pairs can be specified for a second, third,
and fourth laser.
bbox = [[0,0], [50,50]]
Must span the rectangle that contains all points in the paths.
Can also be ommited and/or computed by the boundingbox() method.
The first point ([xmin, ymin] aka "top left") of the bounding
box is ususally [0,0] so that the start position can be easily
adjusted at the machine.
color = [0,255,0]
Give a display color for the layer. This is used in the preview
to visualize different layers in different colors.
Expected as a triple [RED, GREEN, BLUE] each in [0..255]
"""
__version__ = "1.6"
def __init__(self, layers=None):
if layers is None: layers = []
self._layers = layers
self._odo = None
self._globalbbox = None
self._header = None
self._body = None
self._trailer = None
# Must do an absolute mov/cut every now and then to avoid precision loss.
# Worst case estimation: A deviation of 0.1mm is acceptable, this is circa the
# diameter of the laser beam. Precision loss can occur due to rounding of the last decimal,
# Which can contribute less than 0.001 mm each time. Thus a helpful value should be around
# 100. We want the value as high as possible to safe code size, but slow enough to keep the
# precision loss invisible.
#
# Set to 1, to disable relative moves.
# Set to 0, to never force an absolute move. Allows potentially infinite precision loss.
self._forceabs = 100
def addLayer(self, layer):
self._layers.append(layer)
def set(self, nlayers=None, layer=0, paths=None, speed=None, power=None, globalbbox=None, bbox=None, freq=None, odo=None, color=None, forceabs=None):
if forceabs is not None: self._forceabs = forceabs
if globalbbox is not None: self._globalbbox = globalbbox
if odo is not None: self._odo = odo
if layer >= len(self._layers): nlayers = layer+1
if nlayers is not None:
if nlayers < len(self._layers): self._layers = self._layers[0:nlayers]
while nlayers > len(self._layers): self.addLayer(RuidaLayer())
if paths is not None: self._layers[layer].set(paths = paths)
if speed is not None: self._layers[layer].set(speed = speed)
if power is not None: self._layers[layer].set(power = power)
if bbox is not None: self._layers[layer].set(bbox = bbox)
if freq is not None: self._layers[layer].set(freq = freq)
if color is not None: self._layers[layer].set(color = color)
def write(self, fd, scramble=True):
"""
Write a fully prepared object into a file (or raise ValueError()s
for missing attributes). The object must be prepared by passing
parameters to __init__ or set().
The filedescriptor should be opened in "wb" mode.
The file format is normally scrambled. Files written with
scramble=False are not understood by the machine, but may be
helpful for debugging.
"""
if not self._header:
if self._layers:
for l in self._layers:
if l._bbox is None and l._paths: l._bbox = self.boundingbox(l._paths)
self._header = self.header(self._layers)
if not self._body:
if self._layers:
self._body = self.body(self._layers)
if not self._odo:
if self._layers:
for l in self._layers:
self.odoAdd(self.odometer(l._paths))
if not self._trailer: self._trailer = self.trailer(self._odo)
if not self._header: raise ValueError("header(_bbox,_speed,_power,_freq) not initialized")
if not self._body: raise ValueError("body(_layers) not initialized")
if not self._trailer: raise ValueError("trailer() not initialized")
contents = self._header + self._body + self._trailer
if scramble: contents = self.scramble_bytes(contents)
fd.write(contents)
def odometer(self, paths=None, init=[0,0], return_home=False):
"""
Returns a list of two values: [ cut_distance, travel_distance ]
Note that these distances are subject to path ordering.
Call this after all optimizations.
"""
if paths is None: paths = self._paths
if paths is None: raise ValueError("no paths")
def dist_xy(p1, p2):
dx = p2[0] - p1[0]
dy = p2[1] - p1[1]
return math.sqrt(dx*dx+dy*dy)
cut_d = 0
trav_d = 0
xy = init
for path in paths:
traveling=True
for point in path:
if traveling:
trav_d += dist_xy(xy, point)
xy = point
traveling = False
else:
cut_d += dist_xy(xy, point)
xy = point
if return_home:
trav_d += dist_xy(xy, init)
return [ cut_d, trav_d ]
def odoAdd(self, odo):
if self._odo is None:
self._odo = copy.copy(odo) # we change values later. Thus we need a copy.
else:
for n in range(len(odo)):
self._odo[n] += odo[n]
def paths2moves(self, paths=None):
"""
Returns a list of one-element-lists, each point in any of the
sub-paths as a its own list. This is technique generates
only move instructions in the rd output (laser inactive).
"""
if paths is None: paths = self._paths
if paths is None: raise ValueError("no paths")
moves = []
for path in paths:
for point in path:
moves.append([[point[0], point[1]]])
return moves
def boundingbox(self, paths=None):
"""
Returns a list of two pairs [[xmin, ymin], [xmax, ymax]]
that spans the rectangle containing all points found in paths.
If no parameter is given, the _paths of the object are examined.
"""
if paths is None: paths = self._paths
if paths is None: raise ValueError("no paths")
xmin = xmax = paths[0][0][0]
ymin = ymax = paths[0][0][1]
for path in paths:
for point in path:
if point[0] > xmax: xmax = point[0]
if point[0] < xmin: xmin = point[0]
if point[1] > ymax: ymax = point[1]
if point[1] < ymin: ymin = point[1]
return [[xmin, ymin], [xmax, ymax]]
def bbox_combine(self, bbox1, bbox2):
"""
returns the boundingbox of two bounding boxes.
"""
if bbox1 is None: return bbox2
if bbox2 is None: return bbox1
x0 = min(bbox1[0][0], bbox2[0][0])
y0 = min(bbox1[0][1], bbox2[0][1])
x1 = max(bbox1[1][0], bbox2[1][0])
y1 = max(bbox1[1][1], bbox2[1][1])
return [[x0, y0], [x1, y1]]
def bbox2moves(self, bbox):
"""
bbox = [[x0, y0], [x1, y1]]
"""
x0 = bbox[0][0]
y0 = bbox[0][1]
x1 = bbox[1][0]
y1 = bbox[1][1]
return [[[x0,y0]], [[x1,y0]], [[x1,y1]], [[x0,y1]], [[x0, y0]]]
def body(self, layers):
"""
Convert a set of paths (one set per layer) into lasercut instructions.
Each layer has a prolog, that directly sets speed and powers.
Returns the binary instruction data.
"""
def relok(last, point):
"""
Determine, if we can emit a relative move or cut command.
An absolute move or cut costs 11 bytes,
a relative one costs 5 bytes.
"""
maxrel = 8.191 # 8.191 encodes as 3f 7f. -8.191 encodes as 40 01
if last is None: return False
dx = abs(point[0]-last[0])
dy = abs(point[1]-last[1])
return max(dx, dy) <= maxrel
data = bytes([])
# for lnum in reversed(range(len(layers))): # Can be permuted, lower lnum's are processed first. Always.
for lnum in range(len(layers)):
l = layers[lnum]
# CAUTION: keep in sync with header()
power = copy.copy(l._power)
if len(power) % 2: raise ValueError("Even number of elements needed in power[]")
while len(power) < 8: power += power[-2:]
speed = copy.copy(l._speed)
if type(speed) == float or type(speed) == int: speed = [1000, speed]
travelspeed = speed[0]
laserspeed = speed[1]
################## Body Prolog Start #######################
data += self.enc('-b-', ["""
ca 01 00 # Flags_CA_01 00
ca 02""", lnum, """ # CA 02 Layer:0 priority?
ca 01 30 # Flags_CA_01 30
ca 01 10 # Flags_CA_01 10
ca 01 13 # Blow_on
"""])
## '-p-p-p-p-'
# c6 12 00 00 00 00 00 # Cut_Open_delay_12 0.0 ms
# c6 13 00 00 00 00 00 # Cut_Close_delay_13 0.0 ms
# c6 50 """, 100, """ # Cut_through_power1 100%
# c6 51 """, 100, """ # Cut_through_power2 100%
# c6 55 """, 100, """ # Cut_through_power3 100%
# c6 56 """, 100, """ # Cut_through_power4 100%
## if the Cut_through_powers are not present, then c6 15 and c6 16 instead.
data += self.enc('-n-p-p-p-p-p-p-p-p-', ["""
c9 02 """, laserspeed, """ # Speed_C9 30.0mm/s
c6 15 00 00 00 00 00 # Cut_Open_delay_12 0.0 ms
c6 16 00 00 00 00 00 # Cut_Close_delay_13 0.0 ms
c6 01 """, power[0], """ # Laser_1_Min_Pow_C6_01 0%
c6 02 """, power[1], """ # Laser_1_Max_Pow_C6_02 0%
c6 21 """, power[2], """ # Laser_2_Min_Pow_C6_21 0%
c6 22 """, power[3], """ # Laser_2_Max_Pow_C6_22 0%
c6 05 """, power[4], """ # Laser_3_Min_Pow_C6_05 1%
c6 06 """, power[5], """ # Laser_3_Max_Pow_C6_06 0%
c6 07 """, power[6], """ # Laser_4_Min_Pow_C6_07 0%
c6 08 """, power[7], """ # Laser_4_Max_Pow_C6_08 0%
ca 03 01 # Layer_CA_03 01
ca 10 00 # CA 10 00
"""])
################## Body Prolog End #######################
relcounter = 0
lp = None
for path in l._paths:
travel = True
for p in path:
if relok(lp, p) and (self._forceabs == 0 or relcounter < self._forceabs):
if self._forceabs > 0: relcounter += 1
if p[1] == lp[1]: # horizontal rel
if travel:
data += self.enc('-r', ['8a', p[0]-lp[0]]) # Move_Horiz 6.213mm
else:
data += self.enc('-r', ['aa', p[0]-lp[0]]) # Cut_Horiz -6.008mm
elif p[0] == lp[0]: # vertical rel
if travel:
data += self.enc('-r', ['8b', p[1]-lp[1]]) # Move_Vert 17.1mm
else:
data += self.enc('-r', ['ab', p[1]-lp[1]]) # Cut_Vert 2.987mm
else: # other rel
if travel:
data += self.enc('-rr', ['89', p[0]-lp[0], p[1]-lp[1]]) # Move_To_Rel 3.091mm 0.025mm
else:
data += self.enc('-rr', ['a9', p[0]-lp[0], p[1]-lp[1]]) # Cut_Rel 0.015mm -1.127mm
else:
relcounter = 0
if travel:
data += self.enc('-nn', ['88', p[0], p[1]]) # Move_To_Abs 0.0mm 0.0mm
else:
data += self.enc('-nn', ['a8', p[0], p[1]]) # Cut_Abs_a8 17.415mm 7.521mm
lp = p
travel = False
return data
def scramble_bytes(self, data):
if sys.version_info.major < 3:
return bytes([self.scramble(ord(b)) for b in data])
else:
return bytes([self.scramble(b) for b in data])
def unscramble_bytes(self, data):
if sys.version_info.major < 3:
return bytes([self.unscramble(ord(b)) for b in data])
else:
return bytes([self.unscramble(b) for b in data])
def unscramble(self, b):
""" unscramble a single byte for reading from *.rd files """
res_b=b-1
if res_b<0: res_b+=0x100
res_b^=0x88
fb=res_b&0x80
lb=res_b&1
res_b=res_b-fb-lb
res_b|=lb<<7
res_b|=fb>>7
return res_b
def scramble(self, b):
""" scramble a single byte for writing into *.rd files """
fb=b&0x80
lb=b&1
res_b=b-fb-lb
res_b|=lb<<7
res_b|=fb>>7
res_b^=0x88
res_b+=1
if res_b>0xff:res_b-=0x100
return res_b
def header(self, layers):
"""
Generate machine initialization instructions, to be sent before geometry.
layers is a list of RuidaLayer() objects, containing:
_bbox in [[xmin, ymin], [xmax, ymax]] format, as returned by the boundingbox()
method. Note: all test data seen had xmin=0, ymin=0.
_speed: single value per layer.
_power: a list of 2 to 8 elements, [min1, max1, ...]
Missing elements are added by repetition.
Units: Lengths in mm, power in percent [0..100],
speed in mm/sec, freq in khz.
Returns the binary instruction data.
"""
bbox = self._globalbbox
for l in layers:
bbox = self.bbox_combine(bbox, l._bbox)
(xmin, ymin) = bbox[0]
(xmax, ymax) = bbox[1]
data = self.encode_hex("""
d8 12 # Red Light on ?
f0 f1 02 00 # file type ?
d8 00 # Green Light off ?
""")
data += self.enc('-nn', ["e7 06", 0, 0]) # Feeding
data += self.enc('-nn', ["e7 03", xmin, ymin]) # Top_Left_E7_07
data += self.enc('-nn', ["e7 07", xmax, ymax]) # Bottom_Right_E7_07
data += self.enc('-nn', ["e7 50", xmin, ymin]) # Top_Left_E7_50
data += self.enc('-nn', ["e7 51", xmax, ymax]) # Bottom_Right_E7_51
data += self.enc('-nn', ["e7 04 00 01 00 01", 0, 0]) # E7 04 ???
data += self.enc('-', ["e7 05 00"]) # E7 05 ???
## start of per layer headers
for lnum in range(len(layers)):
l = layers[lnum]
# CAUTION: keep in sync with body()
power = copy.copy(l._power)
if len(power) % 2: raise ValueError("Even number of elements needed in power[]")
while len(power) < 8: power += power[-2:]
speed = copy.copy(l._speed)
if type(speed) == float or type(speed) == int: speed = [1000, speed]
travelspeed = speed[0]
laserspeed = speed[1]
data += self.enc('-bn', ["c9 04", lnum, laserspeed])
data += self.enc('-bp-bp', ["c6 31", lnum, power[0], "c6 32", lnum, power[1]]) # Laser_1_Min/Max_Pow
data += self.enc('-bp-bp', ["c6 41", lnum, power[2], "c6 42", lnum, power[3]]) # Laser_2_Min/Max_Pow
data += self.enc('-bp-bp', ["c6 35", lnum, power[4], "c6 36", lnum, power[5]]) # Laser_3_Min/Max_Pow
data += self.enc('-bp-bp', ["c6 37", lnum, power[6], "c6 38", lnum, power[7]]) # Laser_3_Min/Max_Pow
data += self.enc('-bc-bb-bnn-bnn-bnn-bnn-', ["""
ca 06""", lnum, l._color, """ # Layer_CA_06 Layer:0 00 00 00 00 00 RGB-Color for preview
ca 41""", lnum, 0, """ # ??
e7 52""", lnum, l._bbox[0][0], l._bbox[0][1], """ # E7 52 Layer:0 top left?
e7 53""", lnum, l._bbox[1][0], l._bbox[1][1], """ # Bottom_Right_E7_53 Layer:0
e7 61""", lnum, l._bbox[0][0], l._bbox[0][1], """ # E7 61 Layer:0 top left?
e7 62""", lnum, l._bbox[1][0], l._bbox[1][1], """ # Bottom_Right_E7_62 Layer:0
"""])
## end of per layer headers
data += self.enc('-b-', ["""
ca 22""", len(layers)-1, """ # ?? Max layer number ??
e7 54 00 00 00 00 00 00 # Pen_Draw_Y 00 0.0mm
e7 54 01 00 00 00 00 00 # Pen_Draw_Y 01 0.0mm
"""])
data += self.enc('-nn-nn-nn-nn-nn-nn-nn-nn-', ["""
e7 55 00 00 00 00 00 00 # Laser2_Y_Offset False 0.0mm
e7 55 01 00 00 00 00 00 # Laser2_Y_Offset True 0.0mm
f1 03 00 00 00 00 00 00 00 00 00 00 # Laser2_Offset 0.0mm 0.0mm
f1 00 00 # Start0 00
f1 01 00 # Start1 00
f2 00 00 # F2 00 00
f2 01 00 # F2 01 00
f2 02 05 2a 39 1c 41 04 6a 15 08 20 # F2 02 05 2a 39 1c 41 04 6a 15 08 20
f2 03 """, xmin, ymin, """ # F2 03 0.0mm 0.0mm
f2 04 """, xmax, ymax, """ # Bottom_Right_F2_04 17.414mm 24.868mm
f2 06 """, xmin, ymin, """ # F2 06 0.0mm 0.0mm
f2 07 00 # F2 07 00
f2 05 00 01 00 01 """, xmax, ymax, """ # Bottom_Right_F2_05 00 01 00 01 17.414mm 24.868mm
ea 00 # EA 00
e7 60 00 # E7 60 00
e7 13 """, xmin, ymin, """ # E7 13 0.0mm 0.0mm
e7 17 """, xmax, ymax, """ # Bottom_Right_E7_17 17.414mm 24.868mm
e7 23 """, xmin, ymin, """ # E7 23 0.0mm 0.0mm
e7 24 00 # E7 24 00
e7 08 00 01 00 01 """, xmax, ymax, """ # Bottom_Right_E7_08 00 01 00 01 17.414mm 24.868mm
"""])
return data
def trailer(self, odo=[0.0, 0.0]):
"""
Generate machine trailer instructions. To be sent after geometry instructions.
Initialize a trailer with the cut distance in m, not mm.
Note, that RDworks8 uses the cut distance twice here, and does not send the
the travel distance. Is this a bug?
Returns the binary instruction data.
"""
data = self.enc("-nn-", ["""
eb e7 00
da 01 06 20""", odo[0]*0.001, odo[0]*0.001, """
d7 """])
return data
def encode_number(self, num, length=5, scale=1000):
"""
The number n is expected in floating point format with unit mm.
A bytes() array of size length is returned.
The default scale converts to micrometers.
length=5 and scale=1000 are the expected machine format.
"""
res = []
nn = int(num * scale)
while nn > 0:
res.append(nn & 0x7f)
nn >>= 7
while len(res) < length:
res.append(0)
res.reverse()
return bytes(res)
def encode_color(self, color):
"""
color = [RED, GREEN, BLUE]
"""
cc = ((color[2]&0xff)<<16) + ((color[1]&0xff)<<8) + (color[0]&0xff)
return self.encode_number(cc, scale=1)
def enc(self, fmt, tupl):
"""
Encode the elements of tupl according to the format string.
Each character in fmt consumes the corresponds element from tupl
as a parameter to an encoding method:
'-' encode_hex()
'n' encode_number()
'p' encode_percent()
'r' encode_relcoord()
'b' encode_byte()
'c' encode_color()
"""
if len(fmt) != len(tupl): raise ValueError("format '"+fmt+"' length differs from len(tupl)="+str(len(tupl)))
ret = b''
for i in range(len(fmt)):
if fmt[i] == '-': ret += self.encode_hex(tupl[i])
elif fmt[i] == 'n': ret += self.encode_number(tupl[i])
elif fmt[i] == 'p': ret += self.encode_percent(tupl[i])
elif fmt[i] == 'r': ret += self.encode_relcoord(tupl[i])
elif fmt[i] == 'b': ret += self.encode_byte(tupl[i])
elif fmt[i] == 'c': ret += self.encode_color(tupl[i])
else: raise ValueError("unknown character in fmt: "+fmt)
return ret
def decode_number(self, x):
"used with a bytes() array of length 5"
fak=1
res=0
for b in reversed(x):
res+=fak*b
fak*=0x80
return 0.0001 * res
def encode_relcoord(self, n):
"""
Relative position in mm;
Returns a bytes array of two elements.
"""
nn = int(n*1000)
if nn > 8191 or nn < -8191:
raise ValueError("relcoord "+str(n)+" mm is out of range. Use abscoords!")
if nn < 0: nn += 16384
return self.encode_number(nn, length=2, scale=1)
def decode_relcoord(self, x):
"""
using the first two elements of array x
relative position in micrometer; signed (2s complement)
"""
r = x[0] << 8
r += x[1]
if r > 16383 or r < 0:
raise ValueError("Not a rel coord: " + repr(x[0:2]))
if r > 8191: return 0.001 * (r-16384)
else: return 0.001 * r
def encode_byte(self, n):
return self.encode_number(n, length=1, scale=1)
def encode_percent(self, n):
"""
returns two bytes, used with laser and layer percentages.
The magic constant 163.83 is 1/100 of 14bit all 1s.
"""
a = int(n*0x3fff*0.01) # n * 163.83
return bytes([a>>7, a&0x7f]) # 7-bit encoding
def encode_hex(self, str):
"""
Assemble a string from hexadecimal digits. Binary safe.
Example: "48 65 6c 6c f8 # with a smorrebrod o\n 21" -> b'Hell\xf7!'
"""
str = re.sub('#.*$','', str, flags=re.MULTILINE) # weed out comments.
l = map(lambda x: int(x, base=16), str.split()) # locale.atoi() is to be avoided!
return bytes(l)
import json
import inkex
import gettext
# python2 compatibility. Inkscape runs us with python2!
if sys.version_info.major < 3:
def bytes(tupl):
return "".join(map(chr, tupl))
class ThunderLaser(inkex.Effect):
# CAUTION: Keep in sync with thunderlaser-ruida.inx and thunderlaser-ruida_de.inx
__version__ = '1.8' # >= max(src/ruida.py:__version__, src/inksvg.py:__version__)
def __init__(self):
"""
Option parser example:
'thunderlaser.py', '--tab="thunderlaser"', '--cut_group="cut_plastics"', '--cut_wood=30,50,65', '--cut_plastics=25,55,70', '--cut_other=300,26,65', '--cut_manual_speed=68', '--cut_manual_minpow=60', '--cut_manual_maxpow=70', '--cut_color=any', '--mark_group="mark_material"', '--mark_material=1000,8,25', '--mark_manual_speed=30', '--mark_manual_minpow=50', '--mark_manual_maxpow=70', '--mark_color=none', '--smoothness=0.20000000298023224', '--maxwidth=900', '--maxheight=600', '--bbox_only=false', '--device=/dev/ttyUSB0,/tmp/hannes.rd', '--dummy=true', '/tmp/ink_ext_XXXXXX.svgDTI8AZ']
"""
inkex.localize() # does not help for localizing my *.inx file
inkex.Effect.__init__(self)
self.OptionParser.add_option(
"--tab", # NOTE: value is not used.
action="store", type="string", dest="tab", default="thunderlaser",
help="The active tab when Apply was pressed")
self.OptionParser.add_option(
"--cut_group", action="store", type="string", dest="cut_group", default="cut_wood",
help="The active cut_group tab when Apply was pressed")
self.OptionParser.add_option(
"--mark_group", action="store", type="string", dest="mark_group", default="mark_material",
help="The active mark_group tab when Apply was pressed")
self.OptionParser.add_option(
"--cut_color", action="store", type="string", dest="cut_color", default="any",
help="The color setting for cutting. Default: any")
self.OptionParser.add_option(
"--mark_color", action="store", type="string", dest="mark_color", default="none",
help="The color setting for cutting. Default: none")
self.OptionParser.add_option(
'--cut_wood', dest='cut_wood', type='string', default='30,50,65', action='store',
help='Speed,MinPower,MaxPower Setting when cutting wood is selected.')
self.OptionParser.add_option(
'--cut_plastics', dest='cut_plastics', type='string', default='', action='store',
help='Speed,MinPower,MaxPower Setting when cutting plastics is selected.')
self.OptionParser.add_option(
'--cut_other', dest='cut_other', type='string', default='', action='store',
help='Speed,MinPower,MaxPower Setting when cutting other is selected.')
self.OptionParser.add_option(
'--cut_manual_speed', dest='cut_manual_speed', type='int', default=30, action='store',
help='Speed Setting when cutting with manual entry is selected.')
self.OptionParser.add_option(
'--cut_manual_minpow', dest='cut_manual_minpow', type='int', default=30, action='store',
help='MinPower1 Setting when cutting with manual entry is selected.')
self.OptionParser.add_option(
'--cut_manual_maxpow', dest='cut_manual_maxpow', type='int', default=30, action='store',
help='MaxPower1 Setting when cutting with manual entry is selected.')
self.OptionParser.add_option(
'--mark_material', dest='mark_material', type='string', default='1000,7,18', action='store',
help='Speed,MinPower,MaxPower Setting when marking by material is selected.')
self.OptionParser.add_option(
'--mark_manual_speed', dest='mark_manual_speed', type='int', default=1000, action='store',
help='Speed Setting when marking with manual entry is selected.')
self.OptionParser.add_option(
'--mark_manual_minpow', dest='mark_manual_minpow', type='int', default=7, action='store',
help='MinPower1 Setting when marking with manual entry is selected.')
self.OptionParser.add_option(
'--mark_manual_maxpow', dest='mark_manual_maxpow', type='int', default=18, action='store',
help='MaxPower1 Setting when marking with manual entry is selected.')
self.OptionParser.add_option(
'--smoothness', dest='smoothness', type='float', default=float(0.2), action='store',
help='Curve smoothing (less for more [0.0001 .. 5]). Default: 0.2')
self.OptionParser.add_option(
'--freq1', dest='freq1', type='float', default=float(20.0), action='store',
help='Laser1 frequency [kHz]. Default: 20.0')
self.OptionParser.add_option(
'--maxheight', dest='maxheight', type='string', default='600', action='store',
help='Height of laser area [mm]. Default: 600 mm')
self.OptionParser.add_option(
'--maxwidth', dest='maxwidth', type='string', default='900', action='store',
help='Width of laser area [mm]. Default: 900 mm')
self.OptionParser.add_option(
"--bbox_only", action="store", type="inkbool", dest="bbox_only", default=False,
help="Cut bounding box only. Default: False")
self.OptionParser.add_option(
"--move_only", action="store", type="inkbool", dest="move_only", default=False,
help="Move only, instead of cutting and moving. Default: False")
self.OptionParser.add_option(
"--dummy", action="store", type="inkbool", dest="dummy", default=False,
help="Dummy device: Send to /tmp/thunderlaser.rd . Default: False")
self.OptionParser.add_option(
'--device', dest='devicelist', type='string', default='/dev/ttyUSB0,/dev/ttyACM0,/tmp/thunderlaser.rd', action='store',
help='Output device or file name to use. A comma-separated list. Default: /dev/ttyUSB0,/dev/ttyACM0,/tmp/thunderlaser.rd')
self.OptionParser.add_option('-V', '--version',
action = 'store_const', const=True, dest = 'version', default = False,
help='Just print version number ("'+self.__version__+'") and exit.')
def cut_options(self):
"""
returns None, if deactivated or
returns [ 'speed':1000, 'minpow':50, 'maxpow':70, 'color':"any" ] otherwise.
"""
group=self.options.cut_group.strip('"') # passed into the option with surrounding double-quotes. yacc.
color = self.options.cut_color
if color == 'none': return None
parse_str = None
if group == "cut_wood": parse_str = self.options.cut_wood
elif group == "cut_plastics": parse_str = self.options.cut_plastics
elif group == "cut_other": parse_str = self.options.cut_other
if parse_str is None:
return {
'speed': int(self.options.cut_manual_speed),
'minpow':int(self.options.cut_manual_minpow),
'maxpow':int(self.options.cut_manual_maxpow),
'color':color, 'group':group }
else:
v = parse_str.split(',')
return { 'speed':int(v[0]), 'minpow':int(v[1]), 'maxpow':int(v[2]), 'color':color, 'group':group }
def mark_options(self):
"""
returns None, if self.options.mark_color=='none'
returns [ 'speed':1000, 'minpow':50, 'maxpow':70, 'color':"green" ] otherwise.
"""
group=self.options.mark_group.strip('"')
color = self.options.mark_color
if color == 'none': return None
parse_str = None
if group == "mark_material": parse_str = self.options.mark_material
if parse_str is None:
return {
'speed': int(self.options.mark_manual_speed),
'minpow':int(self.options.mark_manual_minpow),
'maxpow':int(self.options.mark_manual_maxpow),
'color':color, 'group':group }
else:
v = parse_str.split(',')
return { 'speed':int(v[0]), 'minpow':int(v[1]), 'maxpow':int(v[2]), 'color':color, 'group':group }
def colorname2rgb(self, name):
if name is None: return None
if name == 'none': return False
if name == 'any': return True
if name == 'red': return [ 255, 0, 0]
if name == 'green': return [ 0, 255, 0]
if name == 'blue': return [ 0, 0, 255]
if name == 'black': return [ 0, 0, 0]
if name == 'white': return [ 255, 255, 255]
if name == 'cyan': return [ 0, 255, 255]
if name == 'magenta': return [ 255, 0, 255]
if name == 'yellow': return [ 255, 255, 0]
raise ValueError("unknown colorname: "+name)
def effect(self):
svg = InkSvg(document=self.document, smoothness=float(self.options.smoothness))
# Viewbox handling
svg.handleViewBox()
if self.options.version:
print("Version "+self.__version__)
sys.exit(0)
cut_opt = self.cut_options()
mark_opt = self.mark_options()
if cut_opt is None and mark_opt is None:
inkex.errormsg(gettext.gettext('ERROR: Enable Cut or Mark or both.'))
sys.exit(1)
if cut_opt is not None and mark_opt is not None and cut_opt['color'] == mark_opt['color']:
inkex.errormsg(gettext.gettext('ERROR: Choose different color settings for Cut and Mark. Both are "'+mark_opt['color']+'"'))
sys.exit(1)
mark_color = self.colorname2rgb(None if mark_opt is None else mark_opt['color'])
cut_color = self.colorname2rgb(None if cut_opt is None else cut_opt['color'])
# First traverse the document (or selected items), reducing
# everything to line segments. If working on a selection,
# then determine the selection's bounding box in the process.
# (Actually, we just need to know it's extrema on the x-axis.)
if self.options.ids:
# Traverse the selected objects
for id in self.options.ids:
transform = svg.recursivelyGetEnclosingTransform(self.selected[id])
svg.recursivelyTraverseSvg([self.selected[id]], transform)
else:
# Traverse the entire document building new, transformed paths
svg.recursivelyTraverseSvg(self.document.getroot(), svg.docTransform)
## First simplification: paths_tupls[]
## Remove the bounding boxes from paths. Replace the object with its id. We can still access color and style attributes through the id.
## from (<Element {http://www.w3.org/2000/svg}path at 0x7fc446a583b0>,
## [[[[207, 744], [264, 801]], [207, 264, 744, 801]], [[[207, 801], [264, 744]], [207, 264, 744, 801]], ...])
## to (<Element {http://www.w3.org/2000/svg}path at 0x7fc446a583b0>,
## [[[207, 744], [264, 801]], [[207, 801], [264, 744]]], ... ]
##
paths_tupls = []
for tup in svg.paths:
node = tup[0]
ll = []
for e in tup[1]:
ll.append(e[0])
paths_tupls.append( (node, ll) )
self.paths = None # free some memory
## Reposition the graphics, so that a corner or the center becomes origin [0,0]
## Convert from dots-per-inch to mm.
## Separate into Cut and Mark lists based on element style.
paths_list = []
paths_list_cut = []
paths_list_mark = []
dpi2mm = 25.4 / svg.dpi
(xoff,yoff) = (svg.xmin, svg.ymin) # top left corner is origin
# (xoff,yoff) = (svg.xmax, svg.ymax) # bottom right corner is origin
# (xoff,yoff) = ((svg.xmax+svg.xmin)/2.0, (svg.ymax+svg.ymin)/2.0) # center is origin
for tupl in paths_tupls:
(elem,paths) = tupl
for path in paths:
newpath = []
for point in path:
newpath.append([(point[0]-xoff) * dpi2mm, (point[1]-yoff) * dpi2mm])
paths_list.append(newpath)
is_mark = svg.matchStrokeColor(elem, mark_color)
is_cut = svg.matchStrokeColor(elem, cut_color)
if is_cut and is_mark: # never both. Named colors win over 'any'
if mark_opt['color'] == 'any':
is_mark = False
else: # cut_opt['color'] == 'any'
is_cut = False
if is_cut: paths_list_cut.append(newpath)
if is_mark: paths_list_mark.append(newpath)
paths_tupls = None # save some memory
bbox = [[(svg.xmin-xoff)*dpi2mm, (svg.ymin-yoff)*dpi2mm], [(svg.xmax-xoff)*dpi2mm, (svg.ymax-yoff)*dpi2mm]]
rd = Ruida()
# bbox = rd.boundingbox(paths_list) # same as above.
if self.options.bbox_only:
paths_list = [[ [bbox[0][0],bbox[0][1]], [bbox[1][0],bbox[0][1]], [bbox[1][0],bbox[1][1]],
[bbox[0][0],bbox[1][1]], [bbox[0][0],bbox[0][1]] ]]
paths_list_cut = paths_list
paths_list_mark = paths_list
if cut_opt['color'] == 'any' or mark_opt is None: paths_list_mark = []
if mark_opt['color'] == 'any' or cut_opt is None: paths_list_cut = [] # once is enough.
if self.options.move_only:
paths_list = rd.paths2moves(paths_list)
paths_list_cut = rd.paths2moves(paths_list_cut)
paths_list_mark = rd.paths2moves(paths_list_mark)
if self.options.dummy:
with open('/tmp/thunderlaser.json', 'w') as fd:
json.dump({
'paths_bbox': bbox,
'cut_opt': cut_opt, 'mark_opt': mark_opt,
'paths_unit': 'mm', 'svg_resolution': svg.dpi, 'svg_resolution_unit': 'dpi',
'freq1': self.options.freq1, 'freq1_unit': 'kHz',
'paths': paths_list,
'cut': { 'paths':paths_list_cut, 'color': cut_color },
'mark': { 'paths':paths_list_mark, 'color': mark_color },
}, fd, indent=4, sort_keys=True, encoding='utf-8')
print("/tmp/thunderlaser.json written.", file=sys.stderr)
else:
if len(paths_list_cut) > 0 and len(paths_list_mark) > 0:
nlay=2
else:
nlay=1
if bbox[0][0] < 0 or bbox[0][1] < 0:
inkex.errormsg(gettext.gettext('Warning: negative coordinates not implemented in class Ruida(), truncating at 0'))
# rd.set(globalbbox=bbox) # Not needed. Even slightly wrong.
rd.set(nlayers=nlay)
l=0
if mark_opt is not None:
if len(paths_list_mark) > 0:
cc = mark_color if type(mark_color) == list else [128,0,64]
rd.set(layer=l, speed=mark_opt['speed'], color=cc)
rd.set(layer=l, power=[mark_opt['minpow'], mark_opt['maxpow']])
rd.set(layer=l, paths=paths_list_mark)
l += 1
else:
if mark_opt['color'] != 'any' and len(paths_list_cut) == 0:
inkex.errormsg(gettext.gettext('ERROR: mark line color "'+mark_opt['color']+'": nothing found.'))
sys.exit(0)
if cut_opt is not None:
if len(paths_list_cut) > 0:
cc = cut_color if type(cut_color) == list else [128,0,64]
rd.set(layer=l, speed=cut_opt['speed'], color=cc)
rd.set(layer=l, power=[cut_opt['minpow'], cut_opt['maxpow']])
rd.set(layer=l, paths=paths_list_cut)
l += 1
else:
if cut_opt['color'] != 'any' and len(paths_list_mark) == 0:
inkex.errormsg(gettext.gettext('ERROR: cut line color "'+cut_opt['color']+'": nothing found.'))
sys.exit(0)
device_used = None
for device in self.options.devicelist.split(','):
fd = None
try:
fd = open(device, 'wb')
except:
pass
if fd is not None:
rd.write(fd)
# print(device+" written.", file=sys.stderr)
device_used = device
break
if device_used is None:
inkex.errormsg(gettext.gettext('Warning: no usable devices in device list (or bad directoy): '+self.options.devicelist))
if __name__ == '__main__':
e = ThunderLaser()
e.affect()