455 lines
23 KiB
Python
455 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import inkex
|
|
from inkex.bezier import csplength, csparea
|
|
from lxml import etree
|
|
import re
|
|
import math
|
|
|
|
class LaserCheck(inkex.EffectExtension):
|
|
|
|
'''
|
|
check for old styles which should be upgraded
|
|
'''
|
|
|
|
def add_arguments(self, pars):
|
|
pars.add_argument('--tab')
|
|
pars.add_argument('--checks', default="check_all")
|
|
pars.add_argument('--bbox', type=inkex.Boolean, default=False)
|
|
pars.add_argument('--bbox_offset', type=float, default=5.000)
|
|
pars.add_argument('--groups_and_layers', type=inkex.Boolean, default=False)
|
|
pars.add_argument('--clones', type=inkex.Boolean, default=False)
|
|
pars.add_argument('--clippaths', type=inkex.Boolean, default=False)
|
|
pars.add_argument('--images', type=inkex.Boolean, default=False)
|
|
pars.add_argument('--texts', type=inkex.Boolean, default=False)
|
|
pars.add_argument('--lowlevelstrokes', type=inkex.Boolean, default=False)
|
|
pars.add_argument('--stroke_colors', type=inkex.Boolean, default=False)
|
|
pars.add_argument('--stroke_widths', type=inkex.Boolean, default=False)
|
|
pars.add_argument('--stroke_opacities', type=inkex.Boolean, default=False)
|
|
pars.add_argument('--cosmestic_dashes', type=inkex.Boolean, default=False)
|
|
pars.add_argument('--invisible_shapes', type=inkex.Boolean, default=False)
|
|
pars.add_argument('--pointy_paths', type=inkex.Boolean, default=False)
|
|
pars.add_argument('--transformations', type=inkex.Boolean, default=False)
|
|
pars.add_argument('--short_paths', type=inkex.Boolean, default=False)
|
|
pars.add_argument('--short_paths_min', type=float, default=1.000)
|
|
pars.add_argument('--non_path_shapes', type=inkex.Boolean, default=False)
|
|
|
|
def effect(self):
|
|
|
|
so = self.options
|
|
|
|
def parseChildren(element):
|
|
if element not in selected:
|
|
selected.append(element)
|
|
children = element.getchildren()
|
|
if children is not None:
|
|
for child in children:
|
|
if child not in selected:
|
|
selected.append(child)
|
|
parseChildren(child) #go deeper and deeper
|
|
|
|
#check if we have selected elements or if we should parse the whole document instead
|
|
selected = [] #total list of elements to parse
|
|
if len(self.svg.selected) == 0:
|
|
for element in self.document.getroot().iter(tag=etree.Element):
|
|
if element != self.document.getroot():
|
|
|
|
selected.append(element)
|
|
else:
|
|
for element in self.svg.selected.values():
|
|
parseChildren(element)
|
|
namedView = self.document.getroot().find(inkex.addNS('namedview', 'sodipodi'))
|
|
doc_units = namedView.get(inkex.addNS('document-units', 'inkscape'))
|
|
pagecolor = namedView.get('pagecolor')
|
|
inkex.utils.debug("---------- Default checks")
|
|
inkex.utils.debug("Document units: {}".format(doc_units))
|
|
'''
|
|
The SVG format is highly complex and offers a lot of possibilities. Most things of SVG we do not
|
|
need for a laser cutter. Usually we need svg:path and maybe svg:image; we can drop a lot of stuff
|
|
like svg:defs, svg:desc, gradients, etc.
|
|
'''
|
|
nonShapes = []
|
|
shapes = []
|
|
for element in selected:
|
|
if not isinstance(element, inkex.ShapeElement):
|
|
nonShapes.append(element)
|
|
else:
|
|
shapes.append(element)
|
|
inkex.utils.debug("{} shape elements in total".format(len(shapes)))
|
|
inkex.utils.debug("{} non-shape elements in total".format(len(nonShapes)))
|
|
for nonShape in nonShapes:
|
|
inkex.utils.debug("non-shape id={}".format(nonShape.get('id')))
|
|
|
|
if so.checks == "check_all" or so.groups_and_layers is True:
|
|
inkex.utils.debug("\n---------- Groups and layers")
|
|
groups = []
|
|
layers = []
|
|
for element in selected:
|
|
if element.tag == inkex.addNS('g','svg'):
|
|
if element.get('inkscape:groupmode') == 'layer':
|
|
layers.append(element)
|
|
else:
|
|
groups.append(element)
|
|
inkex.utils.debug("{} groups in total".format(len(groups)))
|
|
inkex.utils.debug("{} layers in total".format(len(layers)))
|
|
|
|
#check for empty groups
|
|
for group in groups:
|
|
if len(group) == 0:
|
|
inkex.utils.debug("id={} is empty group".format(group.get('id')))
|
|
|
|
#check for empty layers
|
|
for layer in layers:
|
|
if len(layer) == 0:
|
|
inkex.utils.debug("id={} is empty layer".format(layer.get('id')))
|
|
|
|
'''
|
|
Clones should be unlinked because they cause similar issues like transformations
|
|
'''
|
|
if so.checks == "check_all" or so.clones is True:
|
|
inkex.utils.debug("\n---------- Clones (svg:use) - maybe unlink")
|
|
uses = []
|
|
for element in selected:
|
|
if element.tag == inkex.addNS('use','svg'):
|
|
uses.append(element)
|
|
inkex.utils.debug("{} svg:use clones in total".format(len(uses)))
|
|
for use in uses:
|
|
inkex.utils.debug("id={}".format(use.get('id')))
|
|
|
|
|
|
'''
|
|
Clip paths are neat to visualize things, but they do not perform a real path cutting.
|
|
Please perform real intersections to have an intact target geometry.
|
|
'''
|
|
if so.checks == "check_all" or so.clippaths is True:
|
|
inkex.utils.debug("\n---------- Clippings (svg:clipPath) - please replace with real cut paths")
|
|
clipPaths = []
|
|
for element in selected:
|
|
if element.tag == inkex.addNS('clipPath','svg'):
|
|
clipPaths.append(element)
|
|
inkex.utils.debug("{} svg:clipPath in total".format(len(clipPaths)))
|
|
for clipPath in clipPaths:
|
|
inkex.utils.debug("id={}".format(clipPath.get('id')))
|
|
|
|
|
|
'''
|
|
Sometimes images look like vector but they are'nt. In case you dont want to perform engraving, either
|
|
check to drop or trace to vector paths
|
|
'''
|
|
if so.checks == "check_all" or so.images is True:
|
|
inkex.utils.debug("\n---------- Images (svg:image) - maybe trace to svg")
|
|
images = []
|
|
for element in selected:
|
|
if element.tag == inkex.addNS('image','svg'):
|
|
images.append(element)
|
|
inkex.utils.debug("{} svg:image in total".format(len(images)))
|
|
for image in images:
|
|
inkex.utils.debug("image id={}".format(image.get('id')))
|
|
|
|
|
|
'''
|
|
Low level strokes cannot be properly edited in Inkscape (no node handles). Converting helps
|
|
'''
|
|
if so.checks == "check_all" or so.lowlevelstrokes is True:
|
|
inkex.utils.debug("\n---------- Low level strokes (svg:line/polyline/polygon) - maybe convert to path")
|
|
lowlevels = []
|
|
for element in selected:
|
|
if element.tag in (inkex.addNS('line','svg'), inkex.addNS('polyline','svg'), inkex.addNS('polygon','svg')):
|
|
lowlevels.append(element)
|
|
inkex.utils.debug("{} low level strokes in total".format(len(lowlevels)))
|
|
for lowlevel in lowlevels:
|
|
inkex.utils.debug("id={}".format(lowlevel.get('id')))
|
|
|
|
|
|
'''
|
|
Texts cause problems when sharing with other people. You must ensure that everyone has the
|
|
font files installed you used. Convert to paths avoids this issue and guarantees same result
|
|
everywhere.
|
|
'''
|
|
if so.checks == "check_all" or so.texts is True:
|
|
inkex.utils.debug("\n---------- Texts (should be converted to paths)")
|
|
texts = []
|
|
for element in selected:
|
|
if element.tag == inkex.addNS('text','svg'):
|
|
texts.append(element)
|
|
inkex.utils.debug("{} svg:text in total".format(len(texts)))
|
|
for text in texts:
|
|
inkex.utils.debug("id={}".format(text.get('id')))
|
|
|
|
|
|
|
|
'''
|
|
The more stroke colors the more laser job configuration is required. Reduce the SVG file
|
|
to a minimum of stroke colors to be quicker
|
|
'''
|
|
if so.checks == "check_all" or so.stroke_colors is True:
|
|
inkex.utils.debug("\n---------- Stroke colors")
|
|
strokeColors = []
|
|
for element in shapes:
|
|
style = element.get('style')
|
|
if style is not None:
|
|
stroke = re.search('(;|^)stroke:(.*?)(;|$)', style)
|
|
if stroke is not None:
|
|
strokeColor = stroke[0].split("stroke:")[1].split(";")[0]
|
|
if strokeColor not in strokeColors:
|
|
strokeColors.append(strokeColor)
|
|
inkex.utils.debug("{} different stroke colors in total".format(len(strokeColors)))
|
|
for strokeColor in strokeColors:
|
|
inkex.utils.debug("stroke color {}".format(strokeColor))
|
|
|
|
|
|
'''
|
|
Nearly each laser job needs a bit of border to place the material inside the laser. Often
|
|
we have to fixate on vector grid, pin grid or task plate. Thus we need tapes or pins. So we
|
|
leave some borders off the actual part geometries.
|
|
'''
|
|
if so.checks == "check_all" or so.bbox is True:
|
|
inkex.utils.debug("\n---------- Borders around all elements - minimum offset {} mm from each side".format(so.bbox_offset))
|
|
bbox = inkex.BoundingBox()
|
|
for element in self.document.getroot().iter(tag=etree.Element):
|
|
if element != self.document.getroot() and isinstance(element, inkex.ShapeElement) and element.tag != inkex.addNS('use','svg') and element.get('inkscape:groupmode') != 'layer': #bbox fails for svg:use elements and layers
|
|
transform = inkex.Transform()
|
|
parent = element.getparent()
|
|
if parent is not None and isinstance(parent, inkex.ShapeElement):
|
|
transform = parent.composed_transform()
|
|
try:
|
|
bbox += element.bounding_box(transform)
|
|
except Exception:
|
|
transform = element.composed_transform()
|
|
x1, y1 = transform.apply_to_point([0, 0])
|
|
x2, y2 = transform.apply_to_point([1, 1])
|
|
bbox += inkex.BoundingBox((x1, x2), (y1, y2))
|
|
|
|
if abs(bbox.width) == math.inf or abs(bbox.height) == math.inf:
|
|
inkex.utils.debug("bounding box could not be calculated")
|
|
#else:
|
|
# inkex.utils.debug("bounding box is {}".format(bbox))
|
|
page_width = self.svg.unittouu(self.document.getroot().attrib['width'])
|
|
width_height = self.svg.unittouu(self.document.getroot().attrib['height'])
|
|
fmm = self.svg.unittouu(str(so.bbox_offset) + "mm")
|
|
if bbox.left >= fmm:
|
|
inkex.utils.debug("left border... ok")
|
|
else:
|
|
inkex.utils.debug("left border... fail: {:0.3f} mm".format(self.svg.uutounit(bbox.left, "mm")))
|
|
|
|
if bbox.top >= fmm:
|
|
inkex.utils.debug("top border... ok")
|
|
else:
|
|
inkex.utils.debug("top border... fail: {:0.3f} mm".format(self.svg.uutounit(bbox.top, "mm")))
|
|
|
|
if bbox.right + fmm <= page_width:
|
|
inkex.utils.debug("right border... ok")
|
|
else:
|
|
inkex.utils.debug("right border... fail: {:0.3f} mm".format(self.svg.uutounit(bbox.right, "mm")))
|
|
|
|
if bbox.bottom + fmm <= width_height:
|
|
inkex.utils.debug("bottom border... ok")
|
|
else:
|
|
inkex.utils.debug("bottom border... fail: {:0.3f} mm".format(self.svg.uutounit(bbox.bottom, "mm")))
|
|
|
|
|
|
'''
|
|
Different stroke widths might behave the same like different stroke colors. Reduce to a minimum set.
|
|
Ideally all stroke widths are set to 1 pixel.
|
|
'''
|
|
if so.checks == "check_all" or so.stroke_widths is True:
|
|
inkex.utils.debug("\n---------- Stroke widths")
|
|
strokeWidths = []
|
|
for element in shapes:
|
|
style = element.get('style')
|
|
if style is not None:
|
|
stroke_width = re.search('stroke-width:(.*?)(;|$)', style)
|
|
if stroke_width is not None:
|
|
strokeWidth = self.svg.unittouu(stroke_width[0].split("stroke-width:")[1].split(";")[0])
|
|
if strokeWidth not in strokeWidths:
|
|
strokeWidths.append(strokeWidth)
|
|
inkex.utils.debug("{} different stroke widths in total".format(len(strokeWidths)))
|
|
for strokeWidth in strokeWidths:
|
|
inkex.utils.debug("stroke width {}".format(strokeWidth))
|
|
|
|
|
|
'''
|
|
Cosmetic dashes cause simulation issues and are no real cut paths. It's similar to the thing
|
|
with clip paths. Please convert lines to real dash segments if you want to laser them.
|
|
'''
|
|
if so.checks == "check_all" or so.cosmestic_dashes is True:
|
|
inkex.utils.debug("\n---------- Cosmetic dashes - should be converted to paths")
|
|
strokeDasharrays = []
|
|
for element in shapes:
|
|
style = element.get('style')
|
|
if style is not None:
|
|
stroke_dasharray = re.search('stroke-dasharray:(.*?)(;|$)', style)
|
|
if stroke_dasharray is not None:
|
|
strokeDasharray = stroke_dasharray[0].split("stroke-dasharray:")[1].split(";")[0]
|
|
if strokeDasharray not in strokeDasharrays:
|
|
strokeDasharrays.append(strokeDasharray)
|
|
inkex.utils.debug("{} different stroke dash arrays in total".format(len(strokeDasharrays)))
|
|
for strokeDasharray in strokeDasharrays:
|
|
inkex.utils.debug("stroke dash array {}".format(strokeDasharray))
|
|
|
|
|
|
'''
|
|
Shapes/paths with the same color like the background, 0% opacity, etc. lead to strange
|
|
laser cutting results, like duplicated edges, enlarged laser times and more. Please double
|
|
check for such occurences.
|
|
'''
|
|
if so.checks == "check_all" or so.invisible_shapes is True:
|
|
inkex.utils.debug("\n---------- Invisible shapes")
|
|
invisibles = []
|
|
for element in shapes:
|
|
if element.tag not in (inkex.addNS('tspan','svg')):
|
|
style = element.get('style')
|
|
if style is not None:
|
|
stroke = re.search('stroke:(.*?)(;|$)', style) #filter white on white (we guess the background color of the document is white too but we do not check)
|
|
if stroke is None:
|
|
strokeVis = 0
|
|
elif stroke[0].split("stroke:")[1].split(";")[0] == 'none':
|
|
strokeVis = 0
|
|
elif stroke[0].split("stroke:")[1].split(";")[0] in ('#ffffff', 'white', 'rgb(255,255,255)'):
|
|
strokeVis = 0
|
|
else:
|
|
strokeVis = 1
|
|
|
|
stroke_width = re.search('stroke-width:(.*?)(;|$)', style)
|
|
if stroke_width is None:
|
|
widthVis = 0
|
|
elif self.svg.unittouu(stroke_width[0].split("stroke-width:")[1].split(";")[0]) < 0.005: #really thin (0,005pc = 0,080px)
|
|
widthVis = 0
|
|
else:
|
|
widthVis = 1
|
|
|
|
stroke_opacity = re.search('stroke-opacity:(.*?)(;|$)', style)
|
|
if stroke_opacity is None:
|
|
strokeOpacityVis = 0
|
|
elif float(stroke_opacity[0].split("stroke-opacity:")[1].split(";")[0]) < 0.05: #nearly invisible (<5% opacity)
|
|
strokeOpacityVis = 0
|
|
else:
|
|
strokeOpacityVis = 1
|
|
|
|
if pagecolor == '#ffffff':
|
|
invisColors = [pagecolor, 'white', 'rgb(255,255,255)']
|
|
else:
|
|
invisColors = [pagecolor] #we could add some parser to convert pagecolor to rgb/hsl/cmyk
|
|
fill = re.search('fill:(.*?)(;|$)', style)
|
|
if fill is None:
|
|
fillVis = 0
|
|
elif fill[0].split("fill:")[1].split(";")[0] == 'none':
|
|
fillVis = 0
|
|
elif fill[0].split("fill:")[1].split(";")[0] in invisColors:
|
|
fillVis = 0
|
|
else:
|
|
fillVis = 1
|
|
|
|
fill_opacity = re.search('fill-opacity:(.*?)(;|$)', style)
|
|
if fill_opacity is None:
|
|
fillOpacityVis = 0
|
|
elif float(fill_opacity[0].split("fill-opacity:")[1].split(";")[0]) < 0.05: #nearly invisible (<5% opacity)
|
|
fillOpacityVis = 0
|
|
else:
|
|
fillOpacityVis = 1
|
|
|
|
#inkex.utils.debug("strokeVis={}, widthVis={}, strokeOpacityVis={}, fillVis={}, fillOpacityVis={}".format(strokeVis, widthVis, strokeOpacityVis, fillVis, fillOpacityVis))
|
|
if (strokeVis == 0 or widthVis == 0 or strokeOpacityVis == 0) and (fillVis == 0 or fillOpacityVis == 0):
|
|
if element not in invisibles:
|
|
invisibles.append(element)
|
|
inkex.utils.debug("{} invisible shapes in total".format(len(invisibles)))
|
|
for invisible in invisibles:
|
|
inkex.utils.debug("id={}".format(invisible.get('id')))
|
|
|
|
|
|
'''
|
|
Additionally, stroke opacities less than 1.0 cause problems in most laser softwares. Please
|
|
adjust all strokes to use full opacity.
|
|
'''
|
|
if so.checks == "check_all" or so.stroke_opacities is True:
|
|
inkex.utils.debug("\n---------- Objects with stroke transparencies < 1.0 - should be set to 1.0")
|
|
transparencies = []
|
|
for element in shapes:
|
|
style = element.get('style')
|
|
if style is not None:
|
|
stroke_opacity = re.search('stroke-opacity:(.*?)(;|$)', style)
|
|
if stroke_opacity is not None:
|
|
if float(stroke_opacity[0].split("stroke-opacity:")[1].split(";")[0]) < 1.0:
|
|
if element not in transparencies:
|
|
transparencies.append(element)
|
|
inkex.utils.debug("{} objects with stroke transparencies < 1.0 in total".format(len(transparencies)))
|
|
for transparency in transparencies:
|
|
inkex.utils.debug("id={}".format(transparency.get('id')))
|
|
|
|
'''
|
|
We look for paths which are just points. Those are useless in case of lasercutting.
|
|
Note: this scan only works for paths, not for subpaths. If so, you need to break apart before
|
|
'''
|
|
if so.checks == "check_all" or so.pointy_paths is True:
|
|
inkex.utils.debug("\n---------- Pointy paths - should be deleted")
|
|
pointyPaths = []
|
|
for element in shapes:
|
|
if isinstance(element, inkex.PathElement):
|
|
p = element.path
|
|
commandsCoords = p.to_arrays()
|
|
if len(commandsCoords) == 1 or \
|
|
(len(commandsCoords) == 2 and commandsCoords[0][1] == commandsCoords[1][1]) or \
|
|
(len(commandsCoords) == 2 and commandsCoords[-1][0] == 'Z') or \
|
|
(len(commandsCoords) == 3 and commandsCoords[0][1] == commandsCoords[1][1] and commandsCoords[2][1] == 'Z'):
|
|
pointyPaths.append(element)
|
|
inkex.utils.debug("{} pointy paths in total".format(len(pointyPaths)))
|
|
for pointyPath in pointyPaths:
|
|
inkex.utils.debug("id={}".format(pointyPath.get('id')))
|
|
|
|
|
|
'''
|
|
Transformations often lead to wrong stroke widths or mis-rendering in end software. The best we
|
|
can do with a final SVG is to remove all relative translations, rotations and scalings. We should
|
|
apply absolute coordinates only.
|
|
'''
|
|
if so.checks == "check_all" or so.transformations is True:
|
|
inkex.utils.debug("\n---------- Transformations - should be applied to absolute")
|
|
transformations = []
|
|
for element in shapes:
|
|
if element.get('transform') is not None:
|
|
transformations.append(element)
|
|
inkex.utils.debug("{} transformation in total".format(len(transformations)))
|
|
for transformation in transformations:
|
|
inkex.utils.debug("transformation in id={}".format(transformation.get('id')))
|
|
|
|
'''
|
|
Really short paths can cause issues with laser cutter mechanics and should be avoided to
|
|
have healthier stepper motor belts, etc.
|
|
'''
|
|
if so.checks == "check_all" or so.short_paths is True:
|
|
inkex.utils.debug("\n---------- Short paths (< {} mm)".format(so.short_paths_min))
|
|
shortPaths = []
|
|
totalLength = 0
|
|
totalDropLength = 0
|
|
for element in shapes:
|
|
if isinstance(element, inkex.PathElement):
|
|
slengths, stotal = csplength(element.path.transform(element.composed_transform()).to_superpath())
|
|
totalLength += stotal
|
|
if stotal < self.svg.unittouu(str(so.short_paths_min) + "mm"):
|
|
shortPaths.append(element)
|
|
totalDropLength += stotal
|
|
inkex.utils.debug("{} short paths in total".format(len(shortPaths)))
|
|
if totalLength > 0:
|
|
inkex.utils.debug("{:0.2f}% of total ({:0.2f} mm /{:0.2f} mm)".format(totalDropLength / totalLength, totalDropLength, totalLength))
|
|
for shortPath in shortPaths:
|
|
inkex.utils.debug("id={}".format(shortPath.get('id')))
|
|
|
|
|
|
'''
|
|
Shapes like rectangles, ellipses, arcs, spirals should be converted to svg:path to have more
|
|
convenience in the file
|
|
'''
|
|
if so.checks == "check_all" or so.non_path_shapes is True:
|
|
inkex.utils.debug("\n---------- Non-path shapes - should be converted to paths")
|
|
nonPathShapes = []
|
|
for element in shapes:
|
|
if not isinstance(element, inkex.PathElement) and not isinstance(element, inkex.Group):
|
|
nonPathShapes.append(element)
|
|
inkex.utils.debug("{} non-path shapes in total".format(len(nonPathShapes)))
|
|
for nonPathShape in nonPathShapes:
|
|
inkex.utils.debug("id={}".format(nonPathShape.get('id')))
|
|
|
|
exit(0)
|
|
|
|
if __name__ == '__main__':
|
|
LaserCheck().run() |