1941 lines
78 KiB
Python
1941 lines
78 KiB
Python
|
#! /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()
|