2022-09-20 18:29:28 +02:00
#!/usr/bin/env python3
#
# Inkscape extension making long continuous paths from shorter pieces.
# (C) 2015 juewei@fabmail.org
#
# code snippets visited to learn the extension 'effect' interface:
# - convert2dashes.py
# - http://github.com/jnweiger/inkscape-silhouette
# - http://github.com/jnweiger/inkscape-gears-dev
# - http://sourceforge.net/projects/inkcut/
# - http://code.google.com/p/inkscape2tikz/
# - http://code.google.com/p/eggbotcode/
#
# 2015-11-15 jw, V0.1 -- initial draught
# 2015-11-16 jw, V0.2 -- fixed endpoints after chaining.
# 2015-11-16 jw, V0.3 -- all possible chains connected. Yeah
# 2015-11-16 jw, V0.4 -- gui fully functional.
# 2015-11-26 jw, V0.5 -- HACK to resolve some self-reversing path segments.
# https://github.com/fablabnbg/inkscape-chain-paths/issues/1
# 2020-04-10 jw, V0.6 -- Close paths correctly. Self reversing path hack was too eager.
# Workaround for cubicsuperpath.parsePath/formatPath limitation.
# Started python3 compatibility.
# 2020-05-27 vi, V0.7 -- Upgrade to Inkscape 1.0. Write debug info to inkex debug dialog.
from __future__ import print_function
__version__ = ' 0.7 ' # Keep in sync with chain_paths.inx ca line 22
__author__ = ' Juergen Weigert <juergen@fabmail.org> '
__credits__ = [ ' Juergen Weigert ' , ' Veronika Irvine ' ]
import sys
import math
import re
import inkex
from inkex . paths import CubicSuperPath , Path
from optparse import SUPPRESS_HELP
class ChainPaths ( inkex . EffectExtension ) :
2024-01-18 11:56:30 +01:00
def __init__ ( self ) :
inkex . Effect . __init__ ( self )
# 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 . chain_epsilon = 0.01
self . snap_ends = True
self . close_loops = True
self . segments_done = { }
self . min_missed_distance_sq = None
self . chained_count = 0
self . arg_parser . add_argument ( ' -V ' , ' --version ' , type = inkex . Boolean , default = False , help = ' Just print version number ( " ' + __version__ + ' " ) and exit. ' )
self . arg_parser . add_argument ( ' -s ' , ' --snap_ends ' , type = inkex . Boolean , default = True , help = ' snap end-points together when connecting ' )
self . arg_parser . add_argument ( ' -c ' , ' --close_loops ' , type = inkex . Boolean , default = True , help = ' close loops (start/end of the same path) ' )
self . arg_parser . add_argument ( ' -l ' , ' --limit ' , type = int , default = 2000 , help = ' Maximum items to process ' )
self . arg_parser . add_argument ( ' -u ' , ' --units ' , default = " mm " , help = " measurement unit for epsilon " )
self . arg_parser . add_argument ( ' -e ' , ' --chain_epsilon ' , type = float , default = 0.01 , help = " Max. distance to connect [mm] " )
self . arg_parser . add_argument ( ' -d ' , ' --debug ' , type = inkex . Boolean , default = False , help = ' Debug ' )
def version ( self ) :
return __version__
def author ( self ) :
return __author__
def calc_unit_factor ( self , units = ' mm ' ) :
""" return the scale factor for all dimension conversions.
- The document units are always irrelevant as
everything in inkscape is expected to be in 90 dpi pixel units
"""
dialog_units = self . svg . unittouu ( str ( 1.0 ) + units )
self . unit_factor = 1.0 / dialog_units
return self . unit_factor
def reverse_segment ( self , seg ) :
r = [ ]
for s in reversed ( seg ) :
# s has 3 elements: handle1, point, handle2
# Swap handles.
s . reverse ( )
r . append ( s )
return r
def set_segment_done ( self , so , id , n , msg = ' ' ) :
if not id in self . segments_done :
self . segments_done [ id ] = { }
self . segments_done [ id ] [ n ] = True
if so . debug : inkex . utils . debug ( " done {} {} {} " . format ( id , n , msg ) )
def is_segment_done ( self , id , n ) :
if not id in self . segments_done :
return False
if n in self . segments_done [ id ] :
return True
return False
def link_segments ( self , seg1 , seg2 ) :
if self . snap_ends :
seg = seg1 [ : - 1 ]
p1 = seg1 [ - 1 ]
p2 = seg2 [ 0 ]
# fuse p1 and p2 to create one new point:
# first handle from p1, point coordinates averaged, second handle from p2
seg . append ( [
[ p1 [ 0 ] [ 0 ] , p1 [ 0 ] [ 1 ] ] ,
[ ( p1 [ 1 ] [ 0 ] + p2 [ 1 ] [ 0 ] ) * .5 , ( p1 [ 1 ] [ 1 ] + p2 [ 1 ] [ 1 ] ) * .5 ] ,
[ p2 [ 2 ] [ 0 ] , p2 [ 2 ] [ 1 ] ]
] )
seg . extend ( seg2 [ 1 : ] )
else :
seg = seg1 [ : ]
seg . extend ( seg2 [ : ] )
self . chained_count + = 1
return seg
def near_ends ( self , end1 , end2 ) :
""" requires self.eps_sq to be the square of the near distance """
dx = end1 [ 0 ] - end2 [ 0 ]
dy = end1 [ 1 ] - end2 [ 1 ]
d_sq = dx * dx + dy * dy
if d_sq > self . eps_sq :
if self . min_missed_distance_sq is None :
self . min_missed_distance_sq = d_sq
elif self . min_missed_distance_sq > d_sq :
self . min_missed_distance_sq = d_sq
return False
else :
return True
def effect ( self ) :
so = self . options
if so . version :
print ( __version__ )
sys . exit ( 0 )
self . calc_unit_factor ( so . units )
if so . snap_ends is not None : self . snap_ends = so . snap_ends
if so . close_loops is not None : self . close_loops = so . close_loops
if so . chain_epsilon is not None : self . chain_epsilon = so . chain_epsilon
if self . chain_epsilon < 0.001 : self . chain_epsilon = 0.001 # keep a minimum.
self . eps_sq = self . chain_epsilon * self . unit_factor * self . chain_epsilon * self . unit_factor
selected = self . svg . selected . items ( )
itemsCount = len ( selected )
if not itemsCount :
inkex . errormsg ( " Please select one or more objects. " )
return
#selected = dict(reversed(list(selected))) #reverse
if so . limit > 0 and itemsCount > so . limit :
inkex . utils . debug ( " Maximum items to process is set to {} . You selected {} items. We continue with processing until limit is reached. " . format ( so . limit , itemsCount ) )
segments = [ ]
workedon = 0
for id , node in selected :
if node . tag != inkex . addNS ( ' path ' , ' svg ' ) :
inkex . errormsg ( " Object id {} is not a path. Try \n - Path->Object to Path \n - Object->Ungroup " . format ( node . get ( ' id ' ) ) )
return
#check if node has a transform. If yes, print a warning
if node . get ( ' transform ' ) is not None :
inkex . utils . debug ( " Warning: node {} has transform {} . Use ' Apply Transforms ' extension before to handle this. " . format ( node . get ( ' id ' ) , node . get ( ' transform ' ) ) )
return
2024-04-02 02:53:04 +02:00
if so . debug : inkex . utils . debug ( " id= {} , tag= " . format ( node . get ( ' id ' ) , node . tag ) )
2024-01-18 11:56:30 +01:00
path_d = CubicSuperPath ( Path ( node . get ( ' d ' ) ) )
sub_idx = - 1
for sub in path_d :
sub_idx + = 1
# sub = [[[200.0, 300.0], [200.0, 300.0], [175.0, 290.0]], [[175.0, 265.0], [220.37694, 256.99876], [175.0, 240.0]], [[175.0, 215.0], [200.0, 200.0], [200.0, 200.0]]]
# this is a path of three points. All the bezier handles are included. the Structure is:
# [[handle0_OUT, point0, handle0_1], [handle1_0, point1, handle1_2], [handle2_1, point2, handle2_OUT]]
# the _OUT handles at the end of the path are ignored. The data structure has them identical to their points.
#
if so . debug : inkex . utils . debug ( " sub= {} " . format ( sub ) )
end1 = [ sub [ 0 ] [ 1 ] [ 0 ] , sub [ 0 ] [ 1 ] [ 1 ] ]
end2 = [ sub [ - 1 ] [ 1 ] [ 0 ] , sub [ - 1 ] [ 1 ] [ 1 ] ]
# Remove trivial self reversal when building candidate segments list.
if ( ( len ( sub ) == 3 ) and self . near_ends ( end1 , end2 ) ) :
if so . debug : inkex . utils . debug ( " dropping segment from self-reversing path, length: {} " . format ( len ( sub ) ) )
sub . pop ( )
end2 = [ sub [ - 1 ] [ 1 ] [ 0 ] , sub [ - 1 ] [ 1 ] [ 1 ] ]
segments . append ( { ' id ' : id , ' n ' : sub_idx , ' end1 ' : end1 , ' end2 ' : end2 , ' seg ' : sub } )
if node . get ( inkex . addNS ( ' type ' , ' sodipodi ' ) ) :
del node . attrib [ inkex . addNS ( ' type ' , ' sodipodi ' ) ]
workedon + = 1
if workedon > = so . limit and so . limit > 0 :
break
if so . debug : inkex . utils . debug ( " -------- seen: " )
for s in segments :
if so . debug : inkex . utils . debug ( " {} , {} , {} , {} " . format ( s [ ' id ' ] , s [ ' n ' ] , s [ ' end1 ' ] , s [ ' end2 ' ] ) )
# chain the segments
obsoleted = 0
remaining = 0
workedon = 0
for id , node in selected :
path_d = CubicSuperPath ( Path ( node . get ( ' d ' ) ) )
# ATTENTION: for parsePath() it is the same, if first and last point coincide, or if the path is really closed.
path_closed = True if re . search ( r ' z \ s*$ ' , node . get ( ' d ' ) ) else False
new = [ ]
cur_idx = - 1
for chain in path_d :
cur_idx + = 1
if not self . is_segment_done ( id , cur_idx ) :
# quadratic algorithm: we check both ends of the current segment.
# If one of them is near another known end from the segments list, we
# chain this segment to the current segment and remove it from the
# list,
# end1-end1 or end2-end2: The new segment is reversed.
# end1-end2: The new segment is prepended to the current segment.
# end2-end1: The new segment is appended to the current segment.
self . set_segment_done ( so , id , cur_idx , " output " ) # do not cross with ourselves.
end1 = [ chain [ 0 ] [ 1 ] [ 0 ] , chain [ 0 ] [ 1 ] [ 1 ] ]
end2 = [ chain [ - 1 ] [ 1 ] [ 0 ] , chain [ - 1 ] [ 1 ] [ 1 ] ]
# Remove trivial self revesal when doing the actual chain operation.
if ( ( len ( chain ) == 3 ) and self . near_ends ( end1 , end2 ) ) :
chain . pop ( )
end2 = [ chain [ - 1 ] [ 1 ] [ 0 ] , chain [ - 1 ] [ 1 ] [ 1 ] ]
segments_idx = 0
while segments_idx < len ( segments ) :
seg = segments [ segments_idx ]
if self . is_segment_done ( seg [ ' id ' ] , seg [ ' n ' ] ) :
segments_idx + = 1
continue
if ( self . near_ends ( end1 , seg [ ' end1 ' ] ) or
self . near_ends ( end2 , seg [ ' end2 ' ] ) ) :
seg [ ' seg ' ] = self . reverse_segment ( seg [ ' seg ' ] )
seg [ ' end1 ' ] , seg [ ' end2 ' ] = seg [ ' end2 ' ] , seg [ ' end1 ' ]
if so . debug : inkex . utils . debug ( " reversed seg {} , {} " . format ( seg [ ' id ' ] , seg [ ' n ' ] ) )
if self . near_ends ( end1 , seg [ ' end2 ' ] ) :
# prepend seg to chain
self . set_segment_done ( so , seg [ ' id ' ] , seg [ ' n ' ] , ' prepended to {} {} ' . format ( id , cur_idx ) )
chain = self . link_segments ( seg [ ' seg ' ] , chain )
end1 = [ chain [ 0 ] [ 1 ] [ 0 ] , chain [ 0 ] [ 1 ] [ 1 ] ]
segments_idx = 0 # this chain changed. re-visit all candidate
continue
if self . near_ends ( end2 , seg [ ' end1 ' ] ) :
# append seg to chain
self . set_segment_done ( so , seg [ ' id ' ] , seg [ ' n ' ] , ' appended to {} {} ' . format ( id , cur_idx ) )
chain = self . link_segments ( chain , seg [ ' seg ' ] )
end2 = [ chain [ - 1 ] [ 1 ] [ 0 ] , chain [ - 1 ] [ 1 ] [ 1 ] ]
segments_idx = 0 # this chain changed. re-visit all candidate
continue
segments_idx + = 1
# Now all joinable segments are joined.
# Finally, we can check, if the resulting path is a closed path:
# Closing a path here, isolates it from the rest.
# But as we prefer to make the chain as long as possible, we close late.
if self . near_ends ( end1 , end2 ) and not path_closed and self . close_loops :
if so . debug : inkex . utils . debug ( " closing closeable loop {} " . format ( id ) )
if self . snap_ends :
# move first point to mid position
x1n = ( chain [ 0 ] [ 1 ] [ 0 ] + chain [ - 1 ] [ 1 ] [ 0 ] ) * 0.5
y1n = ( chain [ 0 ] [ 1 ] [ 1 ] + chain [ - 1 ] [ 1 ] [ 1 ] ) * 0.5
chain [ 0 ] [ 1 ] [ 0 ] , chain [ 0 ] [ 1 ] [ 1 ] = x1n , y1n
# merge handle of the last point to the handle of the first point
dx0e = chain [ - 1 ] [ 0 ] [ 0 ] - chain [ - 1 ] [ 1 ] [ 0 ]
dy0e = chain [ - 1 ] [ 0 ] [ 1 ] - chain [ - 1 ] [ 1 ] [ 1 ]
if so . debug : inkex . utils . debug ( " handle diff: {} {} " . format ( dx0e , dy0e ) )
# FIXME: this does not work. cubicsuperpath.formatPath() ignores this handle.
chain [ 0 ] [ 0 ] [ 0 ] , chain [ 0 ] [ 0 ] [ 1 ] = x1n + dx0e , y1n + dy0e
# drop last point
chain . pop ( )
end2 = [ chain [ - 1 ] [ 1 ] [ 0 ] , chain [ - 1 ] [ 1 ] [ 1 ] ]
path_closed = True
self . chained_count + = 1
new . append ( chain )
if not len ( new ) :
# node.clear()
if node . getparent ( ) is not None :
node . delete ( )
obsoleted + = 1
if so . debug : inkex . utils . debug ( " Path node obsoleted: {} " . format ( id ) )
else :
remaining + = 1
# BUG: All previously closed loops are open after we convert them back with cubicsuperpath.formatPath()
p_fmt = str ( Path ( CubicSuperPath ( new ) . to_path ( ) . to_arrays ( ) ) )
if path_closed : p_fmt + = " z "
if so . debug : inkex . utils . debug ( " new path: {} " . format ( p_fmt ) )
node . set ( ' d ' , p_fmt )
workedon + = 1
if workedon > = so . limit and so . limit > 0 :
break
# statistics:
if so . debug : inkex . utils . debug ( " Path nodes obsoleted: {} \n Path nodes remaining: {} " . format ( obsoleted , remaining ) )
if self . min_missed_distance_sq is not None :
if so . debug : inkex . utils . debug ( " min_missed_distance: {} > {} " . format ( math . sqrt ( float ( self . min_missed_distance_sq ) ) / self . unit_factor , self . chain_epsilon ) + str ( so . units ) )
if so . debug : inkex . utils . debug ( " Successful link operations: {} " . format ( self . chained_count ) )
2022-09-20 18:29:28 +02:00
if __name__ == ' __main__ ' :
2024-01-18 11:56:30 +01:00
ChainPaths ( ) . run ( )