2021-04-02 10:20:08 +02:00
#!/usr/bin/env python3
2021-04-02 23:07:28 +02:00
# suppress some nasty warnings we don't want. Note that this is really generic. For developing purposes re-enable this to see errors/deprecations
2021-04-02 10:20:08 +02:00
import logging
for key in logging . Logger . manager . loggerDict :
print ( key )
logging . getLogger ( ) . setLevel ( logging . CRITICAL )
2021-04-02 23:07:28 +02:00
#for name, logger in logging.root.manager.loggerDict.items():
# logger.disabled=True
#import warnings
#warnings.filterwarnings("ignore", category=DeprecationWarning)
#warnings.filterwarnings('always', category=DeprecationWarning)
#with warnings.catch_warnings():
# warnings.simplefilter("ignore", category=DeprecationWarning)
#def noop(*args, **kargs): pass
#warnings.warn = noop
#logging.captureWarnings(True)
2021-04-02 10:20:08 +02:00
import sys
import os
from lxml import etree
import inkex
2021-04-02 23:07:28 +02:00
from inkex import transforms , bezier
2021-04-02 10:20:08 +02:00
from inkex . paths import CubicSuperPath
from inkex . command import inkscape
import vpype
import vpype_viewer
from vpype_viewer import ViewMode
from vpype_cli import execute
from shapely . geometry import LineString , Point
"""
Extension for InkScape 1. X
Author : Mario Voigt / FabLab Chemnitz
Mail : mario . voigt @stadtfabrikanten.org
2021-04-02 23:07:28 +02:00
Date : 02.04 .2021
2021-04-03 18:34:01 +02:00
Last patch : 03.04 .2021
2021-04-02 10:20:08 +02:00
License : GNU GPL v3
2021-04-03 18:34:01 +02:00
Used ( tested ) version of vpype : commit id https : / / github . com / abey79 / vpype / commit / 0b0 dc8dd7e32998dbef639f9db578c3bff02690b ( 29.03 .2021 )
Used ( tested ) version of vpype occult : commit id https : / / github . com / LoicGoulefert / occult / commit / 2 d04ca57d69078755c340066c226fd6cd927d41e ( 04.02 .2021 )
2021-04-02 10:20:08 +02:00
CLI / API docs :
- https : / / vpype . readthedocs . io / en / stable / api / vpype_cli . html #module-vpype_cli
- https : / / vpype . readthedocs . io / en / stable / api / vpype . html #module-vpype
vpype commands could be performed differently :
- 1. Work with current selection ( line - wise ) : we could get the selected nodes / groups and check if those nodes are paths . If yes we could convert them to polylines and put it into vpype using doc . add ( LineCollection , Layer )
- 2. We could execute vpype on the complete document only ( svg file handling , possible as one layer or multiple layers )
working line of code ( example : ) doc = vpype . read_multilayer_svg ( self . options . input_file , quantization = 0.1 , crop = False , simplify = False , parallel = False )
Todo ' s
- https : / / github . com / abey79 / vpype / issues / 243
2021-04-02 23:07:28 +02:00
- command chain is slow on Windows
- add some debugging options to remove deprecation warnings
2021-04-03 18:34:01 +02:00
- allow to select other units than mm for tolerance , trimming , . . .
2021-04-03 18:34:58 +02:00
- fix occult plugin sometimes not removing duplicate lines . maybe different stroke widths ?
2021-04-02 10:20:08 +02:00
"""
class vpypetools ( inkex . EffectExtension ) :
def __init__ ( self ) :
inkex . Effect . __init__ ( self )
2021-04-02 23:07:28 +02:00
# Line Sorting
2021-04-03 18:34:01 +02:00
self . arg_parser . add_argument ( " --linesort " , type = inkex . Boolean , default = False )
2021-04-02 10:20:08 +02:00
self . arg_parser . add_argument ( " --linesort_no_flip " , type = inkex . Boolean , default = False , help = " Disable reversing stroke direction for optimization " )
2021-04-02 23:07:28 +02:00
# Line Merging
2021-04-03 18:34:01 +02:00
self . arg_parser . add_argument ( " --linemerge " , type = inkex . Boolean , default = False )
self . arg_parser . add_argument ( " --linemerge_tolerance " , type = float , default = 0.500 , help = " Maximum distance between two line endings that should be merged (default 0.500 mm) " )
self . arg_parser . add_argument ( " --linemerge_no_flip " , type = inkex . Boolean , default = False , help = " Disable reversing stroke direction for merging " )
2021-04-02 23:07:28 +02:00
2021-04-03 18:34:01 +02:00
# Trimming
self . arg_parser . add_argument ( " --trim " , type = inkex . Boolean , default = False )
self . arg_parser . add_argument ( " --trim_x_margin " , type = float , default = 0.000 , help = " trim margin - x direction (mm) " ) # keep default at 0.000 to keep clean bbox
self . arg_parser . add_argument ( " --trim_y_margin " , type = float , default = 0.000 , help = " trim margin - y direction (mm) " ) # keep default at 0.000 to keep clean bbox
# Relooping
self . arg_parser . add_argument ( " --reloop " , type = inkex . Boolean , default = False )
self . arg_parser . add_argument ( " --reloop_tolerance " , type = float , default = 0.500 , help = " Controls how close the path beginning and end must be to consider it closed (default 0.500 mm) " )
# Multipass
self . arg_parser . add_argument ( " --multipass " , type = inkex . Boolean , default = False )
self . arg_parser . add_argument ( " --multipass_count " , type = int , default = 2 , help = " How many passes for each line (default 2) " )
# Filter
self . arg_parser . add_argument ( " --filter " , type = inkex . Boolean , default = False )
self . arg_parser . add_argument ( " --filter_tolerance " , type = float , default = 0.050 , help = " Tolerance used to determined if a line is closed or not (default 0.050 mm) " )
self . arg_parser . add_argument ( " --filter_closed " , type = inkex . Boolean , default = False , help = " Keep closed lines " )
self . arg_parser . add_argument ( " --filter_not_closed " , type = inkex . Boolean , default = False , help = " Keep open lines " )
self . arg_parser . add_argument ( " --filter_min_length_enabled " , type = inkex . Boolean , default = False , help = " filter by min length " )
self . arg_parser . add_argument ( " --filter_min_length " , type = float , default = 0.000 , help = " Keep lines whose length isn ' t shorter than value " )
self . arg_parser . add_argument ( " --filter_max_length_enabled " , type = inkex . Boolean , default = False , help = " filter by max length " )
self . arg_parser . add_argument ( " --filter_max_length " , type = float , default = 0.000 , help = " Keep lines whose length isn ' t greater than value " )
# Plugin Occult
self . arg_parser . add_argument ( " --plugin_occult " , type = inkex . Boolean , default = False )
self . arg_parser . add_argument ( " --plugin_occult_tolerance " , type = float , default = 0.01 , help = " Max distance between start and end point to consider a path closed (default 0.01 mm) " )
2021-04-02 23:07:28 +02:00
# General Settings
self . arg_parser . add_argument ( " --flattenbezier " , type = inkex . Boolean , default = False , help = " Flatten bezier curves to polylines " )
self . arg_parser . add_argument ( " --flatness " , type = float , default = 0.1 , help = " Minimum flatness = 0.1. The smaller the value the more fine segments you will get. " )
2021-04-02 10:20:08 +02:00
self . arg_parser . add_argument ( " --apply_transformations " , type = inkex . Boolean , default = False , help = " Run ' Apply Transformations ' extension before running vpype. Helps avoiding geometry shifting " )
self . arg_parser . add_argument ( " --output_show " , type = inkex . Boolean , default = False , help = " This will open a new matplotlib window showing modified SVG data " )
self . arg_parser . add_argument ( " --output_stats " , type = inkex . Boolean , default = False , help = " Show output statistics before/after conversion " )
self . arg_parser . add_argument ( " --output_trajectories " , type = inkex . Boolean , default = False , help = " Add paths for the travel trajectories " )
2021-04-02 23:07:28 +02:00
self . arg_parser . add_argument ( " --keep_selection " , type = inkex . Boolean , default = False , help = " If false, selected paths will be removed " )
self . arg_parser . add_argument ( " --strokes_to_paths " , type = inkex . Boolean , default = True , help = " Recommended option. Performs ' Path ' > ' Stroke to Path ' (CTRL + ALT + C) to convert vpype converted lines back to regular path objects " )
2021-04-02 10:20:08 +02:00
def effect ( self ) :
lc = vpype . LineCollection ( ) # create a new array of LineStrings consisting of Points. We convert selected paths to polylines and grab their points
nodesToConvert = [ ] # we make an array of all collected nodes to get the boundingbox of that array. We need it to place the vpype converted stuff to the correct XY coordinates
applyTransformAvailable = False
# at first we apply external extension
try :
sys . path . append ( " .. " ) # add parent directory to path to allow importing applytransform (vpype extension is encapsulated in sub directory)
import applytransform
applyTransformAvailable = True
except Exception as e :
# inkex.utils.debug(e)
inkex . utils . debug ( " Calling ' Apply Transformations ' extension failed. Maybe the extension is not installed. You can download it from official InkScape Gallery. Skipping this step " )
2021-04-02 23:07:28 +02:00
def flatten ( node ) :
path = node . path . to_superpath ( )
bezier . cspsubdiv ( path , self . options . flatness )
newpath = [ ]
for subpath in path :
first = True
for csp in subpath :
cmd = ' L '
if first :
cmd = ' M '
first = False
newpath . append ( [ cmd , [ csp [ 1 ] [ 0 ] , csp [ 1 ] [ 1 ] ] ] )
node . path = newpath
2021-04-02 10:20:08 +02:00
def convertPath ( node ) :
if node . tag == inkex . addNS ( ' path ' , ' svg ' ) :
nodesToConvert . append ( node )
2021-04-02 23:07:28 +02:00
if self . options . flattenbezier is True :
flatten ( node )
2021-04-02 10:20:08 +02:00
d = node . get ( ' d ' )
p = CubicSuperPath ( d )
points = [ ]
for subpath in p :
for csp in subpath :
points . append ( Point ( csp [ 1 ] [ 0 ] , csp [ 1 ] [ 1 ] ) )
lc . append ( LineString ( points ) )
children = node . getchildren ( )
if children is not None :
for child in children :
convertPath ( child )
# inkex.utils.debug(str(applyTransformAvailable)) #check if ApplyTransform Extension is available. If yes we use it
if self . options . apply_transformations is True and applyTransformAvailable is True :
applytransform . ApplyTransform ( ) . recursiveFuseTransform ( self . document . getroot ( ) )
# getting the bounding box of the current selection. We use to calculate the offset XY from top-left corner of the canvas. This helps us placing back the elements
2021-04-03 18:34:01 +02:00
input_bbox = None
2021-04-02 10:20:08 +02:00
if len ( self . svg . selected ) == 0 :
convertPath ( self . document . getroot ( ) )
for element in nodesToConvert :
2021-04-03 18:34:01 +02:00
input_bbox + = element . bounding_box ( )
2021-04-02 10:20:08 +02:00
else :
for id , item in self . svg . selected . items ( ) :
convertPath ( item )
2021-04-03 18:34:01 +02:00
input_bbox = inkex . elements . _selected . ElementList . bounding_box ( self . svg . selected ) # get BoundingBox for selection
2021-04-02 10:20:08 +02:00
2021-04-03 18:34:01 +02:00
# inkex.utils.debug(input_bbox)
2021-04-02 10:20:08 +02:00
2021-04-03 18:34:01 +02:00
# lc.as_mls() #cast LineString array to MultiLineString
2021-04-02 10:20:08 +02:00
if len ( lc ) == 0 :
2021-04-02 23:07:28 +02:00
inkex . errormsg ( ' Selection appears to be empty or does not contain any valid svg:path nodes. Try to cast your objects to paths using CTRL + SHIFT + C or strokes to paths using CTRL + ALT+ C ' )
2021-04-02 10:20:08 +02:00
return
doc = vpype . Document ( ) #create new vpype document
# we add the lineCollection (converted selection) to the vpype document
doc . add ( lc , layer_id = None )
2021-04-03 18:34:01 +02:00
tooling_length_before = doc . length ( )
traveling_length_before = doc . pen_up_length ( )
2021-04-02 10:20:08 +02:00
# build and execute the conversion command
2021-04-03 18:34:01 +02:00
# the following code block is not intended to sum up the commands to build a series (pipe) of commands!
2021-04-02 23:07:28 +02:00
##########################################
# Line Sort
if self . options . linesort is True :
command = " linesort "
if self . options . linesort_no_flip is True :
command + = " --no-flip "
# Line Merging
if self . options . linemerge is True :
command = " linemerge --tolerance " + str ( self . options . linemerge_tolerance )
if self . options . linemerge_no_flip is True :
2021-04-03 18:34:01 +02:00
command + = " --no-flip "
# Trimming
if self . options . trim is True :
command = " trim " + str ( self . options . trim_x_margin ) + " " + str ( self . options . trim_y_margin )
# Relooping
if self . options . reloop is True :
command = " reloop --tolerance " + str ( self . options . reloop_tolerance )
# Multipass
if self . options . multipass is True :
command = " multipass --count " + str ( self . options . multipass_count )
# Filter
if self . options . filter is True :
command = " filter --tolerance " + str ( self . options . filter_tolerance )
if self . options . filter_min_length_enabled is True :
command + = " --min-length " + str ( self . options . filter_min_length )
if self . options . filter_max_length_enabled is True :
command + = " --max-length " + str ( self . options . filter_max_length )
if self . options . filter_closed is True :
command + = " --closed "
if self . options . filter_not_closed is True :
command + = " --not-closed "
if self . options . filter_closed is False and self . options . filter_not_closed is False and self . options . filter_min_length_enabled is False and self . options . filter_max_length_enabled is False :
inkex . errormsg ( ' No filters to apply. Please select at least one filter. ' )
return
# Plugin Occult
if self . options . plugin_occult is True :
command = " occult --tolerance " + str ( self . options . plugin_occult_tolerance )
# inkex.utils.debug(command)
2021-04-02 10:20:08 +02:00
doc = execute ( command , doc )
2021-04-02 23:07:28 +02:00
##########################################
2021-04-03 18:34:01 +02:00
tooling_length_after = doc . length ( )
traveling_length_after = doc . pen_up_length ( )
if tooling_length_before > 0 :
tooling_length_saving = ( 1.0 - tooling_length_after / tooling_length_before ) * 100.0
else :
tooling_length_saving = 0.0
if traveling_length_before > 0 :
traveling_length_saving = ( 1.0 - traveling_length_after / traveling_length_before ) * 100.0
else :
traveling_length_saving = 0.0
2021-04-02 10:20:08 +02:00
if self . options . output_stats is True :
inkex . utils . debug ( ' Total tooling length before vpype conversion: ' + str ( ' {:0.2f} ' . format ( tooling_length_before ) ) + ' mm ' )
inkex . utils . debug ( ' Total traveling length before vpype conversion: ' + str ( ' {:0.2f} ' . format ( traveling_length_before ) ) + ' mm ' )
inkex . utils . debug ( ' Total tooling length after vpype conversion: ' + str ( ' {:0.2f} ' . format ( tooling_length_after ) ) + ' mm ' )
inkex . utils . debug ( ' Total traveling length after vpype conversion: ' + str ( ' {:0.2f} ' . format ( traveling_length_after ) ) + ' mm ' )
inkex . utils . debug ( ' Total tooling length optimized: ' + str ( ' {:0.2f} ' . format ( tooling_length_saving ) ) + ' % ' )
inkex . utils . debug ( ' Total traveling length optimized: ' + str ( ' {:0.2f} ' . format ( traveling_length_saving ) ) + ' % ' )
2021-04-03 18:34:01 +02:00
if tooling_length_after == 0 :
inkex . errormsg ( ' No lines left after vpype conversion. Conversion result is empty. Cannot continue ' )
return
# show the vpype document visually
# there are missing options to set pen_width and pen_opacity. This is anchored in "Engine" class
if self . options . output_show :
vpype_viewer . show ( doc , view_mode = ViewMode . PREVIEW , show_pen_up = self . options . output_trajectories , show_points = False , argv = None ) # https://vpype.readthedocs.io/en/stable/api/vpype_viewer.ViewMode.html
2021-04-02 10:20:08 +02:00
# save the vpype document to new svg file and close it afterwards
output_file = self . options . input_file + " .vpype.svg "
output_fileIO = open ( output_file , " w " , encoding = " utf-8 " )
vpype . write_svg ( output_fileIO , doc , page_size = None , center = False , source_string = ' ' , layer_label_format = ' %d ' , show_pen_up = self . options . output_trajectories , color_mode = ' layer ' )
output_fileIO . close ( )
# convert vpype polylines/lines/polygons to regular paths again. We need to use "--with-gui" to respond to "WARNING: ignoring verb FileSave - GUI required for this verb."
2021-04-02 23:07:28 +02:00
if self . options . strokes_to_paths is True :
cli_output = inkscape ( output_file , " --with-gui " , actions = " EditSelectAllInAllLayers;EditUnlinkClone;ObjectToPath;FileSave;FileQuit " )
if len ( cli_output ) > 0 :
self . debug ( _ ( " Inkscape returned the following output when trying to run the vpype object to path back-conversion: " ) )
self . debug ( cli_output )
2021-04-02 10:20:08 +02:00
# new parse the SVG file and insert it as new group into the current document tree
2021-04-03 18:34:01 +02:00
# vpype_svg = etree.parse(output_file).getroot().xpath("//svg:g", namespaces=inkex.NSS)
# when using the trim function the bounding box of the original elements will not match the vpype'd ones. So we need to do some calculation for exact placement
2021-04-02 23:07:28 +02:00
# the label id is the number of layer_id=None (will start with 1)
lines = etree . parse ( output_file ) . getroot ( ) . xpath ( " //svg:g[@inkscape:label= ' 1 ' ] " , namespaces = inkex . NSS )
vpypeLinesGroup = self . document . getroot ( ) . add ( inkex . Group ( ) )
vpypeLinesGroup . set ( ' style ' , ' stroke:#000000;stroke-width:1px;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;fill:none ' )
for item in lines :
for child in item . getchildren ( ) :
vpypeLinesGroup . append ( child )
2021-04-03 18:34:01 +02:00
# get the output's bounding box size (which might be other size than previous input bounding box, e.g. when using trim feature)
# output_bbox = vpypeLinesGroup.bounding_box()
translation = ' translate( ' + str ( input_bbox . left + self . options . trim_x_margin ) + ' , ' + str ( input_bbox . top + self . options . trim_y_margin ) + ' ) ' # we use the same translation for trajectory lines
vpypeLinesGroup . attrib [ ' transform ' ] = translation
2021-04-02 23:07:28 +02:00
vpypeLinesGroupId = self . svg . get_unique_id ( ' vpypetools-lines- ' )
vpypeLinesGroup . set ( ' id ' , vpypeLinesGroupId )
2021-04-03 18:34:01 +02:00
# inkex.utils.debug(self.svg.selection.first()) # get the first selected element. Chould be None
self . svg . selection . set ( vpypeLinesGroupId )
# inkex.utils.debug(self.svg.selection.first()) # get the first selected element again to check if changing selection has worked
2021-04-02 23:07:28 +02:00
if self . options . output_trajectories is True :
trajectories = etree . parse ( output_file ) . getroot ( ) . xpath ( " //svg:g[@id= ' pen_up_trajectories ' ] " , namespaces = inkex . NSS )
vpypeTrajectoriesGroup = self . document . getroot ( ) . add ( inkex . Group ( ) )
vpypeTrajectoriesGroup . set ( ' style ' , ' stroke:#0000ff;stroke-width:1px;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;fill:none ' )
for item in trajectories :
for child in item . getchildren ( ) :
vpypeTrajectoriesGroup . append ( child )
2021-04-03 18:34:01 +02:00
vpypeTrajectoriesGroup . attrib [ ' transform ' ] = translation
2021-04-02 23:07:28 +02:00
vpypeTrajectoriesId = self . svg . get_unique_id ( ' vpypetools-trajectories- ' )
vpypeTrajectoriesGroup . set ( ' id ' , vpypeTrajectoriesId )
2021-04-03 18:34:01 +02:00
self . svg . selection . add ( vpypeTrajectoriesId ) # we also add trajectories to selection to remove translations in following step
2021-04-02 10:20:08 +02:00
# we apply transformations also for new group to remove the "translate()" again
if self . options . apply_transformations and applyTransformAvailable :
for node in self . svg . selection :
applytransform . ApplyTransform ( ) . recursiveFuseTransform ( node )
# Delete the temporary file again because we do not need it anymore
if os . path . exists ( output_file ) :
os . remove ( output_file )
# Remove selection objects to do a real replace with new objects from vpype document
if self . options . keep_selection is False :
for node in nodesToConvert :
node . getparent ( ) . remove ( node )
if __name__ == ' __main__ ' :
2021-04-02 23:07:28 +02:00
vpypetools ( ) . run ( )