From cfc03f33b12771b9f9e440d496594445045d5765 Mon Sep 17 00:00:00 2001 From: Mario Voigt Date: Mon, 11 Oct 2021 21:16:13 +0200 Subject: [PATCH] added parallel translation --- .../parallel_translation.inx | 99 +++ .../parallel_translation.py | 620 ++++++++++++++++++ 2 files changed, 719 insertions(+) create mode 100644 extensions/fablabchemnitz/parallel_translation/parallel_translation.inx create mode 100644 extensions/fablabchemnitz/parallel_translation/parallel_translation.py diff --git a/extensions/fablabchemnitz/parallel_translation/parallel_translation.inx b/extensions/fablabchemnitz/parallel_translation/parallel_translation.inx new file mode 100644 index 00000000..fd89cc13 --- /dev/null +++ b/extensions/fablabchemnitz/parallel_translation/parallel_translation.inx @@ -0,0 +1,99 @@ + + + Parallel Translation + fablabchemnitz.de.parallel_translation + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + false + + false + + 0 + + + + + + + + + + + + + + + 15 + 0x20f020ff + false + false + + + + + + 1 + + + + + + + + + + + + + + + false + + + + path + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/parallel_translation/parallel_translation.py b/extensions/fablabchemnitz/parallel_translation/parallel_translation.py new file mode 100644 index 00000000..cccd1d78 --- /dev/null +++ b/extensions/fablabchemnitz/parallel_translation/parallel_translation.py @@ -0,0 +1,620 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2021 Christian Vogt +# +# recursiveFuseTransform() has originally been written by +# Mark "Klowner" Riedesel +# see: https://github.com/Klowner/inkscape-applytransforms +# +# edgeResize() has been inspired by inkscape-round-corners extension by +# Juergen Weigert +# see: https://github.com/jnweiger/inkscape-round-corners +# +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2, as +# published by the Free Software Foundation. +# +# 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. +# + + +# This extension was written and tested using Inkscape V1.0.2 +# Most probably, it will not work on versions prior to V1.0 +# +# Resources I found being useful and inspiring: +# https://gitlab.com/inkscape/extensions/-/wikis/My-First-Effect-Extension +# https://wiki.inkscape.org/wiki/index.php?title=Extension_subsystem +# https://wiki.inkscape.org/wiki/index.php?title=Extensions:_INX_widgets_and_parameters +# https://inkscape.gitlab.io/extensions/documentation/inkex.html +# https://inkscape-extensions-guide.readthedocs.io/en/latest/inkex-modules.html# +# https://gitlab.com/inkscape/extensions +# https://inkscape.gitlab.io/inkscape/doxygen-extensions/index.html + + +# The purpose of this extension is to help performing parallel +# translations of selected straight paths (lines). This is equivalent +# to changing the absolute X coordinate of a vertical line or changing +# the absolute Y coordinate of a horizontal line, but for lines drawn +# in any angle. +# +# The second purpose is to align a group of objects to such a line +# drawn in any angle. This means: moving and rotating the group and +# adjust its width to match the line length. +# +# To align two objects, it is also possible to turn one of them into +# a group by adding a 'rotation center object' in the middle of the +# selected path/lines, and rotate the group into its 0-degrees position. +# This group can then aligned to the other object. +# +# V0.1 2021-04-20 : initial version. +# +# V0.2 2021-04-21 : +# - Fix: avoid using deprecated inkex API. +# - Fix: move both point and handles of a node to avoid +# non-intentional creating curves out of flat line segments. +# - New: added 'endpoint tolerance' parameter (--endpTol) +# - New: now supports having the rotation center object outside the +# horizontal center of the group. +# +# V0.3 2021-04-28 : +# - New: now allows to select two nodes of a larger path to be used +# as straight line to use for calculating the length and +# translation angle. +# - Mod: Added 'global' section to the information printout. +# - Mod: For closed paths (start point matches end point), we +# use a hardcoded translation angle of 0 degrees. +# +# V0.4 2021-04-29 : +# - New: Added the 'Obj-to-Group' tab. +# - Mod: Updated the info-tab description. +# +# V0.5 2021-05-04 : +# - New: Added 'remove rotation center object' checkbox. +# - Mod: Added LICENCE file. +# - Mod: Changed default rotation center object color to neon green. +# (because pink has already been used for cut-lines in some +# existing drawings) +# +# V0.6 2021-05-05 : +# - Mod: added a workaround for un-selecting a path after we've +# created a rotation-group out of it. +# +# V0.7 2021-05-11 : +# - Mod: Added more error-checking to the 'Obj-to-Group' function. +# - Mod: 'Obj-to-Group' is now able to group multiple objects. +# - Mod: Changed copyright and put version information into inx-file. +# +# V1.0 2021-09-03 : +# - Doc: Moved to github repository. +# - Doc: Added README.md and screenshot art. +# - Doc: Now using the mail address that corresponds to github account. +# - Fix: Fixed stretch/resize option of group alignment. + + +import math +import inkex +from inkex.paths import CubicSuperPath, Path +from inkex.transforms import Transform +from inkex.styles import Style + +NULL_TRANSFORM = Transform([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) + + +class ParallelTranlation(inkex.EffectExtension): + + def add_arguments(self, pars): + pars.add_argument("--tab") + # 'translation' Tab + pars.add_argument("--copyMode" , type=str , default="none", help="Move copy or original") + pars.add_argument("--distance" , type=float , default=0 , help="Distance to move") + pars.add_argument("--distUnit" , type=str , default="mm" , help="Unit of the distance") + pars.add_argument("--reverse" , type=inkex.Boolean, default=False , help="Move in opposite direction") + pars.add_argument("--useFixedAngle", type=inkex.Boolean, default=False , help="use fixed translation angle") + # 'align' Tab + pars.add_argument("--fixedAngle" , type=float , default=0 , help="Translation angle") + pars.add_argument("--copyModeA" , type=str , default="none", help="Align copy or original") + pars.add_argument("--lengthModeA" , type=str , default="none", help="Group length adjustment method") + pars.add_argument("--endpTol" , type=int , default=15 , help="Enpoint tolerance (percent of group width)") + pars.add_argument("--colorA" , type=int , default=0 , help="Color of rotation center circle") + pars.add_argument("--rmFromGroup" , type=inkex.Boolean, default=False , help="remove rotation center object from aligned group") + pars.add_argument("--reverseA" , type=inkex.Boolean, default=False , help="additinal group rotate by 180 degrees") + # 'group' Tab + pars.add_argument("--ctSize" , type=float , default=1 , help="Size of rotation center object") + pars.add_argument("--ctSzUnit" , type=str , default="mm" , help="Unit of the size") + pars.add_argument("--reverseG" , type=inkex.Boolean, default=False , help="additinal group rotate by 180 degrees") + + + def effect(self): + pathCount = 0 + pathsWNodesCount = 0 + groupCount = 0 + for elem in self.svg.selected.values(): + if isinstance(elem, inkex.PathElement): + pathCount += 1 + nodes = self.count_selected_nodes( elem.get_id() ) + if nodes > 0: + if nodes == 2: + pathsWNodesCount += 1 + self.groupCenterPath = elem + else: + raise inkex.AbortExtension( + "When selecting individual nodes, you should " + "always select exactly two from the same " + "(sub-)path.") + + elif isinstance(elem, inkex.Group): + groupCount += 1 + self.alignGroup = elem + + if self.options.tab == "align": + if pathCount == 0 or groupCount == 0: + raise inkex.AbortExtension( + "In alignment mode, please select exactly one group " + "and at least one path to align it to.") + if groupCount > 1: + raise inkex.AbortExtension( + "Sorry, we can align a single group only.") + if pathCount > 1 and self.options.copyModeA != 'copy': + raise inkex.AbortExtension( + "To align to multiple paths at once, please choose " + "to apply the alignment to copies of the group.") + elif self.options.tab == "group": + if pathsWNodesCount != 1: + raise inkex.AbortExtension( + "In group mode, please select exactly two nodes " + "from the same (sub-)path.") + else: + if pathCount == 0: + raise inkex.AbortExtension( + "Please select one path at least.") + + if self.options.tab == "info": + msg = "Global info:" + self.msg(msg) + msg = " Document name: {}" + self.msg(msg.format(self.svg.name)) + msg = " Dimensions: width={} height={} scale={} unit={}" + self.msg(msg.format(self.svg.width, self.svg.height, self.svg.scale, self.svg.unit)) + msg = " Selected: {} group(s), {} path(s), {} node(s)" + self.msg(msg.format(groupCount, pathCount, len(self.options.selected_nodes))) + if self.options.selected_nodes: + msg = " Nodes: {}" + self.msg(msg.format(self.options.selected_nodes)) + self.msg(' ') + + if self.options.tab == "group": + self.process_node(self.groupCenterPath) + else: + for elem in self.svg.selected.values(): + self.process_node(elem) + + + @staticmethod + def close_enough(a, b, maxdist): + # two numbers are compared for close-enough numerical equality + if maxdist <= 0 : + eps = 1e-9 + else: + eps = maxdist + return abs(a-b) <= eps + + + def edgeResize(self, obj, length, rbb): + bbox = obj.bounding_box() + dx = (length - bbox.width) / 2 + offcenter = rbb.x.center - bbox.x.center + maxdist = (bbox.width * self.options.endpTol) / 100 + + # Iterate over all children of the group-object. + # Find path nodes located at the left and right edges of the + # groups bounding box, and move these by 'dx' further to the + # left or right. + for child in obj.iterchildren(): + + if not isinstance(child, inkex.PathElement): + continue #ignore all non-path-objects + + csp = child.path.to_superpath() + node_count = 0; + for sub in csp: + for node in sub: + node_x = node[1][0] + if self.close_enough( node_x, bbox.left, maxdist ): + for i in range(0, 3): + node[i][0] -= (dx - offcenter) + node_count += 1 + """msg = "id:{} touched left edge at:{}" + self.msg(msg.format(child.get_id(), + bbox.left))""" + + if self.close_enough( node_x, bbox.right, maxdist ): + for i in range(0, 3): + node[i][0] += (dx + offcenter) + node_count += 1 + """msg = "id:{} touched right edge at:{}" + self.msg(msg.format(child.get_id(), + bbox.right))""" + + if node_count > 0: + # we've moved some nodes of this superpath! + # convert back to real path and modify child object in-place + child.set_path(csp.to_path(curves_only=False)) + + + def align(self, x, y, alpha, length): + rotation_col = inkex.Color(self.options.colorA).to_rgba() + rotation_bb = None + + # select the object to move + if self.options.copyModeA in ('copy'): + objToMove = self.alignGroup.duplicate() + else: + if self.options.copyModeA in ('obj'): + self.alignGroup.duplicate() + objToMove = self.alignGroup + + """ + msg = "align goup {}" + self.msg(msg.format(self.alignGroup.get_id())) + msg = " to: x={} y={}" + self.msg(msg.format(x,y)) + msg = " length: {}" + self.msg(msg.format(length)) + msg = " angle : {}" + self.msg(msg.format(math.degrees(alpha))) + msg = " rotation center color:{}" + self.msg(msg.format(rotation_col)) + for child in self.alignGroup.iterchildren(): + bbox = child.bounding_box() + msg = " child-obj:{} fill:{} stroke:{} x={} y={}" + self.msg(msg.format(child.get_id(), + child.style.get_color(name='fill'), + child.style.get_color(name='stroke'), + bbox.x.center, + bbox.y.center)) + """ + # first, apply any transformations the object may have already + # so we are starting with no transform assigned to the group + self.recursiveFuseTransform(objToMove) + + # locate the rotation center marker in the object to move by + # checking for the rotation marker fill color within the group + for child in objToMove.iterchildren(): + if child.style.get_color(name='fill') == rotation_col: + rotation_bb = child.bounding_box() + break + if rotation_bb is None: + raise inkex.AbortExtension( + "No rotation center object found in group.") + + # adjust the objects length. Since we haven't moved or rotated + # it by now, we have to adjust its width only. + if self.options.lengthModeA != "none": + if self.options.lengthModeA == "scale": + if not math.isclose( rotation_bb.x.center, objToMove.bounding_box().x.center, rel_tol=1e-05): + msg = "Rotation center x = {}" + self.msg(msg.format(rotation_bb.x.center)) + msg = "Group center x = {}" + self.msg(msg.format(objToMove.bounding_box().x.center)) + raise inkex.AbortExtension( + "Warning: rotation center is outside the groups horizontal center. " + "This is not supported in stretch/resize adjustment mode. " + "You may want to try the line endpoint adjust mode intead.") + + tr = inkex.Transform() + tr.add_scale( length / objToMove.bounding_box().width, 1 ) + objToMove.transform = tr * objToMove.transform + self.recursiveFuseTransform(objToMove) + # it looks like the scale transformation also applies + # some horizontal movement, so we need to retrieve + # the rotation_bb again afterwards. + rotation_bb = child.bounding_box() + + elif self.options.lengthModeA == "endpoints": + self.edgeResize( objToMove, length, rotation_bb ) + + # Remove the rotation center object from the group if we have + # been instructed to do so. + if self.options.rmFromGroup: + child.delete() + + # then, move it to the desired location + dx = x - rotation_bb.x.center + dy = y - rotation_bb.y.center + tr = inkex.Transform() + tr.add_translate( dx, dy ) + objToMove.transform = tr * objToMove.transform + self.recursiveFuseTransform(objToMove) + + # finally, rotate it by the given angle at the desired location + tr = inkex.Transform() + tr.add_rotate( math.degrees(alpha), x, y ) + objToMove.transform = tr * objToMove.transform + self.recursiveFuseTransform(objToMove) + + + def make_group(self, parent, x, y, alpha): + # Add a new group and put a rotation center object into it. + group = parent.add(inkex.Group()) + style = "color:#000000;fill:{};stroke-width:1;stroke:none".format( + str(inkex.Color(self.options.colorA).to_rgb())) + size = self.svg.unittouu('{}{}'.format(self.options.ctSize, + self.options.ctSzUnit) ) + circle = group.add(inkex.Circle(cx=str(x), cy=str(y), r=str(size/2))) + circle.style = inkex.Style().parse_str(style) + + # put duplicates of all selected elements into the group. + for elem in self.svg.selected.values(): + group.add(elem.duplicate()) + + # Rotate the whole new group back to it's zero position + tr = inkex.Transform() + tr.add_rotate( math.degrees(-alpha), x, y ) + group.transform = tr * group.transform + self.recursiveFuseTransform(group) + + # delete all selected elements to remove the selection. + # Note that self.svg.set_selected() don't work. See: + # https://inkscape.org/forums/extensions/how-to-clear-node-selection/ + for elem in self.svg.selected.values(): + elem.delete() + + + + # Returns the number of selected nodes from the object given + # by name. Indices of selected nodes matching the given object name + # and subpath index are appended to the given index list + def count_selected_nodes(self, name, sub_idx=0, idx_list=[] ): + nodeCount = 0 + for node in self.options.selected_nodes: + # The string with selected nodes looks like this: + # 'Object-id:subpath-index:node-index' + # example: 'path1419:0:1' + substr = node.split(":") + if name == substr[0]: + nodeCount += 1 + if sub_idx == int(substr[1]): + idx_list.append(int(substr[2])) + return nodeCount + + + def process_node(self, elem): + if not isinstance(elem, inkex.PathElement): + return # just ignore all non-path elements + + sub_idx = 0 + hint = "" + for sub in elem.path.to_superpath(): + # Calculate the objects rotation angle . For this, + # we assume a line between two nodes of the object. + # A horizontal line from left to right is 0 degrees. + # Positive angles means the line is rotated clockwise. + # -180 < alpha <= +180 + + # Select the two nodes to use for the calculation + node_idx = [] + if self.count_selected_nodes( elem.get_id(), sub_idx, node_idx ) > 0: + # we have selected nodes from this element. + if len(node_idx) == 2: + # if the user selected exactly two individual nodes + # of a probably larger path, we shall use these. + p1_idx = node_idx[0] + p2_idx = node_idx[1] + hint = " (Segment defined by selected nodes)" + else: + # otherwise, we'll skip this sub-path and try the + # next from the same element. + sub_idx += 1 + continue + else: + # if no nodes have been selected in this element, we + # use the start- and end-node of the sub-path + p1_idx = 0 + p2_idx = -1 + + # Calculate angle, length, midpoint, etc. + x1=sub[p1_idx][1][0] + y1=sub[p1_idx][1][1] + x2=sub[p2_idx][1][0] + y2=sub[p2_idx][1][1] + width = x2-x1 + heigth= y2-y1 + if math.isclose( width, 0 ): + if math.isclose( heigth, 0 ): + alpha = 0 + elif heigth > 0: + alpha = math.pi/2 + else: + alpha = -math.pi/2 + else: + alpha = math.atan(heigth/width) + if width < 0: + if heigth < 0: + alpha -= math.pi + else: + alpha += math.pi + xm = (x1+x2)/2 + ym = (y1+y2)/2 + length = math.sqrt( math.pow(width,2) + math.pow(heigth,2) ) + + # Select translation angle by options + if self.options.useFixedAngle: + da = math.radians(self.options.fixedAngle) + else: + da = alpha + + # Calculate translation in x and y direction + # A positive distance value moves towards greater coordinates + dist = self.svg.unittouu('{}{}'.format(self.options.distance, + self.options.distUnit) ) + if self.options.reverse: + da = da + math.radians(180) + dx = -math.sin(da) * dist + dy = math.cos(da) * dist + + if self.options.tab == "translation": + if self.options.copyMode in ('copy'): + objToMove = elem.duplicate() + else: + if self.options.copyMode in ('obj'): + elem.duplicate() + objToMove = elem + objToMove.path = objToMove.path.translate( dx, dy ) + + elif self.options.tab == "align": + if self.options.reverseA: + alpha = alpha + math.radians(180) + self.align(xm, ym, alpha, length) + + elif self.options.tab == "group": + if length > 0: + if self.options.reverseG: + alpha = alpha + math.radians(180) + self.make_group(elem.getparent(), xm, ym, alpha) + + elif self.options.tab == "info": + msg = "Measuring result:" + self.msg(msg) + msg = " object name: {}" + self.msg(msg.format(elem.get_id())) + msg = " subpath: {} {}" + self.msg(msg.format(sub_idx, hint)) + msg = " start point: x={} y={}" + self.msg(msg.format(x1,y1)) + msg = " end point: x={} y={}" + self.msg(msg.format(x2,y2)) + msg = " mid point: x={} y={}" + self.msg(msg.format(xm,ym)) + msg = " object length: {}" + self.msg(msg.format(length)) + msg = " object angle : {}°" + self.msg(msg.format(math.degrees(alpha))) + msg = " translation angle: {}°" + self.msg(msg.format(math.degrees(da))) + msg = " translation: dx={} dy={}" + self.msg(msg.format(dx,dy)) + self.msg(" ") + + # continue for-loop with next sub-index + sub_idx += 1 + + + @staticmethod + def objectToPath(node): + if node.tag == inkex.addNS('g', 'svg'): + return node + + if node.tag == inkex.addNS('path', 'svg') or node.tag == 'path': + for attName in node.attrib.keys(): + if ("sodipodi" in attName) or ("inkscape" in attName): + del node.attrib[attName] + return node + + return node + + + def recursiveFuseTransform(self, node, transf=[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]): + + transf = Transform(transf) * Transform(node.get("transform", None)) + + if 'transform' in node.attrib: + del node.attrib['transform'] + + node = self.objectToPath(node) + + if transf == NULL_TRANSFORM: + # Don't do anything if there is effectively no transform applied + # reduces alerts for unsupported nodes + pass + elif 'd' in node.attrib: + d = node.get('d') + p = CubicSuperPath(d) + p = Path(p).to_absolute().transform(transf, True) + node.set('d', str(Path(CubicSuperPath(p).to_path()))) + + elif node.tag in [inkex.addNS('polygon', 'svg'), + inkex.addNS('polyline', 'svg')]: + points = node.get('points') + points = points.strip().split(' ') + for k, p in enumerate(points): + if ',' in p: + p = p.split(',') + p = [float(p[0]), float(p[1])] + p = transf.apply_to_point(p) + p = [str(p[0]), str(p[1])] + p = ','.join(p) + points[k] = p + points = ' '.join(points) + node.set('points', points) + + elif node.tag in [inkex.addNS("ellipse", "svg"), inkex.addNS("circle", "svg")]: + + def isequal(a, b): + return abs(a - b) <= transf.absolute_tolerance + + if node.TAG == "ellipse": + rx = float(node.get("rx")) + ry = float(node.get("ry")) + else: + rx = float(node.get("r")) + ry = rx + + cx = float(node.get("cx")) + cy = float(node.get("cy")) + sqxy1 = (cx - rx, cy - ry) + sqxy2 = (cx + rx, cy - ry) + sqxy3 = (cx + rx, cy + ry) + newxy1 = transf.apply_to_point(sqxy1) + newxy2 = transf.apply_to_point(sqxy2) + newxy3 = transf.apply_to_point(sqxy3) + + node.set("cx", (newxy1[0] + newxy3[0]) / 2) + node.set("cy", (newxy1[1] + newxy3[1]) / 2) + edgex = math.sqrt( + abs(newxy1[0] - newxy2[0]) ** 2 + abs(newxy1[1] - newxy2[1]) ** 2 + ) + edgey = math.sqrt( + abs(newxy2[0] - newxy3[0]) ** 2 + abs(newxy2[1] - newxy3[1]) ** 2 + ) + + """ + if not isequal(edgex, edgey) and ( + node.TAG == "circle" + or not isequal(newxy2[0], newxy3[0]) + or not isequal(newxy1[1], newxy2[1]) + ): + inkex.utils.errormsg( + "Warning: Shape %s (%s) is approximate only, try Object to path first for better results" + % (node.TAG, node.get("id")) + ) + """ + + if node.TAG == "ellipse": + node.set("rx", edgex / 2) + node.set("ry", edgey / 2) + else: + node.set("r", edgex / 2) + + elif node.tag in [inkex.addNS('rect', 'svg'), + inkex.addNS('text', 'svg'), + inkex.addNS('image', 'svg'), + inkex.addNS('use', 'svg')]: + inkex.utils.errormsg( + "Shape %s (%s) not yet supported, try Object to path first" + % (node.TAG, node.get("id")) + ) + + for child in node.getchildren(): + self.recursiveFuseTransform(child, transf) + + +if __name__ == '__main__': + ParallelTranlation().run()