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

521 lines
25 KiB
Python
Executable File

#!/usr/bin/env python3
# coding=utf-8
#
# Copyright (C) 2020 Juergen Weigert, jnweiger@gmail.com
#
# 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., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# v0.1, 2020-11-08, jw - initial draught, finding and printing selected nodes to the terminal...
# v0.2, 2020-11-08, jw - duplicate the selected nodes in their superpaths, write them back.
# v0.3, 2020-11-21, jw - find "meta-handles"
# v0.4, 2020-11-26, jw - alpha and trim math added. trimming with a striaght line implemented, needs fixes.
# Option 'cut' added.
# v0.5, 2020-11-28, jw - Cut operation looks correct. Dummy midpoint for large arcs added, looks wrong, of course.
# v1.0, 2020-11-30, jw - Code completed. Bot cut and arc work fine.
# v1.1, 2020-12-07, jw - Replaced boolean 'cut' with a method selector 'arc'/'line'. Added round_corners_092.inx
# and started backport in round_corners.py -- attempting to run the same code everywhere.
# v1.2, 2020-12-08, jw - Backporting continued: option parser hack added. Started effect_wrapper() to prepare self.svg
# v1.3, 2020-12-12, jw - minimalistic compatibility layer for inkscape 0.92.4 done. It now works in both, 1.0 and 0.92!
# v1.4, 2020-12-15, jw - find_roundable_nodes() added for auto selecting nodes, if none were selected.
# And fix https://github.com/jnweiger/inkscape-round-corners/issues/2
# 2021-01-15, Mario Voigt - removed oboslete InkScape 0.92.* stuff
#
# Bad side-effect: As the node count increases during operation, the list of
# selected nodes is incorrect afterwards. We have no way to give inkscape an update.
#
"""
Rounded Corners
This extension operates on selected sharp corner nodes and converts them to a fillet (bevel,chamfer).
An arc shaped path segment with the given radius is inserted smoothly.
The fitted arc is approximated by a bezier spline, as we are doing path operations here.
When the sides at the corner are straight lines, the operation never move the sides, it just shortens them to fit the arc.
When the sides are curved, the arc is placed on the tanget line, and the curve may thus change in shape.
Selected smooth nodes are skipped.
Cases with insufficient space (180deg turn or too short segments/handles) are warned about.
References:
- https://gitlab.com/inkscape/extensions/-/wikis/home
- https://gitlab.com/inkscape/extras/extensions-tutorials/-/blob/master/My-First-Effect-Extension.md
- https://gitlab.com/inkscape/extensions/-/wikis/uploads/25063b4ae6c3396fcda428105c5cff89/template_effect.zip
- https://inkscape-extensions-guide.readthedocs.io/en/latest/_modules/inkex/elements.html#ShapeElement.get_path
- https://inkscape.gitlab.io/extensions/documentation/_modules/inkex/paths.html#CubicSuperPath.to_path
- https://stackoverflow.com/questions/734076/how-to-best-approximate-a-geometrical-arc-with-a-bezier-curve
- https://hansmuller-flex.blogspot.com/2011/10/more-about-approximating-circular-arcs.html
- https://itc.ktu.lt/index.php/ITC/article/download/11812/6479 (Riskus' PDF)
The algorithm of arc_bezier_handles() is based on the approach described in:
A. Riškus, "Approximation of a Cubic Bezier Curve by Circular Arcs and Vice Versa,"
Information Technology and Control, 35(4), 2006 pp. 371-378.
"""
import inkex
import sys, math, pprint, copy
__version__ = '1.4' # Keep in sync with round_corners.inx line 16
debug = False # True: babble on controlling tty
max_trim_factor = 0.90 # 0.5: can cut half of a segment length or handle length away for rounding a corner
max_trim_factor_single = 0.98 # 0.98: we can eat up almost everything, as there are no neighbouring trims to be expected.
class RoundedCorners(inkex.EffectExtension):
def add_arguments(self, pars): # an __init__ in disguise ...
try:
self.tty = open("/dev/tty", 'w')
except:
try:
self.tty = open("CON:", 'w') # windows. Does this work???
except:
self.tty = open(os.devnull, 'w') # '/dev/null' for POSIX, 'nul' for Windows.
if debug: print("RoundedCorners ...", file=self.tty)
self.nodes_inserted = {}
self.eps = 0.00001 # avoid division by zero
self.radius = None
self.max_trim_factor = max_trim_factor
self.skipped_degenerated = 0 # not a useful corner (e.g. 180deg corner)
self.skipped_small_count = 0 # not enough room for arc
self.skipped_small_len = 1e99 # record the shortest handle (or segment) when skipping.
pars.add_argument("--radius", type=float, default=2.0, help="Radius [mm] to round selected vertices. Default: 2")
pars.add_argument("--method", type=str, default="arc", help="operation: one of 'arc' (default), 'line'")
def effect(self):
if debug:
# SvgInputMixin __init__: "id:subpath:position of selected nodes, if any"
print(self.options.selected_nodes, file=self.tty)
self.radius = math.fabs(self.options.radius)
self.cut = False
if self.options.method in ('line'):
self.cut = True
if len(self.options.selected_nodes) < 1:
# find selected objects and construct a list of selected_nodes for them...
for p in self.options.ids:
self.options.selected_nodes.extend(self.find_roundable_nodes(p))
if len(self.options.selected_nodes) < 1:
raise inkex.AbortExtension("Could not find nodes inside a path. No path objects selected?")
if len(self.options.selected_nodes) == 1:
# when we only trim one node, we can eat up almost everything,
# no need to leave room for rounding neighbour nodes.
self.max_trim_factor = max_trim_factor_single
for node in sorted(self.options.selected_nodes):
## we walk through the list sorted, so that node indices are processed within a subpath in ascending numeric order.
## that makes adjusting index offsets after node inserts easier.
ss = self.round_corner(node)
def find_roundable_nodes(self, path_id):
""" select all nodes of all (sub)paths. except for
- the last (one or two) nodes of a closed path (which coindide with the first node)
- the first and last node of an open path (which cannot be smoothed)
"""
ret = []
elem = self.svg.getElementById(path_id)
if elem.tag != '{'+elem.nsmap['svg']+'}path':
return ret # ellipse never works.
try:
csp = elem.path.to_superpath()
except:
return ret
for sp_idx in range(0, len(csp)):
sp = csp[sp_idx]
if len(sp) < 3:
continue # subpaths of 2 or less nodes are ignored
if self.very_close(sp[0], sp[-1]):
idx_s = 0 # closed paths count from 0 to either n-1 or n-2
idx_e = len(sp) - 1
if self.very_close_xy(sp[-2][1], sp[-1][1]):
idx_e = len(sp) - 2
else:
idx_s = 1 # open paths count from 1 to either n-1
idx_e = len(sp) - 1
for idx in range(idx_s, idx_e):
ret.append("%s:%d:%d" % (path_id, sp_idx, idx))
if debug:
print("find_roundable_nodes: ", self.options.selected_nodes, file=sys.stderr)
return ret
def very_close(self, n1, n2):
"deep compare. all elements in sub arrays are compared for (very close) numerical equality"
return self.very_close_xy(n1[0], n2[0]) and self.very_close_xy(n1[1], n2[1]) and self.very_close_xy(n1[2], n2[2])
def very_close_xy(self, p1, p2):
"one 2 element array is compared for (very close) numerical equality"
eps = 1e-9
return abs(p1[0]-p2[0]) < eps and abs(p1[1]-p2[1]) < eps
def round_corner(self, node_id):
""" round the corner at (adjusted) node_idx of subpath
Side_effect: store (or increment) in self.inserted["pathname:subpath"] how many points were inserted in that subpath.
the adjusted node_idx is computed by adding that number (if exists) to the value of the node_id before doing any manipulation
"""
s = node_id.split(":")
path_id = s[0]
subpath_idx = int(s[1])
subpath_id = s[0] + ':' + s[1]
idx_adjust = self.nodes_inserted.get(subpath_id, 0)
node_idx = int(s[2]) + idx_adjust
elem = self.svg.getElementById(path_id)
if elem is None:
print("selected_node %s not found in svg document" % node_id, file=sys.stderr)
return None
elem.apply_transform() # modifies path inplace? -- We save later back to the same element. Maybe we should not?
path = elem.path
s = path.to_superpath()
sp = s[subpath_idx]
## call the actual path manipulator, record how many nodes were inserted.
orig_len = len(sp)
sp = self.subpath_round_corner(sp, node_idx)
idx_adjust += len(sp) - orig_len
# convert the superpath back to a normal path
s[subpath_idx] = sp
elem.set_path(s.to_path(curves_only=False))
self.nodes_inserted[subpath_id] = idx_adjust
# If we picked up the 'd' attribute of a non-path (e.g. star), we must make sure the object now becomes a path.
# Otherwise inkscape uses the sodipodi data and ignores our changed 'd' attribute.
if '{'+elem.nsmap['sodipodi']+'}type' in elem.attrib:
del(elem.attrib['{'+elem.nsmap['sodipodi']+'}type'])
# Debugging is no longer available or not yet implemented? This explodes, although it is
# documented in https://inkscape.gitlab.io/extensions/documentation/inkex.command.html
# inkex.command.write_svg(self.svg, "/tmp/seen.svg")
# - AttributeError: module 'inkex' has no attribute 'command'
# But hey, we can always resort to good old ET.dump(self.document) ...
def super_node(self, sp, node_idx):
""" In case of node_idx 0, we need to use either the last, the second-last or the third last node as a previous node.
For a closed subpath, the last node and the first node are identical. Then, the second last node may be still at the
same location if it has a handle. If so, we take the third last instead. Gah. It has a certain logic...
In case of the node_idx being the last node, we already know that the subpath is not closed,
we use 0 as the next node.
The direction sn.prev.dir does not really point to the coordinate of the previous node, but to the end of the
next-handle of the prvious node. This is the same when there are straight lines. The absence of handles is
denoted by having the same coordinates for handle and node.
Same for next.dir, it points to the next.prev handle.
The exact implementation here is:
- sn.next.handle is set to a relative vector that is the tangent of the curve towards the next point.
we implement four cases:
- if neither node nor next have handles, the connection is a straight line, and next.handle points
in the direction of the next node itself.
- if the curve between node and next is defined by two handles, then sn.next.handle is in the direction of the
nodes own handle,
- if the curve between node and next is defined one handle at the node itself, then sn.next.handle is in the
direction of the nodes own handle,
- if the curve between node and next is defined one handle at the next node, then sn.next.handle is in the
direction from the node to the end of that other handle.
- when trimming back later, we move along that tangent, instead of following the curve.
That is an approximation when the segment is curved, and exact when it is straight.
(Finding exact candidate points on curved lines that have tangents with the desired circle
is beyond me today. Multiple candidates may exist. Any volunteers?)
"""
prev_idx = node_idx - 1
sp_node_idx_ = copy.deepcopy(sp[node_idx]) # if this wraps around, at node_idx=0, we may need to tweak the prev handle
if node_idx == 0:
prev_idx = len(sp) - 1
if self.very_close(sp_node_idx_, sp[prev_idx]):
prev_idx = prev_idx - 1 # skip one node, it is the 'close marker'
if self.very_close_xy(sp_node_idx_[1], sp[prev_idx][1]):
# still no distance, skip more. Needed for https://github.com/jnweiger/inkscape-round-corners/issues/2
sp_node_idx_[0] = sp[prev_idx][0] # this sp_node_idx_ must acts as if its prev handle is that one.
prev_idx = prev_idx - 1
else:
self.skipped_degenerated += 1 # path ends here.
return None, None
# if debug: pprint.pprint({'node_idx': node_idx, 'len(sp)':len(sp), 'sp': sp}, stream=self.tty)
if node_idx == len(sp)-1:
self.skipped_degenerated += 1 # path ends here. On a closed loop, we can never select the last point.
return None, None
next_idx = node_idx + 1
if next_idx >= len(sp): next_idx = 0
t = sp_node_idx_
p = sp[prev_idx]
n = sp[next_idx]
dir1 = [ p[2][0] - t[1][0], p[2][1] - t[1][1] ] # direction to the previous node (rel coords)
dir2 = [ n[0][0] - t[1][0], n[0][1] - t[1][1] ] # direction to the next node (rel coords)
dist1 = math.sqrt(dir1[0]*dir1[0] + dir1[1]*dir1[1]) # distance to the previous node
dist2 = math.sqrt(dir2[0]*dir2[0] + dir2[1]*dir2[1]) # distance to the next node
handle1 = [ t[0][0] - t[1][0], t[0][1] - t[1][1] ] # handle towards previous node (rel coords)
handle2 = [ t[2][0] - t[1][0], t[2][1] - t[1][1] ] # handle towards next node (rel coords)
if self.very_close_xy(handle1, [ 0, 0 ]): handle1 = dir1
if self.very_close_xy(handle2, [ 0, 0 ]): handle2 = dir2
prev = { 'idx': prev_idx, 'dir':dir1, 'handle':handle1 }
next = { 'idx': next_idx, 'dir':dir2, 'handle':handle2 }
sn = { 'idx': node_idx, 'prev': prev, 'next': next, 'x': t[1][0], 'y': t[1][1] }
if dist1 < self.radius:
if debug:
print("subpath node_idx=%d, dist to prev(%d) is smaller than radius: %g < %g" %
(node_idx, prev_idx, dist1, self.radius), file=sys.stderr)
pprint.pprint(sn, stream=sys.stderr)
if self.skipped_small_len > dist1: self.skipped_small_len = dist1
self.skipped_small_count += 1
return None, None
if dist2 < self.radius:
if debug:
print("subpath node_idx=%d, dist to next(%d) is smaller than radius: %g < %g" %
(node_idx, next_idx, dist2, self.radius), file=sys.stderr)
pprint.pprint(sn, stream=sys.stderr)
if self.skipped_small_len > dist2: self.skipped_small_len = dist2
self.skipped_small_count += 1
return None, None
len_h1 = math.sqrt(handle1[0]*handle1[0] + handle1[1]*handle1[1])
len_h2 = math.sqrt(handle2[0]*handle2[0] + handle2[1]*handle2[1])
prev['hlen'] = len_h1
next['hlen'] = len_h2
if len_h1 < self.radius:
if debug:
print("subpath node_idx=%d, handle to prev(%d) is shorter than radius: %g < %g" %
(node_idx, prev_idx, len_h1, self.radius), file=sys.stderr)
pprint.pprint(sn, stream=sys.stderr)
if self.skipped_small_len > len_h1: self.skipped_small_len = len_h1
self.skipped_small_count += 1
return None, None
if len_h2 < self.radius:
if debug:
print("subpath node_idx=%d, handle to next(%d) is shorter than radius: %g < %g" %
(node_idx, next_idx, len_h2, self.radius), file=sys.stderr)
pprint.pprint(sn, stream=sys.stderr)
if self.skipped_small_len > len_h2: self.skipped_small_len = len_h2
self.skipped_small_count += 1
return None, None
if len_h1 > dist1: # shorten that handle to dist1, avoid overshooting the point
handle1[0] = handle1[0] * dist1 / len_h1
handle1[1] = handle1[1] * dist1 / len_h1
prev['hlen'] = dist1
if len_h2 > dist2: # shorten that handle to dist2, avoid overshooting the point
handle2[0] = handle2[0] * dist2 / len_h2
handle2[1] = handle2[1] * dist2 / len_h2
next['hlen'] = dist2
return sn, sp_node_idx_
def arc_c_m_from_super_node(self, s):
"""
Given the supernode s and the radius self.radius, we compute and return two points:
c, the center of the arc and m, the midpoint of the arc.
Method used:
- construct the ray c_m_vec that runs though the original point p=[x,y] through c and m.
- next.trim_pt, [x,y] and c form a rectangular triangle. Thus we can
compute cdist as the length of the hypothenuses under trim and radius.
- c is then cdist away from [x,y] along the vector c_m_vec.
- m is closer to [x,y] than c by exactly radius.
"""
a = [ s['prev']['trim_pt'][0] - s['x'], s['prev']['trim_pt'][1] - s['y'] ]
b = [ s['next']['trim_pt'][0] - s['x'], s['next']['trim_pt'][1] - s['y'] ]
c_m_vec = [ a[0] + b[0],
a[1] + b[1] ]
l = math.sqrt( c_m_vec[0]*c_m_vec[0] + c_m_vec[1]*c_m_vec[1] )
cdist = math.sqrt( self.radius*self.radius + s['trim']*s['trim'] ) # distance [x,y] to circle center c.
c = [ s['x'] + cdist * c_m_vec[0] / l, # circle center
s['y'] + cdist * c_m_vec[1] / l ]
m = [ s['x'] + (cdist-self.radius) * c_m_vec[0] / l, # spline midpoint
s['y'] + (cdist-self.radius) * c_m_vec[1] / l ]
return (c, m)
def arc_bezier_handles(self, p1, p4, c):
"""
Compute the control points p2 and p3 between points p1 and p4, so that the cubic bezier spline
defined by p1,p2,p3,p2 approximates an arc around center c
Algorithm based on Aleksas Riškus and Hans Muller. Sorry Pomax, saw your works too, but did not use any.
"""
x1,y1 = p1
x4,y4 = p4
xc,yc = c
ax = x1 - xc
ay = y1 - yc
bx = x4 - xc
by = y4 - yc
q1 = ax * ax + ay * ay
q2 = q1 + ax * bx + ay * by
k2 = 4./3. * (math.sqrt(2 * q1 * q2) - q2) / (ax * by - ay * bx)
x2 = xc + ax - k2 * ay
y2 = yc + ay + k2 * ax
x3 = xc + bx + k2 * by
y3 = yc + by - k2 * bx
return ([x2, y2], [x3, y3])
def subpath_round_corner(self, sp, node_idx):
sn, sp_node_idx_ = self.super_node(sp, node_idx)
if sn is None: return sp # do nothing. stderr messages are already printed.
# The angle to be rounded is now between the vectors a and b
#
a = sn['prev']['handle']
b = sn['next']['handle']
a_len = sn['prev']['hlen']
b_len = sn['next']['hlen']
try:
# From https://de.wikipedia.org/wiki/Schnittwinkel_(Geometrie)
# Wikipedia has an abs() in the formula, which extracts the smaller of the two angles.
# We don't want that. We need to distinguish betwenn spitzwingklig and stumpfwinklig.
#
alpha = math.acos( (a[0]*b[0]+a[1]*b[1]) / ( math.sqrt(a[0]*a[0]+a[1]*a[1]) * math.sqrt(b[0]*b[0]+b[1]*b[1]) ) )
except:
# Division by 0 error means path folds back on itself here. No space to apply a radius between the segments.
self.skipped_degenerated += 1
return sp
sn['alpha'] = math.degrees(alpha)
# find the amount to trim back both sides so that a circle of radius self.radius would perfectly fit.
if alpha < self.eps:
# path folds back on itself here. No space to apply a radius between the segments.
self.skipped_degenerated += 1
return sp
if abs(alpha - math.pi) < self.eps:
# stretched. radius won't be visible, that is just fine. No need to warn about that.
return sp
trim = self.radius / math.tan(0.5 * alpha)
sn['trim'] = trim
if trim < 0.0:
print("Error: at node_idx=%d: angle=%g°, trim is negative: %g" % (node_idx, math.degrees(alpha), trim), file=sys.stderr)
return sp
# a_len points to the previous node. There we can always allow max_trim_factor_single, as the trim was either already done,
# or will not be done. Only at b_len we need to reserve space for the next trim.
# FIXME: also allow max_trim_factor_single at b_len, when we find that the very next node will not be rounded.
#
available_len = min(max_trim_factor_single*a_len, self.max_trim_factor*b_len)
if trim > available_len:
if debug:
if trim > max_trim_factor_single*a_len:
print("Skipping where hlen_a %g * max_trim %g < needed_trim %g" % (a_len, max_trim_factor_single, trim), file=self.tty)
if trim > self.max_trim_factor*b_len:
print("Skipping where hlen_b %g * max_trim %g < needed_trim %g" % (b_len, self.max_trim_factor, trim), file=self.tty)
pprint.pprint(sn, stream=self.tty)
if self.skipped_small_len > available_len:
self.skipped_small_len = available_len
self.skipped_small_count += 1
return sp
trim_pt_p = [ sn['x'] + a[0] * trim / a_len, sn['y'] + a[1] * trim / a_len ]
trim_pt_n = [ sn['x'] + b[0] * trim / b_len, sn['y'] + b[1] * trim / b_len ]
sn['prev']['trim_pt'] = trim_pt_p
sn['next']['trim_pt'] = trim_pt_n
if debug:
pprint.pprint(sn, stream=self.tty)
pprint.pprint(self.cut, stream=self.tty)
# We replace the node_idx node by two nodes node_a, node_b.
# We need an extra middle node node_m if alpha < 90° -- alpha is the angle between the tangents,
# as the arc spans the remainder to complete 180° an arc with more than 90° needs the midpoint.
# We preserve the endpoints of the two outside handles if they are non-0-length.
# We know that such handles are long enough (because of the above max_trim_factor checks)
# to not flip around when applying the trim.
# But we move the endpoints of 0-length outside handles with the point when trimming,
# so that they don't end up on the inside.
prev_handle = sp_node_idx_[0][:]
next_handle = sp_node_idx_[2][:]
if self.very_close_xy(prev_handle, sp_node_idx_[1]): prev_handle = trim_pt_p[:]
if self.very_close_xy(next_handle, sp_node_idx_[1]): next_handle = trim_pt_n[:]
p1 = trim_pt_p[:]
p7 = trim_pt_n[:]
arc_c, p4 = self.arc_c_m_from_super_node(sn)
node_a = [ prev_handle, p1[:], p1[:] ] # deep copy, as we may want to modify the second handle later
node_b = [ p7[:], p7[:], next_handle ] # deep copy, as we may want to modify the first handle later
if alpha >= 0.5*math.pi or self.cut:
if self.cut == False:
# p3,p4,p5 do not exist, we need no midpoint
p2, p6 = self.arc_bezier_handles(p1, p7, arc_c)
node_a[2] = p2
node_b[0] = p6
if node_idx == 0:
# use prev idx to know about the extra skip. +1 for the node here, +1 for inclusive.
# CAUTION: Keep in sync below
sp = [node_a] + [node_b] + sp[1:sn['prev']['idx']+2]
else:
sp = sp[:node_idx] + [node_a] + [node_b] + sp[node_idx+1:]
else:
p2, p3 = self.arc_bezier_handles(p1, p4, arc_c)
p5, p6 = self.arc_bezier_handles(p4, p7, arc_c)
node_m = [ p3, p4, p5 ]
node_a[2] = p2
node_b[0] = p6
if node_idx == 0:
# use prev idx to know about the extra skip. +1 for the node here, +1 for inclusive.
# CAUTION: Keep in sync above
sp = [node_a] + [node_m] + [node_b] + sp[1:sn['prev']['idx']+2]
else:
sp = sp[:node_idx] + [node_a] + [node_m] + [node_b] + sp[node_idx+1:]
# A closed path is formed by making the last node indentical to the first node.
# So, if we trim at the first node, then duplicte that trim on the last node, to keep the loop closed.
if node_idx == 0:
sp[-1][0] = sp[0][0][:]
sp[-1][1] = sp[0][1][:]
sp[-1][2] = sp[0][2][:]
return sp
def clean_up(self): # __fini__
if self.tty is not None:
self.tty.close()
super(RoundedCorners, self).clean_up()
if self.skipped_degenerated:
print("Warning: Skipped %d degenerated nodes (180° turn or end of path?).\n" % self.skipped_degenerated, file=sys.stderr)
if self.skipped_small_count:
print("Warning: Skipped %d nodes with not enough space (Value %g is too small. Try again with a smaller radius or only one node selected).\n" % (self.skipped_small_count, self.skipped_small_len), file=sys.stderr)
if __name__ == '__main__':
RoundedCorners().run()