diff --git a/extensions/fablabchemnitz/reorder_sequence/plot_utils.py b/extensions/fablabchemnitz/reorder_sequence/plot_utils.py new file mode 100644 index 00000000..fb3c4861 --- /dev/null +++ b/extensions/fablabchemnitz/reorder_sequence/plot_utils.py @@ -0,0 +1,745 @@ +# -*- coding: utf-8 -*- +# plot_utils.py +# Common plotting utilities for EiBotBoard +# https://github.com/evil-mad/plotink +# +# Intended to provide some common interfaces that can be used by +# EggBot, WaterColorBot, AxiDraw, and similar machines. +# +# See below for version information +# +# +# The MIT License (MIT) +# +# Copyright (c) 2019 Windell H. Oskay, Evil Mad Scientist Laboratories +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from math import sqrt + +import cspsubdiv +import simplepath +import bezmisc +import ffgeom + +def version(): # Version number for this document + return "0.16" # Dated 2019-06-18 + +__version__ = version() + +PX_PER_INCH = 96.0 +# This value has changed to 96 px per inch, as of version 0.12 of this library. +# Prior versions used 90 PPI, corresponding the value used in Inkscape < 0.92. +# For use with Inkscape 0.91 (or older), use PX_PER_INCH = 90.0 + +trivial_svg = """ + + + """ + +def checkLimits(value, lower_bound, upper_bound): + # Limit a value to within a range. + # Return constrained value with error boolean. + if value > upper_bound: + return upper_bound, True + if value < lower_bound: + return lower_bound, True + return value, False + + +def checkLimitsTol(value, lower_bound, upper_bound, tolerance): + # Limit a value to within a range. + # Return constrained value with error boolean. + # Allow a range of tolerance where we constrain the value without an error message. + + if value > upper_bound: + if value > (upper_bound + tolerance): + return upper_bound, True # Truncate & throw error + else: + return upper_bound, False # Truncate with no error + if value < lower_bound: + if value < (lower_bound - tolerance): + return lower_bound, True # Truncate & throw error + else: + return lower_bound, False # Truncate with no error + return value, False # Return original value without error + + +def clip_code(x, y, x_min, x_max, y_min, y_max): + # Encode point position with respect to boundary box + code = 0 + if x < x_min: + code = 1 # Left + if x > x_max: + code |= 2 # Right + if y < y_min: + code |= 4 # Top + if y > y_max: + code |= 8 # Bottom + return code + + +def clip_segment(segment, bounds): + """ + Given an input line segment [[x1,y1],[x2,y2]], as well as a + rectangular bounding region [[x_min,y_min],[x_max,y_max]], clip and + keep the part of the segment within the bounding region, using the + Cohen–Sutherland algorithm. + Return a boolean value, "accept", indicating that the output + segment is non-empty, as well as truncated segment, + [[x1',y1'],[x2',y2']], giving the portion of the input line segment + that fits within the bounds. + """ + + x1 = segment[0][0] + y1 = segment[0][1] + x2 = segment[1][0] + y2 = segment[1][1] + + x_min = bounds[0][0] + y_min = bounds[0][1] + x_max = bounds[1][0] + y_max = bounds[1][1] + + while True: # Repeat until return + code_1 = clip_code(x1, y1, x_min, x_max, y_min, y_max) + code_2 = clip_code(x2, y2, x_min, x_max, y_min, y_max) + + # Trivial accept: + if code_1 == 0 and code_2 == 0: + return True, segment # Both endpoints are within bounds. + # Trivial reject, if both endpoints are outside, and on the same side: + if code_1 & code_2: + return False, segment # Verify with bitwise AND. + + # Otherwise, at least one point is out of bounds; not trivial. + if code_1 != 0: + code = code_1 + else: + code = code_2 + + # Clip at a single boundary; may need to do this up to twice per vertex + + if code & 1: # Vertex on LEFT side of bounds: + x = x_min # Find intersection of our segment with x_min + slope = (y2 - y1) / (x2 - x1) + y = slope * (x_min - x1) + y1 + + elif code & 2: # Vertex on RIGHT side of bounds: + x = x_max # Find intersection of our segment with x_max + slope = (y2 - y1) / (x2 - x1) + y = slope * (x_max - x1) + y1 + + elif code & 4: # Vertex on TOP side of bounds: + y = y_min # Find intersection of our segment with y_min + slope = (x2 - x1) / (y2 - y1) + x = slope * (y_min - y1) + x1 + + elif code & 8: # Vertex on BOTTOM side of bounds: + y = y_max # Find intersection of our segment with y_max + slope = (x2 - x1) / (y2 - y1) + x = slope * (y_max - y1) + x1 + + if code == code_1: + x1 = x + y1 = y + else: + x2 = x + y2 = y + segment = [[x1,y1],[x2,y2]] # Now checking this clipped segment + + +def constrainLimits(value, lower_bound, upper_bound): + # Limit a value to within a range. + return max(lower_bound, min(upper_bound, value)) + + +def distance(x, y): + """ + Pythagorean theorem + """ + return sqrt(x * x + y * y) + + +def dotProductXY(input_vector_first, input_vector_second): + temp = input_vector_first[0] * input_vector_second[0] + input_vector_first[1] * input_vector_second[1] + if temp > 1: + return 1 + elif temp < -1: + return -1 + else: + return temp + + +def getLength(altself, name, default): + """ + Get the attribute with name "name" and default value "default" + Parse the attribute into a value and associated units. Then, accept + no units (''), units of pixels ('px'), and units of percentage ('%'). + Return value in px. + """ + string_to_parse = altself.document.getroot().get(name) + + if string_to_parse: + v, u = parseLengthWithUnits(string_to_parse) + if v is None: + return None + elif u == '' or u == 'px': + return float(v) + elif u == 'in': + return float(v) * PX_PER_INCH + elif u == 'mm': + return float(v) * PX_PER_INCH / 25.4 + elif u == 'cm': + return float(v) * PX_PER_INCH / 2.54 + elif u == 'Q' or u == 'q': + return float(v) * PX_PER_INCH / (40.0 * 2.54) + elif u == 'pc': + return float(v) * PX_PER_INCH / 6.0 + elif u == 'pt': + return float(v) * PX_PER_INCH / 72.0 + elif u == '%': + return float(default) * v / 100.0 + else: + # Unsupported units + return None + else: + # No width specified; assume the default value + return float(default) + + +def getLengthInches(altself, name): + """ + Get the attribute with name "name", and parse it as a length, + into a value and associated units. Return value in inches. + + As of version 0.11, units of 'px' or no units ('') are interpreted + as imported px, at a resolution of 96 px per inch, as per the SVG + specification. (Prior versions returned None in this case.) + + This allows certain imported SVG files, (imported with units of px) + to plot while they would not previously. However, it may also cause + new scaling issues in some circumstances. Note, for example, that + Adobe Illustrator uses 72 px per inch, and Inkscape used 90 px per + inch prior to version 0.92. + """ + string_to_parse = altself.document.getroot().get(name) + if string_to_parse: + v, u = parseLengthWithUnits(string_to_parse) + if v is None: + return None + elif u == 'in': + return float(v) + elif u == 'mm': + return float(v) / 25.4 + elif u == 'cm': + return float(v) / 2.54 + elif u == 'Q' or u == 'q': + return float(v) / (40.0 * 2.54) + elif u == 'pc': + return float(v) / 6.0 + elif u == 'pt': + return float(v) / 72.0 + elif u == '' or u == 'px': + return float(v) / 96.0 + else: + # Unsupported units, including '%' + return None + + +def parseLengthWithUnits(string_to_parse): + """ + Parse an SVG value which may or may not have units attached. + There is a more general routine to consider in scour.py if more + generality is ever needed. + """ + u = 'px' + s = string_to_parse.strip() + if s[-2:] == 'px': # pixels, at a size of PX_PER_INCH per inch + s = s[:-2] + elif s[-2:] == 'in': # inches + s = s[:-2] + u = 'in' + elif s[-2:] == 'mm': # millimeters + s = s[:-2] + u = 'mm' + elif s[-2:] == 'cm': # centimeters + s = s[:-2] + u = 'cm' + elif s[-2:] == 'pt': # points; 1pt = 1/72th of 1in + s = s[:-2] + u = 'pt' + elif s[-2:] == 'pc': # picas; 1pc = 1/6th of 1in + s = s[:-2] + u = 'pc' + elif s[-1:] == 'Q' or s[-1:] == 'q': # quarter-millimeters. 1q = 1/40th of 1cm + s = s[:-1] + u = 'Q' + elif s[-1:] == '%': + u = '%' + s = s[:-1] + + try: + v = float(s) + except: + return None, None + + return v, u + + +def unitsToUserUnits(input_string): + """ + Custom replacement for the unittouu routine in inkex.py + + Parse the attribute into a value and associated units. + Return value in user units (typically "px"). + """ + + v, u = parseLengthWithUnits(input_string) + if v is None: + return None + elif u == '' or u == 'px': + return float(v) + elif u == 'in': + return float(v) * PX_PER_INCH + elif u == 'mm': + return float(v) * PX_PER_INCH / 25.4 + elif u == 'cm': + return float(v) * PX_PER_INCH / 2.54 + elif u == 'Q' or u == 'q': + return float(v) * PX_PER_INCH / (40.0 * 2.54) + elif u == 'pc': + return float(v) * PX_PER_INCH / 6.0 + elif u == 'pt': + return float(v) * PX_PER_INCH / 72.0 + elif u == '%': + return float(v) / 100.0 + else: + # Unsupported units + return None + + +def subdivideCubicPath(sp, flat, i=1): + """ + Break up a bezier curve into smaller curves, each of which + is approximately a straight line within a given tolerance + (the "smoothness" defined by [flat]). + + This is a modified version of cspsubdiv.cspsubdiv(). I rewrote the recursive + call because it caused recursion-depth errors on complicated line segments. + """ + + while True: + while True: + if i >= len(sp): + return + p0 = sp[i - 1][1] + p1 = sp[i - 1][2] + p2 = sp[i][0] + p3 = sp[i][1] + + b = (p0, p1, p2, p3) + + if cspsubdiv.maxdist(b) > flat: + break + i += 1 + + one, two = bezmisc.beziersplitatt(b, 0.5) + sp[i - 1][2] = one[1] + sp[i][0] = two[2] + p = [one[2], one[3], two[1]] + sp[i:1] = [p] + +def max_dist_from_n_points(input): + """ + Like cspsubdiv.maxdist, but it can check for distances of any number of points >= 0. + + `input` is an ordered collection of points, each point specified as an x- and y-coordinate. + The first point and the last point define the segment we are finding distances from. + + does not mutate `input` + """ + assert len(input) >= 3, "There must be points (other than begin/end) to check." + + points = [ffgeom.Point(point[0], point[1]) for point in input] + segment = ffgeom.Segment(points.pop(0), points.pop()) + + distances = [segment.distanceToPoint(point) for point in points] + return max(distances) + +def supersample(vertices, tolerance): + """ + Given a list of vertices, remove some according to the following algorithm. + + Suppose that the vertex list consists of points A, B, C, D, E, and so forth, which define segments AB, BC, CD, DE, EF, and so on. + + We first test to see if vertex B can be removed, by using perpDistanceToPoint to check whether the distance between B and segment AC is less than tolerance. + If B can be removed, then check to see if the next vertex, C, can be removed. Both B and C can be removed if the both the distance between B and AD is less than Tolerance and the distance between C and AD is less than Tolerance. Continue removing additional vertices, so long as the perpendicular distance between every point removed and the resulting segment is less than tolerance (and the end of the vertex list is not reached). +If B cannot be removed, then move onto vertex C, and perform the same checks, until the end of the vertex list is reached. + """ + if len(vertices) <= 2: # there is nothing to delete + return vertices + + start_index = 0 # can't remove first vertex + while start_index < len(vertices) - 2: + end_index = start_index + 2 + # test the removal of (start_index, end_index), exclusive until we can't advance end_index + while (max_dist_from_n_points(vertices[start_index:end_index + 1]) < tolerance + and end_index < len(vertices)): + end_index += 1 # try removing the next vertex too + + vertices[start_index + 1:end_index - 1] = [] # delete (start_index, end_index), exclusive + start_index += 1 + +def userUnitToUnits(distance_uu, unit_string): + """ + Custom replacement for the uutounit routine in inkex.py + + Parse the attribute into a value and associated units. + Return value in user units (typically "px"). + """ + + if distance_uu is None: # Couldn't parse the value + return None + elif unit_string == '' or unit_string == 'px': + return float(distance_uu) + elif unit_string == 'in': + return float(distance_uu) / PX_PER_INCH + elif unit_string == 'mm': + return float(distance_uu) / (PX_PER_INCH / 25.4) + elif unit_string == 'cm': + return float(distance_uu) / (PX_PER_INCH / 2.54) + elif unit_string == 'Q' or unit_string == 'q': + return float(distance_uu) / (PX_PER_INCH / (40.0 * 2.54)) + elif unit_string == 'pc': + return float(distance_uu) / (PX_PER_INCH / 6.0) + elif unit_string == 'pt': + return float(distance_uu) / (PX_PER_INCH / 72.0) + elif unit_string == '%': + return float(distance_uu) * 100.0 + else: + # Unsupported units + return None + + +def vb_scale(vb, p_a_r, doc_width, doc_height): + """" + Parse SVG viewbox and generate scaling parameters. + Reference documentation: https://www.w3.org/TR/SVG11/coords.html + + Inputs: + vb: Contents of SVG viewbox attribute + p_a_r: Contents of SVG preserveAspectRatio attribute + doc_width: Width of SVG document + doc_height: Height of SVG document + + Output: sx, sy, ox, oy + Scale parameters (sx,sy) and offset parameters (ox,oy) + + """ + if vb is None: + return 1,1,0,0 # No viewbox; return default transform + else: + vb_array = vb.strip().replace(',', ' ').split() + + if len(vb_array) < 4: + return 1,1,0,0 # invalid viewbox; return default transform + + min_x = float(vb_array[0]) # Viewbox offset: x + min_y = float(vb_array[1]) # Viewbox offset: y + width = float(vb_array[2]) # Viewbox width + height = float(vb_array[3]) # Viewbox height + + if width <= 0 or height <= 0: + return 1,1,0,0 # invalid viewbox; return default transform + + d_width = float(doc_width) + d_height = float(doc_height) + + if d_width <= 0 or d_height <= 0: + return 1,1,0,0 # invalid document size; return default transform + + ar_doc = d_height / d_width # Document aspect ratio + ar_vb = height / width # Viewbox aspect ratio + + # Default values of the two preserveAspectRatio parameters: + par_align = "xmidymid" # "align" parameter (lowercased) + par_mos = "meet" # "meetOrSlice" parameter + + if p_a_r is not None: + par_array = p_a_r.strip().replace(',', ' ').lower().split() + if len(par_array) > 0: + par0 = par_array[0] + if par0 == "defer": + if len(par_array) > 1: + par_align = par_array[1] + if len(par_array) > 2: + par_mos = par_array[2] + else: + par_align = par0 + if len(par_array) > 1: + par_mos = par_array[1] + + if par_align == "none": + # Scale document to fill page. Do not preserve aspect ratio. + # This is not default behavior, nor what happens if par_align + # is not given; the "none" value must be _explicitly_ specified. + + sx = d_width/ width + sy = d_height / height + ox = -min_x + oy = -min_y + return sx,sy,ox,oy + + """ + Other than "none", all situations fall into two classes: + + 1) (ar_doc >= ar_vb AND par_mos == "meet") + or (ar_doc < ar_vb AND par_mos == "slice") + -> In these cases, scale document up until VB fills doc in X. + + 2) All other cases, i.e., + (ar_doc < ar_vb AND par_mos == "meet") + or (ar_doc >= ar_vb AND par_mos == "slice") + -> In these cases, scale document up until VB fills doc in Y. + + Note in cases where the scaled viewbox exceeds the document + (page) boundaries (all "slice" cases and many "meet" cases where + an offset value is given) that this routine does not perform + any clipping, but subsequent clipping to the page boundary + is appropriate. + + Besides "none", there are 9 possible values of par_align: + xminymin xmidymin xmaxymin + xminymid xmidymid xmaxymid + xminymax xmidymax xmaxymax + """ + + if (((ar_doc >= ar_vb) and (par_mos == "meet")) + or ((ar_doc < ar_vb) and (par_mos == "slice"))): + # Case 1: Scale document up until VB fills doc in X. + + sx = d_width / width + sy = sx # Uniform aspect ratio + ox = -min_x + + scaled_vb_height = ar_doc * width + excess_height = scaled_vb_height - height + + if par_align in {"xminymin", "xmidymin", "xmaxymin"}: + # Case: Y-Min: Align viewbox to minimum Y of the viewport. + oy = -min_y + # OK: tested with Tall-Meet, Wide-Slice + + elif par_align in {"xminymax", "xmidymax", "xmaxymax"}: + # Case: Y-Max: Align viewbox to maximum Y of the viewport. + oy = -min_y + excess_height + # OK: tested with Tall-Meet, Wide-Slice + + else: # par_align in {"xminymid", "xmidymid", "xmaxymid"}: + # Default case: Y-Mid: Center viewbox on page in Y + oy = -min_y + excess_height / 2 + # OK: Tested with Tall-Meet, Wide-Slice + + return sx,sy,ox,oy + else: + # Case 2: Scale document up until VB fills doc in Y. + + sy = d_height / height + sx = sy # Uniform aspect ratio + oy = -min_y + + scaled_vb_width = height / ar_doc + excess_width = scaled_vb_width - width + + if par_align in {"xminymin", "xminymid", "xminymax"}: + # Case: X-Min: Align viewbox to minimum X of the viewport. + ox = -min_x + # OK: Tested with Tall-Slice, Wide-Meet + + elif par_align in {"xmaxymin", "xmaxymid", "xmaxymax"}: + # Case: X-Max: Align viewbox to maximum X of the viewport. + ox = -min_x + excess_width + # Need test: Tall-Slice, Wide-Meet + + else: # par_align in {"xmidymin", "xmidymid", "xmidymax"}: + # Default case: X-Mid: Center viewbox on page in X + ox = -min_x + excess_width / 2 + # OK: Tested with Tall-Slice, Wide-Meet + + return sx,sy,ox,oy + return 1,1,0,0 # Catch-all: return default transform + + +def vInitial_VF_A_Dx(v_final, acceleration, delta_x): + """ + Kinematic calculation: Maximum allowed initial velocity to arrive at distance X + with specified final velocity, and given maximum linear acceleration. + + Calculate and return the (real) initial velocity, given an final velocity, + acceleration rate, and distance interval. + + Uses the kinematic equation Vi^2 = Vf^2 - 2 a D_x , where + Vf is the final velocity, + a is the acceleration rate, + D_x (delta x) is the distance interval, and + Vi is the initial velocity. + + We are looking at the positive root only-- if the argument of the sqrt + is less than zero, return -1, to indicate a failure. + """ + initial_v_squared = (v_final * v_final) - (2 * acceleration * delta_x) + if initial_v_squared > 0: + return sqrt(initial_v_squared) + else: + return -1 + + +def vFinal_Vi_A_Dx(v_initial, acceleration, delta_x): + """ + Kinematic calculation: Final velocity with constant linear acceleration. + + Calculate and return the (real) final velocity, given an initial velocity, + acceleration rate, and distance interval. + + Uses the kinematic equation Vf^2 = 2 a D_x + Vi^2, where + Vf is the final velocity, + a is the acceleration rate, + D_x (delta x) is the distance interval, and + Vi is the initial velocity. + + We are looking at the positive root only-- if the argument of the sqrt + is less than zero, return -1, to indicate a failure. + """ + final_v_squared = (2 * acceleration * delta_x) + (v_initial * v_initial) + if final_v_squared > 0: + return sqrt(final_v_squared) + else: + return -1 + + +def pathdata_first_point(path): + """ + Return the first (X,Y) point from an SVG path data string + + Input: A path data string; the text of the 'd' attribute of an SVG path + Output: Two floats in a list representing the x and y coordinates of the first point + """ + + # Path origin's default values are used to see if we have + # Written anything to the path_origin variable yet + MaxLength = len(path) + ix = 0 + tempString = '' + x_val = '' + y_val = '' + # Check one char at a time + # until we have the moveTo Command + while ix < MaxLength: + if path[ix].upper() == 'M': + break + # Increment until we have M + ix = ix + 1 + + # Parse path until we reach a digit, decimal point or negative sign + while ix < MaxLength: + if(path[ix].isdigit()) or path[ix] == '.' or path[ix] == '-': + break + ix = ix + 1 + + # Add digits and decimal points to x_val + # Stop parsing when next character is neither a digit nor a decimal point + while ix < MaxLength: + if (path[ix].isdigit()): + tempString = tempString + path[ix] + x_val = float(tempString ) + ix = ix + 1 + # If next character is a decimal place, save the decimal and continue parsing + # This allows for paths without leading zeros to be parsed correctly + elif (path[ix] == '.' or path[ix] == '-'): + tempString = tempString + path[ix] + ix = ix + 1 + else: + ix = ix + 1 + break + + # Reset tempString for y coordinate + tempString = '' + + # Parse path until we reach a digit or decimal point + while ix < MaxLength: + if(path[ix].isdigit()) or path[ix] == '.' or path[ix] == '-': + break + ix = ix + 1 + + # Add digits and decimal points to y_val + # Stop parsin when next character is neither a digit nor a decimal point + while ix < MaxLength: + if (path[ix].isdigit() ): + tempString = tempString + path[ix] + y_val = float(tempString) + ix = ix + 1 + # If next character is a decimal place, save the decimal and continue parsing + # This allows for paths without leading zeros to be parsed correctly + elif (path[ix] == '.' or path[ix] == '-'): + tempString = tempString + path[ix] + ix = ix + 1 + else: + ix = ix + 1 + break + return [x_val,y_val] + + +def pathdata_last_point(path): + """ + Return the last (X,Y) point from an SVG path data string + + Input: A path data string; the text of the 'd' attribute of an SVG path + Output: Two floats in a list representing the x and y coordinates of the last point + """ + + command, params = simplepath.parsePath(path)[-1] # parsePath splits path into segments + + if command.upper() == 'Z': + return pathdata_first_point(path) # Trivial case + + """ + Otherwise: The last command should be in the set 'MLCQA' + - All commands converted to absolute by parsePath. + - Can ignore Z (case handled) + - Can ignore H,V, since those are converted to L by parsePath. + - Can ignore S, converted to C by parsePath. + - Can ignore T, converted to Q by parsePath. + + MLCQA: Commands all ending in (X,Y) pair. + """ + + x_val = params[-2] # Second to last parameter given + y_val = params[-1] # Last parameter given + + return [x_val,y_val] diff --git a/extensions/fablabchemnitz/reorder_sequence/reorder_sequence.inx b/extensions/fablabchemnitz/reorder_sequence/reorder_sequence.inx new file mode 100755 index 00000000..d28f0104 --- /dev/null +++ b/extensions/fablabchemnitz/reorder_sequence/reorder_sequence.inx @@ -0,0 +1,25 @@ + + + Optimize Sequence: Travel Distances + fablabchemnitz.de.svg_reorder + + + + + + + +false + + + all + + + + + + + + \ No newline at end of file diff --git a/extensions/fablabchemnitz/reorder_sequence/reorder_sequence.py b/extensions/fablabchemnitz/reorder_sequence/reorder_sequence.py new file mode 100644 index 00000000..c2e78738 --- /dev/null +++ b/extensions/fablabchemnitz/reorder_sequence/reorder_sequence.py @@ -0,0 +1,1242 @@ +# coding=utf-8 +# +# SVG Path Ordering Extension +# This extension uses a simple TSP algorithm to order the paths so as +# to reduce plotting time by plotting nearby paths consecutively. +# +# +# While written from scratch, this is a derivative in spirit of the work by +# Matthew Beckler and Daniel C. Newman for the EggBot project. +# +# The MIT License (MIT) +# +# Copyright (c) 2020 Windell H. Oskay, Evil Mad Science LLC +# www.evilmadscientist.com +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import math +import sys +from lxml import etree +import inkex +import simpletransform +import simplestyle +import plot_utils + +""" +TODOs: + +* Apparent difference in execution time for portrait vs landscape document orientation. + Seems to be related to the _change_ + +* Implement path functions + + +<_option value=0>Leave as is +<_option value=1>Reorder subpaths +<_option value=2>Break apart + + +self.OptionParser.add_option( "--path_handling",\ +action="store", type="int", dest="path_handling",\ +default=1,help="How compound paths are handled") + + +* Consider re-introducing GUI method for rendering: + +false + + +""" + +class ReorderEffect(inkex.Effect): + """ + Inkscape effect extension. + Re-order the objects in the SVG document for faster plotting. + Respect layers: Initialize a new dictionary of objects for each layer, and sort + objects within that layer only + Objects in root of document are treated as being on a _single_ layer, and will all + be sorted. + + """ + + def __init__( self ): + inkex.Effect.__init__( self ) + + self.arg_parser.add_argument( "--reordering",type=int, default=1, help="How groups are handled") + self.arg_parser.add_argument( "--preview_rendering",type=inkex.Boolean, default=False, help="Preview rendering") # Rendering is available for debug purposes. It only previews pen-up movements that are reordered and typically does not include all possible movement. + self.auto_rotate = True + + def effect(self): + # Main entry point of the program + + self.svg_width = 0 + self.svg_height = 0 + self.air_total_default = 0 + self.air_total_sorted = 0 + self.printPortrait = False + + self.layer_index = 0 # index for coloring layers + self.svg = self.document.getroot() + self.DocUnits = "in" # Default + self.DocUnits = self.svg.unit + self.unit_scaling = 1 + self.getDocProps() + + """ + Set up the document-wide transforms to handle SVG viewbox + """ + + matCurrent = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + + viewbox = self.svg.get( 'viewBox' ) + + vb = self.svg.get('viewBox') + if vb: + p_a_r = self.svg.get('preserveAspectRatio') + sx,sy,ox,oy = plot_utils.vb_scale(vb, p_a_r, self.svg_width, self.svg_height) + else: + sx = 1.0 / float(plot_utils.PX_PER_INCH) # Handle case of no viewbox + sy = sx + ox = 0.0 + oy = 0.0 + + # Initial transform of document is based on viewbox, if present: + matCurrent = simpletransform.parseTransform('scale({0:.6E},{1:.6E}) translate({2:.6E},{3:.6E})'.format(sx, sy, ox, oy)) + # Set up x_last, y_last, which keep track of last known pen position + # The initial position is given by the expected initial pen position + + self.y_last = 0 + + if (self.printPortrait): + self.x_last = self.svg_width + else: + self.x_last = 0 + + parent_vis='visible' + + self.root_nodes = [] + + if self.options.preview_rendering == True: + # Remove old preview layers, if rendering is enabled + for node in self.svg: + if node.tag == inkex.addNS( 'g', 'svg' ) or node.tag == 'g': + if ( node.get( inkex.addNS( 'groupmode', 'inkscape' ) ) == 'layer' ): + LayerName = node.get( inkex.addNS( 'label', 'inkscape' ) ) + if LayerName == '% Preview': + self.svg.remove( node ) + + preview_transform = simpletransform.parseTransform( + 'translate({2:.6E},{3:.6E}) scale({0:.6E},{1:.6E})'.format( + 1.0/sx, 1.0/sy, -ox, -oy)) + path_attrs = { 'transform': simpletransform.formatTransform(preview_transform)} + self.preview_layer = etree.Element(inkex.addNS('g', 'svg'), + path_attrs, nsmap=inkex.NSS) + + + self.preview_layer.set( inkex.addNS('groupmode', 'inkscape' ), 'layer' ) + self.preview_layer.set( inkex.addNS( 'label', 'inkscape' ), '% Preview' ) + self.svg.append( self.preview_layer ) + + + # Preview stroke width: 1/1000 of page width or height, whichever is smaller + if self.svg_width < self.svg_height: + width_du = self.svg_width / 1000.0 + else: + width_du = self.svg_height / 1000.0 + + """ + Stroke-width is a css style element, and cannot accept scientific notation. + + Thus, in cases with large scaling (i.e., high values of 1/sx, 1/sy) + resulting from the viewbox attribute of the SVG document, it may be necessary to use + a _very small_ stroke width, so that the stroke width displayed on the screen + has a reasonable width after being displayed greatly magnified thanks to the viewbox. + + Use log10(the number) to determine the scale, and thus the precision needed. + """ + + log_ten = math.log10(width_du) + if log_ten > 0: # For width_du > 1 + width_string = "{0:.3f}".format(width_du) + else: + prec = int(math.ceil(-log_ten) + 3) + width_string = "{0:.{1}f}".format(width_du, prec) + + self.p_style = {'stroke-width': width_string, 'fill': 'none', + 'stroke-linejoin': 'round', 'stroke-linecap': 'round'} + + self.svg = self.parse_svg(self.svg, matCurrent) + + + def parse_svg(self, input_node, mat_current=None, parent_vis='visible'): + """ + Input: An SVG node (usually) containing other nodes: + The SVG root, a layer, sublayer, or other group. + Output: The re-ordered node. The contents are reordered with the greedy + algorithm, except: + - Layers and sublayers are preserved. The contents of each are + re-ordered for faster plotting. + - Groups are either preserved, broken apart, or re-ordered within + the group, depending on the value of group_mode. + """ + + coord_dict = {} + # coord_dict maps a node ID to the following data: + # Is the node plottable, first coordinate pair, last coordinate pair. + # i.e., Node_id -> (Boolean: plottable, Xi, Yi, Xf, Yf) + + group_dict = {} + # group_dict maps a node ID for a group to the contents of that group. + # The contents may be a preserved nested group or a flat list, depending + # on the selected group handling mode. Example: + # group_dict = {'id_1': , + # 'id_2': + + nodes_to_delete = [] + + counter = 0 # TODO: Replace this with better unique ID system + + # Account for input_node's transform and any transforms above it: + if mat_current is None: + mat_current = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + try: + matNew = simpletransform.composeTransform( mat_current, + simpletransform.parseTransform( input_node.get( "transform" ))) + except AttributeError: + matNew = mat_current + + for node in input_node: + # Step through each object within the top-level input node + + + if node.tag is etree.Comment: + continue + + try: + id = node.get( 'id' ) + except AttributeError: + id = self.svg.get_unique_id("1",True) + node.set( 'id', id) + if id == None: + id = self.svg.get_unique_id("1",True) + node.set( 'id', id) + + + # First check for object visibility: + skip_object = False + + # Check for "display:none" in the node's style attribute: + style = dict(inkex.Style.parse_str(node.get('style'))) + if 'display' in style.keys() and style['display'] == 'none': + skip_object = True # Plot neither this object nor its children + + # The node may have a display="none" attribute as well: + if node.get( 'display' ) == 'none': + skip_object = True # Plot neither this object nor its children + + # Visibility attributes control whether a given object will plot. + # Children of hidden (not visible) parents may be plotted if + # they assert visibility. + visibility = node.get( 'visibility', parent_vis ) + + if 'visibility' in style.keys(): + visibility = style['visibility'] # Style may override attribute. + + if visibility == 'inherit': + visibility = parent_vis + + if visibility != 'visible': + skip_object = True # Skip this object and its children + + # Next, check to see if this inner node is itself a group or layer: + if node.tag == inkex.addNS( 'g', 'svg' ) or node.tag == 'g': + + # Use the user-given option to decide what to do with subgroups: + subgroup_mode = self.options.reordering + +# Values of the parameter: +# subgroup_mode=="1": Preserve groups +# subgroup_mode=="2": Reorder within groups +# subgroup_mode=="3": Break apart groups + + if node.get(inkex.addNS('groupmode', 'inkscape')) == 'layer': + # The node is a layer or sub-layer, not a regular group. + # Parse it separately, and re-order its contents. + + subgroup_mode = 2 # Always sort within each layer. + self.layer_index += 1 + + layer_name = node.get( inkex.addNS( 'label', 'inkscape' ) ) + + if sys.version_info < (3,): # Yes this is ugly. More elegant suggestions welcome. :) + layer_name = layer_name.encode( 'ascii', 'ignore' ) #Drop non-ascii characters + else: + layer_name = str(layer_name) + layer_name.lstrip # Remove leading whitespace + + if layer_name: + if layer_name[0] == '%': # First character is '%'; This + skip_object = True # is a documentation layer; skip plotting. + self.layer_index -= 1 # Set this back to previous value. + + if skip_object: + # Do not re-order hidden groups or layers. + subgroup_mode = 1 # Preserve this group + + if subgroup_mode == 3: + # Break apart this non-layer subgroup and add it to + # the set of things to be re-ordered. + + nodes_to_delete.append(node) + nodes_inside_group = self.group2NodeDict(node) + + for a_node in nodes_inside_group: + try: + id = a_node.get( 'id' ) + except AttributeError: + id = self.uniqueId("1",True) + a_node.set( 'id', id) + if id == None: + id = self.uniqueId("1",True) + a_node.set( 'id', id) + + # Use getFirstPoint and getLastPoint on each object: + start_plottable, first_point = self.getFirstPoint(a_node, matNew) + end_plottable, last_point = self.getLastPoint(a_node, matNew) + + coord_dict[id] = (start_plottable and end_plottable, + first_point[0], first_point[1], last_point[0], last_point[1] ) + # Entry in group_dict is this node + group_dict[id] = a_node + + elif subgroup_mode == 2: + # Reorder a layer or subgroup with a recursive call. + + node = self.parse_svg(node, matNew, visibility) + + # Capture the first and last x,y coordinates of the optimized node + start_plottable, first_point = self.group_first_pt(node, matNew) + end_plottable, last_point = self.group_last_pt(node, matNew) + + # Then add this optimized node to the coord_dict + coord_dict[id] = (start_plottable and end_plottable, + first_point[0], first_point[1], last_point[0], last_point[1] ) + # Entry in group_dict is this node + group_dict[id] = node + + else: # (subgroup_mode == 1) + # Preserve the group, but find its first and last point so + # that it can be re-ordered with respect to other items + + if skip_object: + start_plottable = False + end_plottable = False + first_point = [(-1.), (-1.)] + last_point = [(-1.), (-1.)] + else: + start_plottable, first_point = self.group_first_pt(node, matNew) + end_plottable, last_point = self.group_last_pt(node, matNew) + + coord_dict[id] = (start_plottable and end_plottable, + first_point[0], first_point[1], last_point[0], last_point[1] ) + # Entry in group_dict is this node + group_dict[id] = node + + else: # Handle objects that are not groups + if skip_object: + start_plottable = False + end_plottable = False + first_point = [(-1.), (-1.)] + last_point = [(-1.), (-1.)] + else: + start_plottable, first_point = self.getFirstPoint(node, matNew) + end_plottable, last_point = self.getLastPoint(node, matNew) + + coord_dict[id] = (start_plottable and end_plottable, + first_point[0], first_point[1], last_point[0], last_point[1] ) + group_dict[id] = node # Entry in group_dict is this node + + # Perform the re-ordering: + ordered_element_list = self.ReorderNodeList(coord_dict, group_dict) + + # Once a better order for the svg elements has been determined, + # All there is do to is to reintroduce the nodes to the parent in the correct order + for elt in ordered_element_list: + # Creates identical node at the correct location according to ordered_element_list + input_node.append(elt) + # Once program is finished parsing through + for element_to_remove in nodes_to_delete: + try: + input_node.remove(element_to_remove) + except ValueError: + inkex.errormsg(str(element_to_remove.get('id'))+" is not a member of " + str(input_node.get('id'))) + + return input_node + + + def break_apart_path(self, path): + """ + An SVG path may contain multiple distinct portions, that are normally separated + by pen-up movements. + + This function takes the path data string from an SVG path, parses it, and returns + a dictionary of independent path data strings, each of which represents a single + pen-down movement. It is equivalent to the Inkscape function Path > Break Apart + + Input: path data string, representing a single SVG path + Output: Dictionary of (separated) path data strings + + """ + MaxLength = len(path) + ix = 0 + move_to_location = [] + path_dictionary = {} + path_list = [] + path_number = 1 + + # Search for M or m location + while ix < MaxLength: + if(path[ix] == 'm' or path[ix] == 'M'): + move_to_location.append(ix) + ix = ix + 1 + # Iterate through every M or m location in our list of move to instructions + # Slice the path string according to path beginning and ends as indicated by the + # location of these instructions + + for counter, m in enumerate(move_to_location): + if (m == move_to_location[-1]): + # last entry + path_list.append(path[m:MaxLength].rstrip()) + else: + path_list.append(path[m:move_to_location[counter + 1]].rstrip()) + + for counter, current_path in enumerate(path_list): + + # Enumerate over every entry in the path looking for relative m commands + if current_path[0] == 'm' and counter > 0: + # If path contains relative m command, the best case is when the last command + # was a Z or z. In this case, all relative operations are performed relative to + # initial x, y coordinates of the previous path + + if path_list[counter -1][-1].upper() == 'Z': + current_path_x, current_path_y,index = self.getFirstPoint(current_path, matNew) + prev_path_x, prev_path_y,ignore = self.getFirstPoint(path_list[counter-1]) + adapted_x = current_path_x + prev_path_x + adapted_y = current_path_y + prev_path_y + # Now we can replace the path data with an Absolute Move to instruction + # HOWEVER, we need to adapt all the data until we reach a different command in the case of a repeating + path_list[counter] = "m "+str(adapted_x)+","+str(adapted_y) + ' ' +current_path[index:] + + # If there is no z or absolute commands, we need to parse the entire path + else: + + # scan path for absolute coordinates. If present, begin parsing from their index + # instead of the beginning + prev_path = path_list[counter-1] + prev_path_length = len(prev_path) + jx = 0 + x_val, y_val = 0,0 + # Check one char at a time + # until we have the moveTo Command + last_command = '' + is_absolute_command = False + repeated_command = False + # name of command + # how many parameters we need to skip + accepted_commands = { + 'M':0, + 'L':0, + 'H':0, + 'V':0, + 'C':4, + 'S':2, + 'Q':2, + 'T':0, + 'A':5 + } + + # If there is an absolute command which specifies a new initial point + # then we can save time by setting our index directly to its location in the path data + # See if an accepted_command is present in the path data. If it is present further in the + # string than any command found before, then set the pointer to that location + # if a command is not found, find() will return a -1. jx is initialized to 0, so if no matches + # are found, the program will parse from the beginning to the end of the path + + for keys in 'MLCSQTA': # TODO: Compare to last_point; see if we can clean up this part + if(prev_path.find(keys) > jx): + jx = prev_path.find(keys) + + while jx < prev_path_length: + + temp_x_val = '' + temp_y_val = '' + num_of_params_to_skip = 0 + + # SVG Path commands can be repeated + if (prev_path[jx].isdigit() and last_command): + repeated_command = True + else: + repeated_command = False + + if (prev_path[jx].isalpha() and prev_path[jx].upper() in accepted_commands) or repeated_command: + + if repeated_command: + #is_relative_command is saved from last iteration of the loop + current_command = last_command + else: + # If the character is accepted, we must parse until reach the x y coordinates + is_absolute_command = prev_path[jx].isupper() + current_command = prev_path[jx].upper() + + # Each command has a certain number of parameters we must pass before we reach the + # information we care about. We will parse until we know that we have reached them + + # Get to start of next number + # We will know we have reached a number if the current character is a +/- sign + # or current character is a digit + while jx < prev_path_length: + if(prev_path[jx] in '+-' or prev_path[jx].isdigit()): + break + jx = jx + 1 + + # We need to parse past the unused parameters in our command + # The number of parameters to parse past is dependent on the command and stored + # as the value of accepted_command + # Spaces and commas are used to deliniate paramters + while jx < prev_path_length and num_of_params_to_skip < accepted_commands[current_command]: + if(prev_path[jx].isspace() or prev_path[jx] == ','): + num_of_params_to_skip = num_of_params_to_skip + 1 + jx = jx + 1 + + # Now, we are in front of the x character + + if current_command.upper() == 'V': + temp_x_val = 0 + + if current_command.upper() == 'H': + temp_y_val = 0 + + # Parse until next character is a digit or +/- character + while jx < prev_path_length and current_command.upper() != 'V': + if(prev_path[jx] in '+-' or prev_path[jx].isdigit()): + break + jx = jx + 1 + + # Save each next character until we reach a space + while jx < prev_path_length and current_command.upper() != 'V' and not (prev_path[jx].isspace() or prev_path[jx] == ','): + temp_x_val = temp_x_val + prev_path[jx] + jx = jx + 1 + + # Then we know we have completely parsed the x character + + # Now we are in front of the y character + + # Parse until next character is a digit or +/- character + while jx < prev_path_length and current_command.upper() != 'H': + if(prev_path[jx] in '+-' or prev_path[jx].isdigit()): + break + jx = jx + 1 + + ## Save each next character until we reach a space + while jx < prev_path_length and current_command.upper() != 'H' and not (prev_path[jx].isspace() or prev_path[jx] == ','): + temp_y_val = temp_y_val + prev_path[jx] + jx = jx + 1 + + # Then we know we have completely parsed the y character + + if is_absolute_command: + + if current_command == 'H': + # Absolute commands create new x,y position + try: + x_val = float(temp_x_val) + except ValueError: + pass + elif current_command == 'V': + # Absolute commands create new x,y position + try: + y_val = float(temp_y_val) + except ValueError: + pass + else: + # Absolute commands create new x,y position + try: + x_val = float(temp_x_val) + y_val = float(temp_y_val) + except ValueError: + pass + else: + + if current_command == 'h': + # Absolute commands create new x,y position + try: + x_val = x_val + float(temp_x_val) + except ValueError: + pass + elif current_command == 'V': + # Absolute commands create new x,y position + try: + y_val = y_val + float(temp_y_val) + except ValueError: + pass + else: + # Absolute commands create new x,y position + try: + x_val = x_val + float(temp_x_val) + y_val = y_val + float(temp_y_val) + except ValueError: + pass + last_command = current_command + jx = jx + 1 + x,y,index = self.getFirstPoint(current_path,None) + path_list[counter] = "m "+str(x_val+x)+","+str(y_val+y) + ' ' + current_path[index:] + + for counter, path in enumerate(path_list): + path_dictionary['ad_path'+ str(counter)] = path + + return path_dictionary + + + def getFirstPoint(self, node, matCurrent): + """ + Input: (non-group) node and parent transformation matrix + Output: Boolean value to indicate if the svg element is plottable and + two floats stored in a list representing the x and y coordinates we plot first + """ + + # first apply the current matrix transform to this node's transform + matNew = simpletransform.composeTransform( matCurrent, simpletransform.parseTransform( node.get( "transform" ) ) ) + + point = [float(-1), float(-1)] + try: + if node.tag == inkex.addNS( 'path', 'svg' ): + + pathdata = node.get('d') + + point = plot_utils.pathdata_first_point(pathdata) + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS( 'rect', 'svg' ) or node.tag == 'rect': + + """ + The x,y coordinates for a rect are included in their specific attributes + If there is a transform, we need translate the x & y coordinates to their + correct location via applyTransformToPoint. + """ + + point[0] = float( node.get( 'x' ) ) + point[1] = float( node.get( 'y' ) ) + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS( 'line', 'svg' ) or node.tag == 'line': + """ + The x1 and y1 attributes are where we will start to draw + So, get them, apply the transform matrix, and return the point + """ + + point[0] = float( node.get( 'x1' ) ) + point[1] = float( node.get( 'y1' ) ) + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + + if node.tag == inkex.addNS( 'polyline', 'svg' ) or node.tag == 'polyline': + pl = node.get( 'points', '' ).strip() + + if pl == '': + return False, point + + pa = pl.replace(',',' ').split() # replace comma with space before splitting + + if not pa: + return False, point + pathLength = len( pa ) + if (pathLength < 4): # Minimum of x1,y1 x2,y2 required. + return False, point + + d = "M " + pa[0] + " " + pa[1] + i = 2 + while (i < (pathLength - 1 )): + d += " L " + pa[i] + " " + pa[i + 1] + i += 2 + + point = plot_utils.pathdata_first_point(d) + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if (node.tag == inkex.addNS( 'polygon', 'svg' ) or + node.tag == 'polygon'): + """ + We need to extract x1 and y1 from these: + + We accomplish this with Python string strip + and split methods. Then apply transforms + """ + # Strip() removes all whitespace from the start and end of p1 + pl = node.get( 'points', '' ).strip() + if (pl == ''): + # If pl is blank there has been an error, return False and -1,-1 to indicate a problem has occured + return False, point + # Split string by whitespace + pa = pl.split() + if not len( pa ): + # If pa is blank there has been an error, return False and -1,-1 to indicate a problem has occured + return False, point + # pa[0] = "x1,y1 + # split string via comma to get x1 and y1 individually + # then point = [x1,x2] + point = pa[0].split(",") + + point = [float(point[0]),float(point[1])] + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS( 'ellipse', 'svg' ) or \ + node.tag == 'ellipse': + + cx = float( node.get( 'cx', '0' ) ) + cy = float( node.get( 'cy', '0' ) ) + rx = float( node.get( 'rx', '0' ) ) + + point[0] = cx - rx + point[1] = cy + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS( 'circle', 'svg' ) or \ + node.tag == 'circle': + cx = float( node.get( 'cx', '0' ) ) + cy = float( node.get( 'cy', '0' ) ) + r = float( node.get( 'r', '0' ) ) + point[0] = cx - r + point[1] = cy + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS('symbol', 'svg') or node.tag == 'symbol': + # A symbol is much like a group, except that + # it's an invisible object. + return False, point # Skip this element. + + if node.tag == inkex.addNS('use', 'svg') or node.tag == 'use': + + """ + A element refers to another SVG element via an xlink:href="#blah" + attribute. We will handle the element by doing an XPath search through + the document, looking for the element with the matching id="blah" + attribute. We then recursively process that element after applying + any necessary (x,y) translation. + + Notes: + 1. We ignore the height and g attributes as they do not apply to + path-like elements, and + 2. Even if the use element has visibility="hidden", SVG still calls + for processing the referenced element. The referenced element is + hidden only if its visibility is "inherit" or "hidden". + 3. We may be able to unlink clones using the code in pathmodifier.py + """ + + refid = node.get(inkex.addNS('href', 'xlink')) + + if refid is not None: + # [1:] to ignore leading '#' in reference + path = '//*[@id="{0}"]'.format(refid[1:]) + refnode = node.xpath(path) + if refnode is not None: + + x = float(node.get('x', '0')) + y = float(node.get('y', '0')) + + # Note: the transform has already been applied + if x != 0 or y != 0: + mat_new2 = simpletransform.composeTransform(matNew, simpletransform.parseTransform('translate({0:f},{1:f})'.format(x, y))) + else: + mat_new2 = matNew + # Note that the referenced object may be a 'symbol`, + # which acts like a group, or it may be a simple + # object. + + if len(refnode) > 0: + plottable, the_point = self.group_first_pt(refnode[0], mat_new2) + else: + plottable, the_point = self.group_first_pt(refnode, mat_new2) + + return plottable, the_point + except: + pass + + # Svg Object is not a plottable element + # In this case, return False to indicate a non-plottable element + # and a default point + + return False, point + + def getLastPoint(self, node, matCurrent): + """ + Input: XML tree node and transformation matrix + Output: Boolean value to indicate if the svg element is plottable or not and + two floats stored in a list representing the x and y coordinates we plot last + """ + + # first apply the current matrix transform to this node's transform + matNew = simpletransform.composeTransform( matCurrent, simpletransform.parseTransform( node.get( "transform" ) ) ) + + # If we return a negative value, we know that this function did not work + point = [float(-1), float(-1)] + try: + if node.tag == inkex.addNS( 'path', 'svg' ): + + path = node.get('d') + + point = plot_utils.pathdata_last_point(path) + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS( 'rect', 'svg' ) or node.tag == 'rect': + + """ + The x,y coordinates for a rect are included in their specific attributes + If there is a transform, we need translate the x & y coordinates to their + correct location via applyTransformToPoint. + """ + + point[0] = float( node.get( 'x' ) ) + point[1] = float( node.get( 'y' ) ) + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point # Same start and end points + + if node.tag == inkex.addNS( 'line', 'svg' ) or node.tag == 'line': + + """ + The x2 and y2 attributes are where we will end our drawing + So, get them, apply the transform matrix, and return the point + """ + + point[0] = float( node.get( 'x2' ) ) + point[1] = float( node.get( 'y2' ) ) + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS( 'polyline', 'svg' ) or node.tag == 'polyline': + + pl = node.get( 'points', '' ).strip() + + if pl == '': + return False, point + + pa = pl.replace(',',' ').split() + if not pa: + return False, point + pathLength = len( pa ) + if (pathLength < 4): # Minimum of x1,y1 x2,y2 required. + return False, point + + d = "M " + pa[0] + " " + pa[1] + i = 2 + while (i < (pathLength - 1 )): + d += " L " + pa[i] + " " + pa[i + 1] + i += 2 + + endpoint = plot_utils.pathdata_last_point(d) + simpletransform.applyTransformToPoint(matNew, endpoint) + + return True, endpoint + + if node.tag == inkex.addNS( 'polygon', 'svg' ) or node.tag == 'polygon': + """ + We need to extract x1 and y1 from these: + + We accomplish this with Python string strip + and split methods. Then apply transforms + """ + # Strip() removes all whitespace from the start and end of p1 + pl = node.get( 'points', '' ).strip() + if (pl == ''): + # If pl is blank there has been an error, return -1,-1 to indicate a problem has occured + return False, point + # Split string by whitespace + pa = pl.split() + if not len( pa ): + # If pl is blank there has been an error, return -1,-1 to indicate a problem has occured + return False, point + # pa[0] = "x1,y1 + # split string via comma to get x1 and y1 individually + # then point = [x1,x2] + point = pa[0].split(",") + + point = [float(point[0]),float(point[1])] + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS( 'ellipse', 'svg' ) or node.tag == 'ellipse': + + cx = float( node.get( 'cx', '0' ) ) + cy = float( node.get( 'cy', '0' ) ) + rx = float( node.get( 'rx', '0' ) ) + + point[0] = cx - rx + point[1] = cy + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS( 'circle', 'svg' ) or node.tag == 'circle': + cx = float( node.get( 'cx', '0' ) ) + cy = float( node.get( 'cy', '0' ) ) + r = float( node.get( 'r', '0' ) ) + point[0] = cx - r + point[1] = cy + + simpletransform.applyTransformToPoint(matNew, point) + + return True, point + + if node.tag == inkex.addNS('symbol', 'svg') or node.tag == 'symbol': + # A symbol is much like a group, except that it should only be + # rendered when called within a "use" tag. + return False, point # Skip this element. + + if node.tag == inkex.addNS('use', 'svg') or node.tag == 'use': + + """ + A element refers to another SVG element via an xlink:href="#blah" + attribute. We will handle the element by doing an XPath search through + the document, looking for the element with the matching id="blah" + attribute. We then recursively process that element after applying + any necessary (x,y) translation. + + Notes: + 1. We ignore the height and g attributes as they do not apply to + path-like elements, and + 2. Even if the use element has visibility="hidden", SVG still calls + for processing the referenced element. The referenced element is + hidden only if its visibility is "inherit" or "hidden". + 3. We may be able to unlink clones using the code in pathmodifier.py + """ + + refid = node.get(inkex.addNS('href', 'xlink')) + if refid is not None: + # [1:] to ignore leading '#' in reference + path = '//*[@id="{0}"]'.format(refid[1:]) + refnode = node.xpath(path) + if refnode is not None: + x = float(node.get('x', '0')) + y = float(node.get('y', '0')) + # Note: the transform has already been applied + if x != 0 or y != 0: + mat_new2 = simpletransform.composeTransform(matNew, simpletransform.parseTransform('translate({0:f},{1:f})'.format(x, y))) + else: + mat_new2 = matNew + if len(refnode) > 0: + plottable, the_point = self.group_last_pt(refnode[0], mat_new2) + else: + plottable, the_point = self.group_last_pt(refnode, mat_new2) + return plottable, the_point + except: + pass + + # Svg Object is not a plottable element; + # Return False and a default point + return False, point + + + def group_first_pt(self, group, matCurrent = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]): + """ + Input: A Node which we have found to be a group + Output: Boolean value to indicate if a point is plottable + float values for first x,y coordinates of svg element + """ + + if len(group) == 0: # Empty group -- The object may not be a group. + return self.getFirstPoint(group, matCurrent) + + success = False + point = [float(-1), float(-1)] + + # first apply the current matrix transform to this node's transform + matNew = simpletransform.composeTransform( matCurrent, simpletransform.parseTransform( group.get( "transform" ) ) ) + + # Step through the group, we examine each element until we find a plottable object + for subnode in group: + # Check to see if the subnode we are looking at in this iteration of our for loop is a group + # If it is a group, we must recursively call this function to search for a plottable object + if subnode.tag == inkex.addNS( 'g', 'svg' ) or subnode.tag == 'g': + # Verify that the nested group has objects within it + # otherwise we will not parse it + if subnode is not None: + # Check if group contains plottable elements by recursively calling group_first_pt + # If group contains plottable subnode, then it will return that value and escape the loop + # Else function continues search for first plottable object + success, point = self.group_first_pt(subnode, matNew) + if success: + # Subnode inside nested group is plottable! + # Break from our loop so we can return the first point of this plottable subnode + break + else: + continue + else: + # Node is not a group + # Get its first (x,y) coordinates + # Also get a Boolean value to indicate if the subnode is plottable or not + # If subnode is not plottable, continue to next subnode in the group + success, point = self.getFirstPoint(subnode, matNew) + + if success: + # Subnode inside group is plottable! + # Break from our loop so we can return the first point of this plottable subnode + break + else: + continue + return success, point + + + def group_last_pt(self, group, matCurrent=[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]): + """ + Input: A Node which we have found to be a group + Output: The last node within the group which can be plotted + """ + + if len(group) == 0: # Empty group -- Did someone send an object that isn't a group? + return self.getLastPoint(group, matCurrent) + + success = False + point = [float(-1),float(-1)] + + # first apply the current matrix transform to this node's transform + matNew = simpletransform.composeTransform( matCurrent, simpletransform.parseTransform( group.get( "transform" ) ) ) + + # Step through the group, we examine each element until we find a plottable object + for subnode in reversed(group): + # Check to see if the subnode we are looking at in this iteration of our for loop is a group + # If it is a group, we must recursively call this function to search for a plottable object + if subnode.tag == inkex.addNS( 'g', 'svg' ) or subnode.tag == 'g': + # Verify that the nested group has objects within it + # otherwise we will not parse it + if subnode is not None: + # Check if group contains plottable elements by recursively calling group_last_pt + # If group contains plottable subnode, then it will return that value and escape the loop + # Else function continues search for last plottable object + success, point = self.group_last_pt(subnode, matNew) + if success: + # Subnode inside nested group is plottable! + # Break from our loop so we can return the first point of this plottable subnode + break + else: + continue + else: + # Node is not a group + # Get its first (x,y) coordinates + # Also get a Boolean value to indicate if the subnode is plottable or not + # If subnode is not plottable, continue to next subnode in the group + success, point = self.getLastPoint(subnode, matNew) + if success: + + # Subode inside nested group is plottable! + # Break from our loop so we can return the first point of this plottable subnode + break + else: + continue + return success, point + + + def group2NodeDict(self, group, mat_current=None): + + if mat_current is None: + mat_current = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + + # first apply the current matrix transform to this node's transform + matNew = simpletransform.composeTransform( mat_current, simpletransform.parseTransform( group.get( "transform" ) ) ) + + nodes_in_group = [] + + # Step through the group, we examine each element until we find a plottable object + for subnode in group: + # Check to see if the subnode we are looking at in this iteration of our for loop is a group + # If it is a group, we must recursively call this function to search for a plottable object + if subnode.tag == inkex.addNS( 'g', 'svg' ) or subnode.tag == 'g': + # Verify that the nested group has objects within it + # otherwise we will not parse it + if subnode is not None: + # Check if group contains plottable elements by recursively calling group_first_pt + # If group contains plottable subnode, then it will return that value and escape the loop + # Else function continues search for first plottable object + nodes_in_group.extend(self.group2NodeDict(subnode, matNew)) + else: + simpletransform.applyTransformToNode(matNew, subnode) + nodes_in_group.append(subnode) + return nodes_in_group + + + def ReorderNodeList(self, coord_dict, group_dict): + # Re-order the given set of SVG elements, using a simple "greedy" algorithm. + # The first object will be the element closest to the origin + # After this choice, the algorithm loops through all remaining elements looking for the element whose first x,y + # coordinates are closest to the the previous choice's last x,y coordinates + # This process continues until all elements have been sorted into ordered_element_list and removed from group_dict + + ordered_layer_element_list = [] + + # Continue until all elements have been re-ordered + while group_dict: + + nearest_dist = float('inf') + for key,node in group_dict.items(): + # Is this node non-plottable? + # If so, exit loop and append element to ordered_layer_element_list + if not coord_dict[key][0]: + # Object is not Plottable + nearest = node + nearest_id = key + continue + + # If we reach this point, node is plottable and needs to be considered in our algo + entry_x = coord_dict[key][1] # x-coordinate of first point of the path + entry_y = coord_dict[key][2] # y-coordinate of first point of the path + + exit_x = coord_dict[key][3] # x-coordinate of last point of the path + exit_y = coord_dict[key][4] # y-coordinate of last point of the path + + object_dist = (entry_x-self.x_last)*(entry_x-self.x_last) + (entry_y-self.y_last) * (entry_y-self.y_last) + # This is actually the distance squared; calculating it rather than the pythagorean distance + # saves a square root calculation. Right now, we only care about _which distance is less_ + # not the exact value of it, so this is a harmless shortcut. + # If this distance is smaller than the previous element's distance, then replace the previous + # element's entry with our current element's distance + if nearest_dist >= object_dist: + # We have found an element closer than the previous closest element + nearest = node + nearest_id = key + nearest_dist = object_dist + nearest_start_x = entry_x + nearest_start_y = entry_y + + # Now that the closest object has been determined, it is time to add it to the + # optimized list of closest objects + ordered_layer_element_list.append(nearest) + + # To determine the closest object in the next iteration of the loop, + # we must save the last x,y coor of this element + # If this element is plottable, then save the x,y coordinates + # If this element is non-plottable, then do not save the x,y coordinates + if coord_dict[nearest_id][0]: + + # Also, draw line indicating that we've found a new point. + if self.options.preview_rendering == True: + preview_path = [] # pen-up path data for preview + + preview_path.append("M{0:.3f} {1:.3f}".format( + self.x_last, self.y_last)) + preview_path.append("{0:.3f} {1:.3f}".format( + nearest_start_x, nearest_start_y)) + self.p_style.update({'stroke': self.color_index(self.layer_index)}) + path_attrs = { + 'style': str(inkex.Style(self.p_style)), + 'd': " ".join(preview_path)} + + etree.SubElement( self.preview_layer, + inkex.addNS( 'path', 'svg'), path_attrs, nsmap=inkex.NSS ) + + self.x_last = coord_dict[nearest_id][3] + self.y_last = coord_dict[nearest_id][4] + + # Remove this element from group_dict to indicate it has been optimized + del group_dict[nearest_id] + + # Once all elements have been removed from the group_dictionary + # Return the optimized list of svg elements in the layer + return ordered_layer_element_list + + + def color_index(self, index): + index = index % 9 + + if index == 0: + return "rgb(255, 0, 0))" + elif index == 1: + return "rgb(170, 85, 0))" + elif index == 2: + return "rgb(85, 170, 0))" + elif index == 3: + return "rgb(0, 255, 0))" + elif index == 4: + return "rgb(0, 170, 85))" + elif index == 5: + return "rgb(0, 85, 170))" + elif index == 6: + return "rgb(0, 0, 255))" + elif index == 7: + return "rgb(85, 0, 170))" + else: + return "rgb(170, 0, 85))" + + + def getDocProps(self): + """ + Get the document's height and width attributes from the tag. + Use a default value in case the property is not present or is + expressed in units of percentages. + """ + + self.svg_height = plot_utils.getLengthInches(self, 'height') + self.svg_width = plot_utils.getLengthInches(self, 'width') + + width_string = self.svg.get('width') + if width_string: + value, units = plot_utils.parseLengthWithUnits(width_string) + self.doc_units = units + + if self.auto_rotate and (self.svg_height > self.svg_width): + self.printPortrait = True + if self.svg_height is None or self.svg_width is None: + return False + else: + return True + + + def get_output(self): + # Return serialized copy of svg document output + result = etree.tostring(self.document) + return result.decode("utf-8") + +# Create effect instance and apply it. +if __name__ == '__main__': + ReorderEffect().run() \ No newline at end of file diff --git a/extensions/fablabchemnitz/reorder_sequence/simplepath.py b/extensions/fablabchemnitz/reorder_sequence/simplepath.py new file mode 100644 index 00000000..81e3cd9e --- /dev/null +++ b/extensions/fablabchemnitz/reorder_sequence/simplepath.py @@ -0,0 +1,211 @@ +""" +simplepath.py +functions for digesting paths into a simple list structure + +Copyright (C) 2005 Aaron Spike, aaron@ekips.org + +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. + +""" +import re, math + +def lexPath(d): + """ + returns and iterator that breaks path data + identifies command and parameter tokens + """ + offset = 0 + length = len(d) + delim = re.compile(r'[ \t\r\n,]+') + command = re.compile(r'[MLHVCSQTAZmlhvcsqtaz]') + parameter = re.compile(r'(([-+]?[0-9]+(\.[0-9]*)?|[-+]?\.[0-9]+)([eE][-+]?[0-9]+)?)') + while 1: + m = delim.match(d, offset) + if m: + offset = m.end() + if offset >= length: + break + m = command.match(d, offset) + if m: + yield [d[offset:m.end()], True] + offset = m.end() + continue + m = parameter.match(d, offset) + if m: + yield [d[offset:m.end()], False] + offset = m.end() + continue + #TODO: create new exception + raise Exception('Invalid path data!') +''' +pathdefs = {commandfamily: + [ + implicitnext, + #params, + [casts,cast,cast], + [coord type,x,y,0] + ]} +''' +pathdefs = { + 'M':['L', 2, [float, float], ['x','y']], + 'L':['L', 2, [float, float], ['x','y']], + 'H':['H', 1, [float], ['x']], + 'V':['V', 1, [float], ['y']], + 'C':['C', 6, [float, float, float, float, float, float], ['x','y','x','y','x','y']], + 'S':['S', 4, [float, float, float, float], ['x','y','x','y']], + 'Q':['Q', 4, [float, float, float, float], ['x','y','x','y']], + 'T':['T', 2, [float, float], ['x','y']], + 'A':['A', 7, [float, float, float, int, int, float, float], ['r','r','a',0,'s','x','y']], + 'Z':['L', 0, [], []] + } +def parsePath(d): + """ + Parse SVG path and return an array of segments. + Removes all shorthand notation. + Converts coordinates to absolute. + """ + retval = [] + lexer = lexPath(d) + + pen = (0.0,0.0) + subPathStart = pen + lastControl = pen + lastCommand = '' + + while 1: + try: + token, isCommand = next(lexer) + except StopIteration: + break + params = [] + needParam = True + if isCommand: + if not lastCommand and token.upper() != 'M': + raise Exception('Invalid path, must begin with moveto.') + else: + command = token + else: + #command was omited + #use last command's implicit next command + needParam = False + if lastCommand: + if lastCommand.isupper(): + command = pathdefs[lastCommand][0] + else: + command = pathdefs[lastCommand.upper()][0].lower() + else: + raise Exception('Invalid path, no initial command.') + numParams = pathdefs[command.upper()][1] + while numParams > 0: + if needParam: + try: + token, isCommand = next(lexer) + if isCommand: + raise Exception('Invalid number of parameters') + except StopIteration: + raise Exception('Unexpected end of path') + cast = pathdefs[command.upper()][2][-numParams] + param = cast(token) + if command.islower(): + if pathdefs[command.upper()][3][-numParams]=='x': + param += pen[0] + elif pathdefs[command.upper()][3][-numParams]=='y': + param += pen[1] + params.append(param) + needParam = True + numParams -= 1 + #segment is now absolute so + outputCommand = command.upper() + + #Flesh out shortcut notation + if outputCommand in ('H','V'): + if outputCommand == 'H': + params.append(pen[1]) + if outputCommand == 'V': + params.insert(0,pen[0]) + outputCommand = 'L' + if outputCommand in ('S','T'): + params.insert(0,pen[1]+(pen[1]-lastControl[1])) + params.insert(0,pen[0]+(pen[0]-lastControl[0])) + if outputCommand == 'S': + outputCommand = 'C' + if outputCommand == 'T': + outputCommand = 'Q' + + #current values become "last" values + if outputCommand == 'M': + subPathStart = tuple(params[0:2]) + pen = subPathStart + if outputCommand == 'Z': + pen = subPathStart + else: + pen = tuple(params[-2:]) + + if outputCommand in ('Q','C'): + lastControl = tuple(params[-4:-2]) + else: + lastControl = pen + lastCommand = command + + retval.append([outputCommand,params]) + return retval + +def formatPath(a): + """Format SVG path data from an array""" + return "".join([cmd + " ".join([str(p) for p in params]) for cmd, params in a]) + +def translatePath(p, x, y): + for cmd,params in p: + defs = pathdefs[cmd] + for i in range(defs[1]): + if defs[3][i] == 'x': + params[i] += x + elif defs[3][i] == 'y': + params[i] += y + +def scalePath(p, x, y): + for cmd,params in p: + defs = pathdefs[cmd] + for i in range(defs[1]): + if defs[3][i] == 'x': + params[i] *= x + elif defs[3][i] == 'y': + params[i] *= y + elif defs[3][i] == 'r': # radius parameter + params[i] *= x + elif defs[3][i] == 's': # sweep-flag parameter + if x*y < 0: + params[i] = 1 - params[i] + elif defs[3][i] == 'a': # x-axis-rotation angle + if y < 0: + params[i] = - params[i] + +def rotatePath(p, a, cx = 0, cy = 0): + if a == 0: + return p + for cmd,params in p: + defs = pathdefs[cmd] + for i in range(defs[1]): + if defs[3][i] == 'x': + x = params[i] - cx + y = params[i + 1] - cy + r = math.sqrt((x**2) + (y**2)) + if r != 0: + theta = math.atan2(y, x) + a + params[i] = (r * math.cos(theta)) + cx + params[i + 1] = (r * math.sin(theta)) + cy + + +# vim: expandtab shiftwidth=4 tabstop=8 softtabstop=4 fileencoding=utf-8 textwidth=99 diff --git a/extensions/fablabchemnitz/reorder_sequence/simpletransform.py b/extensions/fablabchemnitz/reorder_sequence/simpletransform.py new file mode 100644 index 00000000..e615fc1d --- /dev/null +++ b/extensions/fablabchemnitz/reorder_sequence/simpletransform.py @@ -0,0 +1,261 @@ +''' +Copyright (C) 2006 Jean-Francois Barraud, barraud@math.univ-lille1.fr +Copyright (C) 2010 Alvin Penner, penner@vaxxine.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. +barraud@math.univ-lille1.fr + +This code defines several functions to make handling of transform +attribute easier. +''' +import inkex, cubicsuperpath, bezmisc, simplestyle +import copy, math, re + +def parseTransform(transf,mat=[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]): + if transf=="" or transf==None: + return(mat) + stransf = transf.strip() + result=re.match(r"(translate|scale|rotate|skewX|skewY|matrix)\s*\(([^)]*)\)\s*,?",stransf) +#-- translate -- + if result.group(1)=="translate": + args=result.group(2).replace(',',' ').split() + dx=float(args[0]) + if len(args)==1: + dy=0.0 + else: + dy=float(args[1]) + matrix=[[1,0,dx],[0,1,dy]] +#-- scale -- + if result.group(1)=="scale": + args=result.group(2).replace(',',' ').split() + sx=float(args[0]) + if len(args)==1: + sy=sx + else: + sy=float(args[1]) + matrix=[[sx,0,0],[0,sy,0]] +#-- rotate -- + if result.group(1)=="rotate": + args=result.group(2).replace(',',' ').split() + a=float(args[0])*math.pi/180 + if len(args)==1: + cx,cy=(0.0,0.0) + else: + cx,cy=map(float,args[1:]) + matrix=[[math.cos(a),-math.sin(a),cx],[math.sin(a),math.cos(a),cy]] + matrix=composeTransform(matrix,[[1,0,-cx],[0,1,-cy]]) +#-- skewX -- + if result.group(1)=="skewX": + a=float(result.group(2))*math.pi/180 + matrix=[[1,math.tan(a),0],[0,1,0]] +#-- skewY -- + if result.group(1)=="skewY": + a=float(result.group(2))*math.pi/180 + matrix=[[1,0,0],[math.tan(a),1,0]] +#-- matrix -- + if result.group(1)=="matrix": + a11,a21,a12,a22,v1,v2=result.group(2).replace(',',' ').split() + matrix=[[float(a11),float(a12),float(v1)], [float(a21),float(a22),float(v2)]] + + matrix=composeTransform(mat,matrix) + if result.end() < len(stransf): + return(parseTransform(stransf[result.end():], matrix)) + else: + return matrix + +def formatTransform(mat): + return ("matrix(%f,%f,%f,%f,%f,%f)" % (mat[0][0], mat[1][0], mat[0][1], mat[1][1], mat[0][2], mat[1][2])) + +def invertTransform(mat): + det = mat[0][0]*mat[1][1] - mat[0][1]*mat[1][0] + if det !=0: # det is 0 only in case of 0 scaling + # invert the rotation/scaling part + a11 = mat[1][1]/det + a12 = -mat[0][1]/det + a21 = -mat[1][0]/det + a22 = mat[0][0]/det + # invert the translational part + a13 = -(a11*mat[0][2] + a12*mat[1][2]) + a23 = -(a21*mat[0][2] + a22*mat[1][2]) + return [[a11,a12,a13],[a21,a22,a23]] + else: + return[[0,0,-mat[0][2]],[0,0,-mat[1][2]]] + +def composeTransform(M1,M2): + a11 = M1[0][0]*M2[0][0] + M1[0][1]*M2[1][0] + a12 = M1[0][0]*M2[0][1] + M1[0][1]*M2[1][1] + a21 = M1[1][0]*M2[0][0] + M1[1][1]*M2[1][0] + a22 = M1[1][0]*M2[0][1] + M1[1][1]*M2[1][1] + + v1 = M1[0][0]*M2[0][2] + M1[0][1]*M2[1][2] + M1[0][2] + v2 = M1[1][0]*M2[0][2] + M1[1][1]*M2[1][2] + M1[1][2] + return [[a11,a12,v1],[a21,a22,v2]] + +def composeParents(node, mat): + trans = node.get('transform') + if trans: + mat = composeTransform(parseTransform(trans), mat) + if node.getparent().tag == inkex.addNS('g','svg'): + mat = composeParents(node.getparent(), mat) + return mat + +def applyTransformToNode(mat,node): + m=parseTransform(node.get("transform")) + newtransf=formatTransform(composeTransform(mat,m)) + node.set("transform", newtransf) + +def applyTransformToPoint(mat,pt): + x = mat[0][0]*pt[0] + mat[0][1]*pt[1] + mat[0][2] + y = mat[1][0]*pt[0] + mat[1][1]*pt[1] + mat[1][2] + pt[0]=x + pt[1]=y + +def applyTransformToPath(mat,path): + for comp in path: + for ctl in comp: + for pt in ctl: + applyTransformToPoint(mat,pt) + +def fuseTransform(node): + if node.get('d')==None: + #FIXME: how do you raise errors? + raise AssertionError('can not fuse "transform" of elements that have no "d" attribute') + t = node.get("transform") + if t == None: + return + m = parseTransform(t) + d = node.get('d') + p = cubicsuperpath.parsePath(d) + applyTransformToPath(m,p) + node.set('d', cubicsuperpath.formatPath(p)) + del node.attrib["transform"] + +#################################################################### +##-- Some functions to compute a rough bbox of a given list of objects. +##-- this should be shipped out in an separate file... + +def boxunion(b1,b2): + if b1 is None: + return b2 + elif b2 is None: + return b1 + else: + return((min(b1[0],b2[0]), max(b1[1],b2[1]), min(b1[2],b2[2]), max(b1[3],b2[3]))) + +def roughBBox(path): + xmin,xMax,ymin,yMax = path[0][0][0][0],path[0][0][0][0],path[0][0][0][1],path[0][0][0][1] + for pathcomp in path: + for ctl in pathcomp: + for pt in ctl: + xmin = min(xmin,pt[0]) + xMax = max(xMax,pt[0]) + ymin = min(ymin,pt[1]) + yMax = max(yMax,pt[1]) + return xmin,xMax,ymin,yMax + +def refinedBBox(path): + xmin,xMax,ymin,yMax = path[0][0][1][0],path[0][0][1][0],path[0][0][1][1],path[0][0][1][1] + for pathcomp in path: + for i in range(1, len(pathcomp)): + cmin, cmax = cubicExtrema(pathcomp[i-1][1][0], pathcomp[i-1][2][0], pathcomp[i][0][0], pathcomp[i][1][0]) + xmin = min(xmin, cmin) + xMax = max(xMax, cmax) + cmin, cmax = cubicExtrema(pathcomp[i-1][1][1], pathcomp[i-1][2][1], pathcomp[i][0][1], pathcomp[i][1][1]) + ymin = min(ymin, cmin) + yMax = max(yMax, cmax) + return xmin,xMax,ymin,yMax + +def cubicExtrema(y0, y1, y2, y3): + cmin = min(y0, y3) + cmax = max(y0, y3) + d1 = y1 - y0 + d2 = y2 - y1 + d3 = y3 - y2 + if (d1 - 2*d2 + d3): + if (d2*d2 > d1*d3): + t = (d1 - d2 + math.sqrt(d2*d2 - d1*d3))/(d1 - 2*d2 + d3) + if (t > 0) and (t < 1): + y = y0*(1-t)*(1-t)*(1-t) + 3*y1*t*(1-t)*(1-t) + 3*y2*t*t*(1-t) + y3*t*t*t + cmin = min(cmin, y) + cmax = max(cmax, y) + t = (d1 - d2 - math.sqrt(d2*d2 - d1*d3))/(d1 - 2*d2 + d3) + if (t > 0) and (t < 1): + y = y0*(1-t)*(1-t)*(1-t) + 3*y1*t*(1-t)*(1-t) + 3*y2*t*t*(1-t) + y3*t*t*t + cmin = min(cmin, y) + cmax = max(cmax, y) + elif (d3 - d1): + t = -d1/(d3 - d1) + if (t > 0) and (t < 1): + y = y0*(1-t)*(1-t)*(1-t) + 3*y1*t*(1-t)*(1-t) + 3*y2*t*t*(1-t) + y3*t*t*t + cmin = min(cmin, y) + cmax = max(cmax, y) + return cmin, cmax + +def computeBBox(aList,mat=[[1,0,0],[0,1,0]]): + bbox=None + for node in aList: + m = parseTransform(node.get('transform')) + m = composeTransform(mat,m) + #TODO: text not supported! + d = None + if node.get("d"): + d = node.get('d') + elif node.get('points'): + d = 'M' + node.get('points') + elif node.tag in [ inkex.addNS('rect','svg'), 'rect', inkex.addNS('image','svg'), 'image' ]: + d = 'M' + node.get('x', '0') + ',' + node.get('y', '0') + \ + 'h' + node.get('width') + 'v' + node.get('height') + \ + 'h-' + node.get('width') + elif node.tag in [ inkex.addNS('line','svg'), 'line' ]: + d = 'M' + node.get('x1') + ',' + node.get('y1') + \ + ' ' + node.get('x2') + ',' + node.get('y2') + elif node.tag in [ inkex.addNS('circle','svg'), 'circle', \ + inkex.addNS('ellipse','svg'), 'ellipse' ]: + rx = node.get('r') + if rx is not None: + ry = rx + else: + rx = node.get('rx') + ry = node.get('ry') + cx = float(node.get('cx', '0')) + cy = float(node.get('cy', '0')) + x1 = cx - float(rx) + x2 = cx + float(rx) + d = 'M %f %f ' % (x1, cy) + \ + 'A' + rx + ',' + ry + ' 0 1 0 %f,%f' % (x2, cy) + \ + 'A' + rx + ',' + ry + ' 0 1 0 %f,%f' % (x1, cy) + + if d is not None: + p = cubicsuperpath.parsePath(d) + applyTransformToPath(m,p) + bbox=boxunion(refinedBBox(p),bbox) + + elif node.tag == inkex.addNS('use','svg') or node.tag=='use': + refid=node.get(inkex.addNS('href','xlink')) + path = '//*[@id="%s"]' % refid[1:] + refnode = node.xpath(path) + bbox=boxunion(computeBBox(refnode,m),bbox) + + bbox=boxunion(computeBBox(node,m),bbox) + return bbox + + +def computePointInNode(pt, node, mat=[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]): + if node.getparent() is not None: + applyTransformToPoint(invertTransform(composeParents(node, mat)), pt) + return pt + + +# vim: expandtab shiftwidth=4 tabstop=8 softtabstop=4 fileencoding=utf-8 textwidth=99