commit 9b3d6fe5e3513e03235822e4bbf5352abef46dd2 Author: Andy Date: Tue Jul 5 21:51:11 2016 -0700 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09a3900 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.pyc +.* +/svgpathtools/nonunittests +!/.gitignore \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..5c7c438 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Andrew Allan Port + +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. \ No newline at end of file diff --git a/LICENSE2.txt b/LICENSE2.txt new file mode 100644 index 0000000..6a9dbcf --- /dev/null +++ b/LICENSE2.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013-2014 Lennart Regebro + +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. \ No newline at end of file diff --git a/MANIFEST b/MANIFEST new file mode 100644 index 0000000..fb4c767 --- /dev/null +++ b/MANIFEST @@ -0,0 +1,12 @@ +# file GENERATED by distutils, do NOT edit +setup.py +svgpathtools/__init__.py +svgpathtools/bezier.py +svgpathtools/misctools.py +svgpathtools/parser.py +svgpathtools/path.py +svgpathtools/paths2svg.py +svgpathtools/pathtools.py +svgpathtools/polytools.py +svgpathtools/smoothing.py +svgpathtools/svg2paths.py diff --git a/README.ipynb b/README.ipynb new file mode 100644 index 0000000..7e2db91 --- /dev/null +++ b/README.ipynb @@ -0,0 +1,753 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# svgpathtools\n", + "\n", + "svgpathtools is a collection of tools for manipulating and analyzing SVG Path objects and Bézier curves.\n", + "\n", + "## Features\n", + "\n", + "svgpathtools contains functions designed to **easily read, write and display SVG files** as well as *a large selection of geometrically\\-oriented tools* to **transform and analyze path elements**.\n", + "\n", + "Additionally, the submodule *bezier.py* contains tools for for working with general **nth order Bezier curves stored as n-tuples**.\n", + "\n", + "Some included tools:\n", + "\n", + "- **read**, **write**, and **display** SVG files containing Path (and other) SVG elements\n", + "- convert Bézier path segments to **numpy.poly1d** (polynomial) objects\n", + "- convert polynomials (in standard form) to their Bézier form\n", + "- compute **tangent vectors** and (right-hand rule) **normal vectors**\n", + "- compute **curvature**\n", + "- break discontinuous paths into their **continuous subpaths**.\n", + "- efficiently compute **intersections** between paths and/or segments\n", + "- find a **bounding box** for a path or segment\n", + "- **reverse** segment/path orientation\n", + "- **crop** and **split** paths and segments\n", + "- **smooth** paths (i.e. smooth away kinks to make paths differentiable)\n", + "- **transition maps** from path domain to segment domain and back (T2t and t2T)\n", + "- compute **area** enclosed by a closed path\n", + "- compute **arc length**\n", + "- compute **inverse arc length**\n", + "- convert RGB color tuples to hexadecimal color strings and back\n", + "\n", + "## Prerequisites\n", + "- **numpy**\n", + "- **svgwrite**\n", + "\n", + "## Setup\n", + "\n", + "If not already installed, you can **install the prerequisites** using pip.\n", + "\n", + "```bash\n", + "$ pip install numpy\n", + "```\n", + "\n", + "```bash\n", + "$ pip install svgwrite\n", + "```\n", + "\n", + "Then **install svgpathtools**:\n", + "```bash\n", + "$ pip install svgpathtools\n", + "``` \n", + " \n", + "### Alternative Setup \n", + "You can download the source from Github and install by using the command (from inside the folder containing setup.py):\n", + "\n", + "```bash\n", + "$ python setup.py install\n", + "```\n", + "\n", + "## Credit where credit's due\n", + "Much of the core of this module was taken from [the svg.path (v2.0) module](https://github.com/regebro/svg.path). Interested svg.path users should see the compatibility notes at bottom of this readme.\n", + "\n", + "## Basic Usage\n", + "\n", + "### Classes\n", + "The svgpathtools module is primarily structured around four path segment classes: ``Line``, ``QuadraticBezier``, ``CubicBezier``, and ``Arc``. There is also a fifth class, ``Path``, whose objects are sequences of (connected or disconnected[1](#f1)) path segment objects.\n", + "\n", + "* ``Line(start, end)``\n", + "\n", + "* ``Arc(start, radius, rotation, large_arc, sweep, end)`` Note: See docstring for a detailed explanation of these parameters\n", + "\n", + "* ``QuadraticBezier(start, control, end)``\n", + "\n", + "* ``CubicBezier(start, control1, control2, end)``\n", + "\n", + "* ``Path(*segments)``\n", + "\n", + "See the relevant docstrings in *path.py* or the [official SVG specifications]() for more information on what each parameter means.\n", + "\n", + "1 Warning: Some of the functionality in this library has not been tested on discontinuous Path objects. A simple workaround is provided, however, by the ``Path.continuous_subpaths()`` method. [↩](#a1)" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "from __future__ import division, print_function" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Path(CubicBezier(start=(300+100j), control1=(100+100j), control2=(200+200j), end=(200+300j)),\n", + " Line(start=(200+300j), end=(250+350j)))\n", + "Path(CubicBezier(start=(300+100j), control1=(100+100j), control2=(200+200j), end=(200+300j)),\n", + " Line(start=(200+300j), end=(250+350j)))\n", + "True\n", + "M 300.0,100.0 C 100.0,100.0 200.0,200.0 200.0,300.0 L 250.0,350.0\n", + "True\n" + ] + } + ], + "source": [ + "# Coordinates are given as points in the complex plane\n", + "from svgpathtools import Path, Line, QuadraticBezier, CubicBezier, Arc\n", + "seg1 = CubicBezier(300+100j, 100+100j, 200+200j, 200+300j) # A cubic beginning at (300, 100) and ending at (200, 300)\n", + "seg2 = Line(200+300j, 250+350j) # A line beginning at (200, 300) and ending at (250, 350)\n", + "path = Path(seg1, seg2) # A path traversing the cubic and then the line\n", + "\n", + "# We could alternatively created this Path object using a d-string\n", + "from svgpathtools import parse_path\n", + "path_alt = parse_path('M 300 100 C 100 100 200 200 200 300 L 250 350')\n", + "\n", + "# Let's check that these two methods are equivalent\n", + "print(path)\n", + "print(path_alt)\n", + "print(path == path_alt)\n", + "\n", + "# On a related note, the Path.d() method returns a Path object's d-string\n", + "print(path.d())\n", + "print(parse_path(path.d()) == path)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The ``Path`` class is a mutable sequence, so it behaves much like a list.\n", + "So segments can **append**ed, **insert**ed, set by index, **del**eted, **enumerate**d, **slice**d out, etc." + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Path(CubicBezier(start=(300+100j), control1=(100+100j), control2=(200+200j), end=(200+300j)),\n", + " Line(start=(200+300j), end=(250+350j)),\n", + " CubicBezier(start=(250+350j), control1=(275+350j), control2=(250+225j), end=(200+100j)))\n", + "Path(Line(start=(200+100j), end=(200+300j)),\n", + " Line(start=(200+300j), end=(250+350j)),\n", + " CubicBezier(start=(250+350j), control1=(275+350j), control2=(250+225j), end=(200+100j)))\n", + "path is continuous? True\n", + "path is closed? True\n", + "path contains non-differentiable points? True\n", + "spath contains non-differentiable points? False\n", + "Path(Line(start=(200+101.5j), end=(200+298.5j)),\n", + " CubicBezier(start=(200+298.5j), control1=(200+298.505j), control2=(201.057124638+301.057124638j), end=(201.060660172+301.060660172j)),\n", + " Line(start=(201.060660172+301.060660172j), end=(248.939339828+348.939339828j)),\n", + " CubicBezier(start=(248.939339828+348.939339828j), control1=(249.649982143+349.649982143j), control2=(248.995+350j), end=(250+350j)),\n", + " CubicBezier(start=(250+350j), control1=(275+350j), control2=(250+225j), end=(200+100j)),\n", + " CubicBezier(start=(200+100j), control1=(199.62675237+99.0668809257j), control2=(200+100.495j), end=(200+101.5j)))\n", + "Notice that path contains 3 segments and spath contains 6 segments.\n" + ] + } + ], + "source": [ + "# Let's append another to the end of it\n", + "path.append(CubicBezier(250+350j, 275+350j, 250+225j, 200+100j))\n", + "print(path)\n", + "\n", + "# Let's replace the first segment with a Line object\n", + "path[0] = Line(200+100j, 200+300j)\n", + "print(path)\n", + "\n", + "# You may have noticed that this path is connected and now is also closed (i.e. path.start == path.end)\n", + "print(\"path is continuous? \", path.iscontinuous())\n", + "print(\"path is closed? \", path.isclosed())\n", + "\n", + "# The curve the path follows is not, however, smooth (differentiable)\n", + "from svgpathtools import kinks, smoothed_path\n", + "print(\"path contains non-differentiable points? \", len(kinks(path)) > 0)\n", + "\n", + "# If we want, we can smooth these out (Experimental and only for line/cubic paths)\n", + "# Note: smoothing will always works (except on 180 degree turns), but you may want \n", + "# to play with the maxjointsize and tightness parameters to get pleasing results\n", + "# Note also: smoothing will increase the number of segments in a path\n", + "spath = smoothed_path(path)\n", + "print(\"spath contains non-differentiable points? \", len(kinks(spath)) > 0)\n", + "print(spath)\n", + "\n", + "# Let's take a quick look at the path and its smoothed relative\n", + "# The following commands will open two browser windows to display path and spaths\n", + "from svgpathtools import disvg\n", + "from time import sleep\n", + "disvg(path) \n", + "sleep(1) # needed when not giving the SVGs unique names (or not using timestamp)\n", + "disvg(spath)\n", + "print(\"Notice that path contains {} segments and spath contains {} segments.\"\n", + " \"\".format(len(path), len(spath)))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Reading SVGSs\n", + "\n", + "The **svg2paths()** function converts an svgfile to a list of Path objects and a separate list of dictionaries containing the attributes of each said path. \n", + "Note: Line, Polyline, Polygon, and Path SVG elements can all be converted to Path objects using this function." + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Path(CubicBezier(start=(10.5+80j), control1=(40+10j), control2=(65+10j), end=(95+80j)),\n", + " CubicBezier(start=(95+80j), control1=(125+150j), control2=(150+150j), end=(180+80j)))\n", + "red\n" + ] + } + ], + "source": [ + "# Read SVG into a list of path objects and list of dictionaries of attributes \n", + "from svgpathtools import svg2paths, wsvg\n", + "paths, attributes = svg2paths('test.svg')\n", + "\n", + "# Let's print out the first path object and the color it was in the SVG\n", + "# We'll see it is composed of two CubicBezier objects and, in the SVG file it \n", + "# came from, it was red\n", + "redpath = paths[0]\n", + "redpath_attribs = attributes[0]\n", + "print(redpath)\n", + "print(redpath_attribs['stroke'])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Writing SVGSs (and some geometric functions and methods)\n", + "\n", + "The **wsvg()** function creates an SVG file from a list of path. This function can do many things (see docstring in *paths2svg.py* for more information) and is meant to be quick and easy to use.\n", + "Note: Use the convenience function **disvg()** (or set 'openinbrowser=True') to automatically attempt to open the created svg file in your default SVG viewer." + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "# Let's make a new SVG that's identical to the first\n", + "wsvg(paths, attributes=attributes, filename='output1.svg')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "![output1.svg](output1.svg)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "There will be many more examples of writing and displaying path data below.\n", + "\n", + "### The .point() method and transitioning between path and path segment parameterizations\n", + "SVG Path elements and their segments have official parameterizations.\n", + "These parameterizations can be accessed using the ``Path.point()``, ``Line.point()``, ``QuadraticBezier.point()``, ``CubicBezier.point()``, and ``Arc.point()`` methods.\n", + "All these parameterizations are defined over the domain 0 <= t <= 1.\n", + "\n", + "**Note:** In this document and in inline documentation and doctrings, I use a capital ``T`` when referring to the parameterization of a Path object and a lower case ``t`` when referring speaking about path segment objects (i.e. Line, QaudraticBezier, CubicBezier, and Arc objects). \n", + "Given a ``T`` value, the ``Path.T2t()`` method can be used to find the corresponding segment index, ``k``, and segment parameter, ``t``, such that ``path.point(T)=path[k].point(t)``. \n", + "There is also a ``Path.t2T()`` method to solve the inverse problem." + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "True\n", + "True\n", + "False\n", + "True\n" + ] + } + ], + "source": [ + "# Example:\n", + "\n", + "# Let's check that the first segment of redpath starts \n", + "# at the same point as redpath\n", + "firstseg = redpath[0] \n", + "print(redpath.point(0) == firstseg.point(0) == redpath.start == firstseg.start)\n", + "\n", + "# Let's check that the last segment of redpath ends on the same point as redpath\n", + "lastseg = redpath[-1] \n", + "print(redpath.point(1) == lastseg.point(1) == redpath.end == lastseg.end)\n", + "\n", + "# This next boolean should return False as redpath is composed multiple segments\n", + "print(redpath.point(0.5) == firstseg.point(0.5))\n", + "\n", + "# If we want to figure out which segment of redpoint the \n", + "# point redpath.point(0.5) lands on, we can use the path.T2t() method\n", + "k, t = redpath.T2t(0.5)\n", + "print(redpath[k].point(t) == redpath.point(0.5))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Tangent vectors and Bezier curves as numpy polynomial objects\n", + "Another great way to work with the parameterizations for Line, QuadraticBezier, and CubicBezier objects is to convert them to ``numpy.poly1d`` objects. This is done easily using the ``Line.poly()``, ``QuadraticBezier.poly()`` and ``CubicBezier.poly()`` methods. \n", + "There's also a ``polynomial2bezier()`` function in the pathtools.py submodule to convert polynomials back to Bezier curves. \n", + "\n", + "**Note:** cubic Bezier curves are parameterized as $$\\mathcal{B}(t) = P_0(1-t)^3 + 3P_1(1-t)^2t + 3P_2(1-t)t^2 + P_3t^3$$\n", + "where $P_0$, $P_1$, $P_2$, and $P_3$ are the control points ``start``, ``control1``, ``control2``, and ``end``, respectively, that svgpathtools uses to define a CubicBezier object. The ``CubicBezier.poly()`` method expands this polynomial to its standard form \n", + "$$\\mathcal{B}(t) = c_0t^3 + c_1t^2 +c_2t+c3$$\n", + "where\n", + "$$\\begin{bmatrix}c_0\\\\c_1\\\\c_2\\\\c_3\\end{bmatrix} = \n", + "\\begin{bmatrix}\n", + "-1 & 3 & -3 & 1\\\\\n", + "3 & -6 & -3 & 0\\\\\n", + "-3 & 3 & 0 & 0\\\\\n", + "1 & 0 & 0 & 0\\\\\n", + "\\end{bmatrix}\n", + "\\begin{bmatrix}P_0\\\\P_1\\\\P_2\\\\P_3\\end{bmatrix}$$ \n", + "\n", + "QuadraticBezier.poly() and Line.poly() are defined similarly." + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "True\n", + "The CubicBezier, b.point(x) = \n", + "\n", + "(300+100j)*(1-t)^3 + 3*(100+100j)*(1-t)^2*t + 3*(200+200j)*(1-t)*t^2 + (200+300j)*t^3\n", + "\n", + "can be rewritten in standard form as \n", + "\n", + " 3 2\n", + "(-400 + -100j) t + (900 + 300j) t - 600 t + (300 + 100j)\n" + ] + } + ], + "source": [ + "# Example:\n", + "b = CubicBezier(300+100j, 100+100j, 200+200j, 200+300j)\n", + "p = b.poly()\n", + "\n", + "# p(t) == b.point(t)\n", + "print(p(0.235) == b.point(0.235))\n", + "\n", + "# What is p(t)? It's just the cubic b written in standard form. \n", + "bpretty = \"{}*(1-t)^3 + 3*{}*(1-t)^2*t + 3*{}*(1-t)*t^2 + {}*t^3\".format(*b.bpoints())\n", + "print(\"The CubicBezier, b.point(x) = \\n\\n\" + \n", + " bpretty + \"\\n\\n\" + \n", + " \"can be rewritten in standard form as \\n\\n\" +\n", + " str(p).replace('x','t'))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To illustrate the awesomeness of being able to convert our Bezier curve objects to numpy.poly1d objects and back, lets compute the unit tangent vector of the above CubicBezier object, b, at t=0.5 in four different ways.\n", + "\n", + "### Tangent vectors (and more on polynomials)" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "unit tangent check: True\n" + ] + } + ], + "source": [ + "t = 0.5\n", + "### Method 1: the easy way\n", + "u1 = b.unit_tangent(t)\n", + "\n", + "### Method 2: another easy way \n", + "# Note: This way will fail if it encounters a removable singularity.\n", + "u2 = b.derivative(t)/abs(b.derivative(t))\n", + "\n", + "### Method 2: a third easy way \n", + "# Note: This way will also fail if it encounters a removable singularity.\n", + "dp = p.deriv() \n", + "u3 = dp(t)/abs(dp(t))\n", + "\n", + "### Method 4: the removable-singularity-proof numpy.poly1d way \n", + "# Note: This is roughly how Method 1 works\n", + "from svgpathtools import real, imag, rational_limit\n", + "dx, dy = real(dp), imag(dp) # dp == dx + 1j*dy \n", + "p_mag2 = dx**2 + dy**2 # p_mag2(t) = |p(t)|**2\n", + "# Note: abs(dp) isn't a polynomial, but abs(dp)**2 is, and,\n", + "# the limit_{t->t0}[f(t) / abs(f(t))] == \n", + "# sqrt(limit_{t->t0}[f(t)**2 / abs(f(t))**2])\n", + "from cmath import sqrt\n", + "u4 = sqrt(rational_limit(dp**2, p_mag2, t))\n", + "\n", + "print(\"unit tangent check:\", u1 == u2 == u3 == u4)\n", + "\n", + "# Let's do a visual check\n", + "mag = b.length()/4 # so it's not hard to see the tangent line\n", + "tangent_line = Line(b.point(t), b.point(t) + mag*u1)\n", + "disvg([b, tangent_line], 'bg', nodes=[b.point(t)])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Translations (shifts), reversing orientation, and normal vectors" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "# Speaking of tangents, let's add a normal vector to the picture\n", + "n = b.normal(t)\n", + "normal_line = Line(b.point(t), b.point(t) + mag*n)\n", + "disvg([b, tangent_line, normal_line], 'bgp', nodes=[b.point(t)])\n", + "\n", + "# and let's reverse the orientation of b! \n", + "# the tangent and normal lines should be sent to their opposites\n", + "br = b.reversed()\n", + "\n", + "# Let's also shift b_r over a bit to the right so we can view it next to b\n", + "# The simplest way to do this is br = br.translated(3*mag), but let's use \n", + "# the .bpoints() instead, which returns a Bezier's control points\n", + "br.start, br.control1, br.control2, br.end = [3*mag + bpt for bpt in br.bpoints()] # \n", + "\n", + "tangent_line_r = Line(br.point(t), br.point(t) + mag*br.unit_tangent(t))\n", + "normal_line_r = Line(br.point(t), br.point(t) + mag*br.normal(t))\n", + "wsvg([b, tangent_line, normal_line, br, tangent_line_r, normal_line_r], \n", + " 'bgpkgp', nodes=[b.point(t), br.point(t)], filename='vectorframes.svg', \n", + " text=[\"b's tangent\", \"br's tangent\"], text_path=[tangent_line, tangent_line_r])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "![vectorframes.svg](vectorframes.svg)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Rotations and Translations" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "# Let's take a Line and an Arc and make some pictures\n", + "top_half = Arc(start=-1, radius=1+2j, rotation=0, large_arc=1, sweep=1, end=1)\n", + "midline = Line(-1.5, 1.5)\n", + "\n", + "# First let's make our ellipse whole\n", + "bottom_half = top_half.rotated(180)\n", + "decorated_ellipse = Path(top_half, bottom_half)\n", + "\n", + "# Now let's add the decorations\n", + "for k in range(12):\n", + " decorated_ellipse.append(midline.rotated(30*k))\n", + " \n", + "# Let's move it over so we can see the original Line and Arc object next\n", + "# to the final product\n", + "decorated_ellipse = decorated_ellipse.translated(4+0j)\n", + "wsvg([top_half, midline, decorated_ellipse], filename='decorated_ellipse.svg')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "![decorated_ellipse.svg](decorated_ellipse.svg)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### arc length and inverse arc length\n", + "\n", + "Here we'll create an SVG that shows off the parametric and geometric midpoints of the paths from ``test.svg``. We'll need to compute use the ``Path.length()``, ``Line.length()``, ``QuadraticBezier.length()``, ``CubicBezier.length()``, and ``Arc.length()`` methods, as well as the related inverse arc length methods ``.ilength()`` function to do this." + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "# First we'll load the path data from the file test.svg\n", + "paths, attributes = svg2paths('test.svg')\n", + "\n", + "# Let's mark the parametric midpoint of each segment\n", + "# I say \"parametric\" midpoint because Bezier curves aren't \n", + "# parameterized by arclength \n", + "# If they're also the geometric midpoint, let's mark them\n", + "# purple and otherwise we'll mark the geometric midpoint green\n", + "min_depth = 5\n", + "error = 1e-4\n", + "dots = []\n", + "ncols = []\n", + "nradii = []\n", + "for path in paths:\n", + " for seg in path:\n", + " parametric_mid = seg.point(0.5)\n", + " seg_length = seg.length()\n", + " if seg.length(0.5)/seg.length() == 1/2:\n", + " dots += [parametric_mid]\n", + " ncols += ['purple']\n", + " nradii += [5]\n", + " else:\n", + " t_mid = seg.ilength(seg_length/2)\n", + " geo_mid = seg.point(t_mid)\n", + " dots += [parametric_mid, geo_mid]\n", + " ncols += ['red', 'green']\n", + " nradii += [5] * 2\n", + "\n", + "# In 'output2.svg' the paths will retain their original attributes\n", + "wsvg(paths, nodes=dots, node_colors=ncols, node_radii=nradii, \n", + " attributes=attributes, filename='output2.svg')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "![output2.svg](output2.svg)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Intersections between Bezier curves" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "# Let's find all intersections between redpath and the other \n", + "redpath = paths[0]\n", + "redpath_attribs = attributes[0]\n", + "intersections = []\n", + "for path in paths[1:]:\n", + " for (T1, seg1, t1), (T2, seg2, t2) in redpath.intersect(path):\n", + " intersections.append(redpath.point(T1))\n", + " \n", + "disvg(paths, filename='output_intersections.svg', attributes=attributes,\n", + " nodes = intersections, node_radii = [5]*len(intersections))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "![output_intersections.svg](output_intersections.svg)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### An Advanced Application: Offsetting Bezier Curves\n", + "Here we'll find the [offset curve](https://en.wikipedia.org/wiki/Parallel_curve) for a few Bezier cubics." + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "def display_offset_curve(bezpath, offset_distance, steps=1000,\n", + " stripe_width=3, stripe_colors='ygb',\n", + " name='offsetcurves.svg', show_normal_line=True):\n", + " \"\"\"Takes in a path of bezier curves, `bezpath`, and a distance,\n", + " `offset_distance`, and displays the 'parallel' offset curve by placing a\n", + " dot at a distance of `offset_distance` away at each step.\"\"\"\n", + " nls = []\n", + " nodes = []\n", + " line_colors = ''\n", + " node_colors = ''\n", + "\n", + " for bez in bezpath:\n", + " ct = 1\n", + " for k in range(steps):\n", + " t = k / steps\n", + " nl = Line(bez.point(t),\n", + " bez.point(t) + offset_distance * bez.normal(t))\n", + " nls.append(nl)\n", + " line_colors += stripe_colors[ct % 3]\n", + " if not (k % stripe_width):\n", + " ct += 1\n", + " nodes.append(bez.point(t))\n", + " nodes.append(nl.end)\n", + " node_colors += 'kr'\n", + " # nls.reverse()\n", + " if show_normal_line:\n", + " wsvg([bezpath] + nls, 'k' + line_colors,\n", + " nodes=nodes, node_colors=node_colors,\n", + " filename=name)\n", + " else:\n", + " wsvg(bezpath, 'k',\n", + " nodes=nodes, node_colors=node_colors,\n", + " filename=name)\n", + "\n", + "bez1 = parse_path(\"m 288,600 c -52,-28 -42,-61 0,-97 \")[0]\n", + "bez2 = parse_path(\"M 151,395 C 407,485 726.17662,160 634,339\")[0]\n", + "bez3 = parse_path(\"m 117,695 c 237,-7 -103,-146 457,0\")[0]\n", + "path = Path(bez1, bez2.translated(300), bez3.translated(500+400j))\n", + "\n", + "display_offset_curve(path, 500)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "![offsetcurves.svg](offsetcurves.svg)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Compatibility Notes for users of svg.path (v2.0)\n", + "\n", + "- renamed Arc.arc attribute as Arc.large_arc\n", + "\n", + "- Path.d() : For behavior similar[2](#f2) to svg.path (v2.0), set both useSandT and use_closed_attrib to be True.\n", + "\n", + "2 The behavior would be identical, but the string formatting used in this method has been changed to use default format (instead of the General format, {:G}), for inceased precision. [↩](#a2)\n", + "\n", + "## To Do\n", + "\n", + "- Implement Arc X Arc intersections.\n", + "- Implement Arc.radialrange() method.\n", + "\n", + "Licence\n", + "-------\n", + "\n", + "This module is under a MIT License." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 2", + "language": "python", + "name": "python2" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 2 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython2", + "version": "2.7.11" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..13e3548 --- /dev/null +++ b/README.rst @@ -0,0 +1,634 @@ + +svgpathtools +============ + +svgpathtools is a collection of tools for manipulating and analyzing SVG +Path objects and Bézier curves. + +Features +-------- + +svgpathtools contains functions designed to **easily read, write and +display SVG files** as well as *a large selection of +geometrically-oriented tools* to **transform and analyze path +elements**. + +Additionally, the submodule *bezier.py* contains tools for for working +with general **nth order Bezier curves stored as n-tuples**. + +Some included tools: + +- **read**, **write**, and **display** SVG files containing Path (and + other) SVG elements +- convert Bézier path segments to **numpy.poly1d** (polynomial) objects +- convert polynomials (in standard form) to their Bézier form +- compute **tangent vectors** and (right-hand rule) **normal vectors** +- compute **curvature** +- break discontinuous paths into their **continuous subpaths**. +- efficiently compute **intersections** between paths and/or segments +- find a **bounding box** for a path or segment +- **reverse** segment/path orientation +- **crop** and **split** paths and segments +- **smooth** paths (i.e. smooth away kinks to make paths + differentiable) +- **transition maps** from path domain to segment domain and back (T2t + and t2T) +- compute **area** enclosed by a closed path +- compute **arc length** +- compute **inverse arc length** +- convert RGB color tuples to hexadecimal color strings and back + +Prerequisites +------------- + +- **numpy** +- **svgwrite** + +Setup +----- + +If not already installed, you can **install the prerequisites** using +pip. + +.. code:: bash + + $ pip install numpy + +.. code:: bash + + $ pip install svgwrite + +Then **install svgpathtools**: + +.. code:: bash + + $ pip install svgpathtools + +Alternative Setup +~~~~~~~~~~~~~~~~~ + +You can download the source from Github and install by using the command +(from inside the folder containing setup.py): + +.. code:: bash + + $ python setup.py install + +Credit where credit's due +------------------------- + +Much of the core of this module was taken from `the svg.path (v2.0) +module `__. Interested svg.path +users should see the compatibility notes at bottom of this readme. + +Basic Usage +----------- + +Classes +~~~~~~~ + +The svgpathtools module is primarily structured around four path segment +classes: ``Line``, ``QuadraticBezier``, ``CubicBezier``, and ``Arc``. +There is also a fifth class, ``Path``, whose objects are sequences of +(connected or disconnected\ `1 <#f1>`__\ ) path segment objects. + +- ``Line(start, end)`` + +- ``Arc(start, radius, rotation, large_arc, sweep, end)`` Note: See + docstring for a detailed explanation of these parameters + +- ``QuadraticBezier(start, control, end)`` + +- ``CubicBezier(start, control1, control2, end)`` + +- ``Path(*segments)`` + +See the relevant docstrings in *path.py* or the `official SVG +specifications `__ for more +information on what each parameter means. + +1 Warning: Some of the functionality in this library has not been tested +on discontinuous Path objects. A simple workaround is provided, however, +by the ``Path.continuous_subpaths()`` method. `↩ <#a1>`__ + +.. code:: python + + from __future__ import division, print_function + +.. code:: python + + # Coordinates are given as points in the complex plane + from svgpathtools import Path, Line, QuadraticBezier, CubicBezier, Arc + seg1 = CubicBezier(300+100j, 100+100j, 200+200j, 200+300j) # A cubic beginning at (300, 100) and ending at (200, 300) + seg2 = Line(200+300j, 250+350j) # A line beginning at (200, 300) and ending at (250, 350) + path = Path(seg1, seg2) # A path traversing the cubic and then the line + + # We could alternatively created this Path object using a d-string + from svgpathtools import parse_path + path_alt = parse_path('M 300 100 C 100 100 200 200 200 300 L 250 350') + + # Let's check that these two methods are equivalent + print(path) + print(path_alt) + print(path == path_alt) + + # On a related note, the Path.d() method returns a Path object's d-string + print(path.d()) + print(parse_path(path.d()) == path) + + +.. parsed-literal:: + + Path(CubicBezier(start=(300+100j), control1=(100+100j), control2=(200+200j), end=(200+300j)), + Line(start=(200+300j), end=(250+350j))) + Path(CubicBezier(start=(300+100j), control1=(100+100j), control2=(200+200j), end=(200+300j)), + Line(start=(200+300j), end=(250+350j))) + True + M 300.0,100.0 C 100.0,100.0 200.0,200.0 200.0,300.0 L 250.0,350.0 + True + + +The ``Path`` class is a mutable sequence, so it behaves much like a +list. So segments can **append**\ ed, **insert**\ ed, set by index, +**del**\ eted, **enumerate**\ d, **slice**\ d out, etc. + +.. code:: python + + # Let's append another to the end of it + path.append(CubicBezier(250+350j, 275+350j, 250+225j, 200+100j)) + print(path) + + # Let's replace the first segment with a Line object + path[0] = Line(200+100j, 200+300j) + print(path) + + # You may have noticed that this path is connected and now is also closed (i.e. path.start == path.end) + print("path is continuous? ", path.iscontinuous()) + print("path is closed? ", path.isclosed()) + + # The curve the path follows is not, however, smooth (differentiable) + from svgpathtools import kinks, smoothed_path + print("path contains non-differentiable points? ", len(kinks(path)) > 0) + + # If we want, we can smooth these out (Experimental and only for line/cubic paths) + # Note: smoothing will always works (except on 180 degree turns), but you may want + # to play with the maxjointsize and tightness parameters to get pleasing results + # Note also: smoothing will increase the number of segments in a path + spath = smoothed_path(path) + print("spath contains non-differentiable points? ", len(kinks(spath)) > 0) + print(spath) + + # Let's take a quick look at the path and its smoothed relative + # The following commands will open two browser windows to display path and spaths + from svgpathtools import disvg + from time import sleep + disvg(path) + sleep(1) # needed when not giving the SVGs unique names (or not using timestamp) + disvg(spath) + print("Notice that path contains {} segments and spath contains {} segments." + "".format(len(path), len(spath))) + + +.. parsed-literal:: + + Path(CubicBezier(start=(300+100j), control1=(100+100j), control2=(200+200j), end=(200+300j)), + Line(start=(200+300j), end=(250+350j)), + CubicBezier(start=(250+350j), control1=(275+350j), control2=(250+225j), end=(200+100j))) + Path(Line(start=(200+100j), end=(200+300j)), + Line(start=(200+300j), end=(250+350j)), + CubicBezier(start=(250+350j), control1=(275+350j), control2=(250+225j), end=(200+100j))) + path is continuous? True + path is closed? True + path contains non-differentiable points? True + spath contains non-differentiable points? False + Path(Line(start=(200+101.5j), end=(200+298.5j)), + CubicBezier(start=(200+298.5j), control1=(200+298.505j), control2=(201.057124638+301.057124638j), end=(201.060660172+301.060660172j)), + Line(start=(201.060660172+301.060660172j), end=(248.939339828+348.939339828j)), + CubicBezier(start=(248.939339828+348.939339828j), control1=(249.649982143+349.649982143j), control2=(248.995+350j), end=(250+350j)), + CubicBezier(start=(250+350j), control1=(275+350j), control2=(250+225j), end=(200+100j)), + CubicBezier(start=(200+100j), control1=(199.62675237+99.0668809257j), control2=(200+100.495j), end=(200+101.5j))) + Notice that path contains 3 segments and spath contains 6 segments. + + +Reading SVGSs +~~~~~~~~~~~~~ + +| The **svg2paths()** function converts an svgfile to a list of Path + objects and a separate list of dictionaries containing the attributes + of each said path. +| Note: Line, Polyline, Polygon, and Path SVG elements can all be + converted to Path objects using this function. + +.. code:: python + + # Read SVG into a list of path objects and list of dictionaries of attributes + from svgpathtools import svg2paths, wsvg + paths, attributes = svg2paths('test.svg') + + # Let's print out the first path object and the color it was in the SVG + # We'll see it is composed of two CubicBezier objects and, in the SVG file it + # came from, it was red + redpath = paths[0] + redpath_attribs = attributes[0] + print(redpath) + print(redpath_attribs['stroke']) + + +.. parsed-literal:: + + Path(CubicBezier(start=(10.5+80j), control1=(40+10j), control2=(65+10j), end=(95+80j)), + CubicBezier(start=(95+80j), control1=(125+150j), control2=(150+150j), end=(180+80j))) + red + + +Writing SVGSs (and some geometric functions and methods) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The **wsvg()** function creates an SVG file from a list of path. This +function can do many things (see docstring in *paths2svg.py* for more +information) and is meant to be quick and easy to use. Note: Use the +convenience function **disvg()** (or set 'openinbrowser=True') to +automatically attempt to open the created svg file in your default SVG +viewer. + +.. code:: python + + # Let's make a new SVG that's identical to the first + wsvg(paths, attributes=attributes, filename='output1.svg') + +.. figure:: output1.svg + :alt: output1.svg + + output1.svg + +There will be many more examples of writing and displaying path data +below. + +The .point() method and transitioning between path and path segment parameterizations +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +SVG Path elements and their segments have official parameterizations. +These parameterizations can be accessed using the ``Path.point()``, +``Line.point()``, ``QuadraticBezier.point()``, ``CubicBezier.point()``, +and ``Arc.point()`` methods. All these parameterizations are defined +over the domain 0 <= t <= 1. + +| **Note:** In this document and in inline documentation and doctrings, + I use a capital ``T`` when referring to the parameterization of a Path + object and a lower case ``t`` when referring speaking about path + segment objects (i.e. Line, QaudraticBezier, CubicBezier, and Arc + objects). +| Given a ``T`` value, the ``Path.T2t()`` method can be used to find the + corresponding segment index, ``k``, and segment parameter, ``t``, such + that ``path.point(T)=path[k].point(t)``. +| There is also a ``Path.t2T()`` method to solve the inverse problem. + +.. code:: python + + # Example: + + # Let's check that the first segment of redpath starts + # at the same point as redpath + firstseg = redpath[0] + print(redpath.point(0) == firstseg.point(0) == redpath.start == firstseg.start) + + # Let's check that the last segment of redpath ends on the same point as redpath + lastseg = redpath[-1] + print(redpath.point(1) == lastseg.point(1) == redpath.end == lastseg.end) + + # This next boolean should return False as redpath is composed multiple segments + print(redpath.point(0.5) == firstseg.point(0.5)) + + # If we want to figure out which segment of redpoint the + # point redpath.point(0.5) lands on, we can use the path.T2t() method + k, t = redpath.T2t(0.5) + print(redpath[k].point(t) == redpath.point(0.5)) + + +.. parsed-literal:: + + True + True + False + True + + +Tangent vectors and Bezier curves as numpy polynomial objects +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +| Another great way to work with the parameterizations for Line, + QuadraticBezier, and CubicBezier objects is to convert them to + ``numpy.poly1d`` objects. This is done easily using the + ``Line.poly()``, ``QuadraticBezier.poly()`` and ``CubicBezier.poly()`` + methods. +| There's also a ``polynomial2bezier()`` function in the pathtools.py + submodule to convert polynomials back to Bezier curves. + +**Note:** cubic Bezier curves are parameterized as + +.. math:: \mathcal{B}(t) = P_0(1-t)^3 + 3P_1(1-t)^2t + 3P_2(1-t)t^2 + P_3t^3 + +where :math:`P_0`, :math:`P_1`, :math:`P_2`, and :math:`P_3` are the +control points ``start``, ``control1``, ``control2``, and ``end``, +respectively, that svgpathtools uses to define a CubicBezier object. The +``CubicBezier.poly()`` method expands this polynomial to its standard +form + +.. math:: \mathcal{B}(t) = c_0t^3 + c_1t^2 +c_2t+c3 + + where + +.. math:: + + \begin{bmatrix}c_0\\c_1\\c_2\\c_3\end{bmatrix} = + \begin{bmatrix} + -1 & 3 & -3 & 1\\ + 3 & -6 & -3 & 0\\ + -3 & 3 & 0 & 0\\ + 1 & 0 & 0 & 0\\ + \end{bmatrix} + \begin{bmatrix}P_0\\P_1\\P_2\\P_3\end{bmatrix} + +QuadraticBezier.poly() and Line.poly() are defined similarly. + +.. code:: python + + # Example: + b = CubicBezier(300+100j, 100+100j, 200+200j, 200+300j) + p = b.poly() + + # p(t) == b.point(t) + print(p(0.235) == b.point(0.235)) + + # What is p(t)? It's just the cubic b written in standard form. + bpretty = "{}*(1-t)^3 + 3*{}*(1-t)^2*t + 3*{}*(1-t)*t^2 + {}*t^3".format(*b.bpoints()) + print("The CubicBezier, b.point(x) = \n\n" + + bpretty + "\n\n" + + "can be rewritten in standard form as \n\n" + + str(p).replace('x','t')) + + +.. parsed-literal:: + + True + The CubicBezier, b.point(x) = + + (300+100j)*(1-t)^3 + 3*(100+100j)*(1-t)^2*t + 3*(200+200j)*(1-t)*t^2 + (200+300j)*t^3 + + can be rewritten in standard form as + + 3 2 + (-400 + -100j) t + (900 + 300j) t - 600 t + (300 + 100j) + + +To illustrate the awesomeness of being able to convert our Bezier curve +objects to numpy.poly1d objects and back, lets compute the unit tangent +vector of the above CubicBezier object, b, at t=0.5 in four different +ways. + +Tangent vectors (and more on polynomials) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: python + + t = 0.5 + ### Method 1: the easy way + u1 = b.unit_tangent(t) + + ### Method 2: another easy way + # Note: This way will fail if it encounters a removable singularity. + u2 = b.derivative(t)/abs(b.derivative(t)) + + ### Method 2: a third easy way + # Note: This way will also fail if it encounters a removable singularity. + dp = p.deriv() + u3 = dp(t)/abs(dp(t)) + + ### Method 4: the removable-singularity-proof numpy.poly1d way + # Note: This is roughly how Method 1 works + from svgpathtools import real, imag, rational_limit + dx, dy = real(dp), imag(dp) # dp == dx + 1j*dy + p_mag2 = dx**2 + dy**2 # p_mag2(t) = |p(t)|**2 + # Note: abs(dp) isn't a polynomial, but abs(dp)**2 is, and, + # the limit_{t->t0}[f(t) / abs(f(t))] == + # sqrt(limit_{t->t0}[f(t)**2 / abs(f(t))**2]) + from cmath import sqrt + u4 = sqrt(rational_limit(dp**2, p_mag2, t)) + + print("unit tangent check:", u1 == u2 == u3 == u4) + + # Let's do a visual check + mag = b.length()/4 # so it's not hard to see the tangent line + tangent_line = Line(b.point(t), b.point(t) + mag*u1) + disvg([b, tangent_line], 'bg', nodes=[b.point(t)]) + + +.. parsed-literal:: + + unit tangent check: True + + +Translations (shifts), reversing orientation, and normal vectors +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: python + + # Speaking of tangents, let's add a normal vector to the picture + n = b.normal(t) + normal_line = Line(b.point(t), b.point(t) + mag*n) + disvg([b, tangent_line, normal_line], 'bgp', nodes=[b.point(t)]) + + # and let's reverse the orientation of b! + # the tangent and normal lines should be sent to their opposites + br = b.reversed() + + # Let's also shift b_r over a bit to the right so we can view it next to b + # The simplest way to do this is br = br.translated(3*mag), but let's use + # the .bpoints() instead, which returns a Bezier's control points + br.start, br.control1, br.control2, br.end = [3*mag + bpt for bpt in br.bpoints()] # + + tangent_line_r = Line(br.point(t), br.point(t) + mag*br.unit_tangent(t)) + normal_line_r = Line(br.point(t), br.point(t) + mag*br.normal(t)) + wsvg([b, tangent_line, normal_line, br, tangent_line_r, normal_line_r], + 'bgpkgp', nodes=[b.point(t), br.point(t)], filename='vectorframes.svg', + text=["b's tangent", "br's tangent"], text_path=[tangent_line, tangent_line_r]) + +.. figure:: vectorframes.svg + :alt: vectorframes.svg + + vectorframes.svg + +Rotations and Translations +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: python + + # Let's take a Line and an Arc and make some pictures + top_half = Arc(start=-1, radius=1+2j, rotation=0, large_arc=1, sweep=1, end=1) + midline = Line(-1.5, 1.5) + + # First let's make our ellipse whole + bottom_half = top_half.rotated(180) + decorated_ellipse = Path(top_half, bottom_half) + + # Now let's add the decorations + for k in range(12): + decorated_ellipse.append(midline.rotated(30*k)) + + # Let's move it over so we can see the original Line and Arc object next + # to the final product + decorated_ellipse = decorated_ellipse.translated(4+0j) + wsvg([top_half, midline, decorated_ellipse], filename='decorated_ellipse.svg') + +.. figure:: decorated_ellipse.svg + :alt: decorated\_ellipse.svg + + decorated\_ellipse.svg + +arc length and inverse arc length +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Here we'll create an SVG that shows off the parametric and geometric +midpoints of the paths from ``test.svg``. We'll need to compute use the +``Path.length()``, ``Line.length()``, ``QuadraticBezier.length()``, +``CubicBezier.length()``, and ``Arc.length()`` methods, as well as the +related inverse arc length methods ``.ilength()`` function to do this. + +.. code:: python + + # First we'll load the path data from the file test.svg + paths, attributes = svg2paths('test.svg') + + # Let's mark the parametric midpoint of each segment + # I say "parametric" midpoint because Bezier curves aren't + # parameterized by arclength + # If they're also the geometric midpoint, let's mark them + # purple and otherwise we'll mark the geometric midpoint green + min_depth = 5 + error = 1e-4 + dots = [] + ncols = [] + nradii = [] + for path in paths: + for seg in path: + parametric_mid = seg.point(0.5) + seg_length = seg.length() + if seg.length(0.5)/seg.length() == 1/2: + dots += [parametric_mid] + ncols += ['purple'] + nradii += [5] + else: + t_mid = seg.ilength(seg_length/2) + geo_mid = seg.point(t_mid) + dots += [parametric_mid, geo_mid] + ncols += ['red', 'green'] + nradii += [5] * 2 + + # In 'output2.svg' the paths will retain their original attributes + wsvg(paths, nodes=dots, node_colors=ncols, node_radii=nradii, + attributes=attributes, filename='output2.svg') + +.. figure:: output2.svg + :alt: output2.svg + + output2.svg + +Intersections between Bezier curves +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: python + + # Let's find all intersections between redpath and the other + redpath = paths[0] + redpath_attribs = attributes[0] + intersections = [] + for path in paths[1:]: + for (T1, seg1, t1), (T2, seg2, t2) in redpath.intersect(path): + intersections.append(redpath.point(T1)) + + disvg(paths, filename='output_intersections.svg', attributes=attributes, + nodes = intersections, node_radii = [5]*len(intersections)) + +.. figure:: output_intersections.svg + :alt: output\_intersections.svg + + output\_intersections.svg + +An Advanced Application: Offsetting Bezier Curves +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Here we'll find the `offset +curve `__ for a few Bezier +cubics. + +.. code:: python + + def display_offset_curve(bezpath, offset_distance, steps=1000, + stripe_width=3, stripe_colors='ygb', + name='offsetcurves.svg', show_normal_line=True): + """Takes in a path of bezier curves, `bezpath`, and a distance, + `offset_distance`, and displays the 'parallel' offset curve by placing a + dot at a distance of `offset_distance` away at each step.""" + nls = [] + nodes = [] + line_colors = '' + node_colors = '' + + for bez in bezpath: + ct = 1 + for k in range(steps): + t = k / steps + nl = Line(bez.point(t), + bez.point(t) + offset_distance * bez.normal(t)) + nls.append(nl) + line_colors += stripe_colors[ct % 3] + if not (k % stripe_width): + ct += 1 + nodes.append(bez.point(t)) + nodes.append(nl.end) + node_colors += 'kr' + # nls.reverse() + if show_normal_line: + wsvg([bezpath] + nls, 'k' + line_colors, + nodes=nodes, node_colors=node_colors, + filename=name) + else: + wsvg(bezpath, 'k', + nodes=nodes, node_colors=node_colors, + filename=name) + + bez1 = parse_path("m 288,600 c -52,-28 -42,-61 0,-97 ")[0] + bez2 = parse_path("M 151,395 C 407,485 726.17662,160 634,339")[0] + bez3 = parse_path("m 117,695 c 237,-7 -103,-146 457,0")[0] + path = Path(bez1, bez2.translated(300), bez3.translated(500+400j)) + + display_offset_curve(path, 500) + +.. figure:: offsetcurves.svg + :alt: offsetcurves.svg + + offsetcurves.svg + +Compatibility Notes for users of svg.path (v2.0) +------------------------------------------------ + +- renamed Arc.arc attribute as Arc.large\_arc + +- Path.d() : For behavior similar\ `2 <#f2>`__\ to svg.path (v2.0), + set both useSandT and use\_closed\_attrib to be True. + +2 The behavior would be identical, but the string formatting used in +this method has been changed to use default format (instead of the +General format, {:G}), for inceased precision. `↩ <#a2>`__ + +To Do +----- + +- Implement Arc X Arc intersections. +- Implement Arc.radialrange() method. + +Licence +------- + +This module is under a MIT License. + diff --git a/decorated_ellipse.svg b/decorated_ellipse.svg new file mode 100644 index 0000000..662cb1f --- /dev/null +++ b/decorated_ellipse.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/dist/svgpathtools-1.0.tar.gz b/dist/svgpathtools-1.0.tar.gz new file mode 100644 index 0000000..f6567af Binary files /dev/null and b/dist/svgpathtools-1.0.tar.gz differ diff --git a/disvg_output.svg b/disvg_output.svg new file mode 100644 index 0000000..280a4c8 --- /dev/null +++ b/disvg_output.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/offsetcurves.svg b/offsetcurves.svg new file mode 100644 index 0000000..dd38985 --- /dev/null +++ b/offsetcurves.svg @@ -0,0 +1,9005 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/output1.svg b/output1.svg new file mode 100644 index 0000000..6cf4ff8 --- /dev/null +++ b/output1.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/output2.svg b/output2.svg new file mode 100644 index 0000000..42c65d2 --- /dev/null +++ b/output2.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/output_intersections.svg b/output_intersections.svg new file mode 100644 index 0000000..77601f9 --- /dev/null +++ b/output_intersections.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/path.svg b/path.svg new file mode 100644 index 0000000..e0573d9 --- /dev/null +++ b/path.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..d696f4b --- /dev/null +++ b/setup.py @@ -0,0 +1,25 @@ +from distutils.core import setup + +VERSION = '1.0' +AUTHOR_NAME = 'Andy Port' +AUTHOR_EMAIL = 'AndyAPort@gmail.com' + +setup(name='svgpathtools', + packages=['svgpathtools'], + version=VERSION, + description=('A collection of tools for manipulating and analyzing SVG ' + 'Path objects and Bezier curves.'), + long_description=open('README.rst').read(), + author=AUTHOR_NAME, + author_email=AUTHOR_EMAIL, + url='https://github.com/mathandy/svgpathtools', + download_url = 'http://github.com/mathandy/svgpathtools/tarball/1.0', + license='MIT', + + # install_requires=['numpy', 'svgwrite'], + platforms="OS Independent", + # test_suite='tests', + requires=['numpy', 'svgwrite'], + keywords=['svg', 'bezier', 'svg.path'], + classifiers = [], + ) diff --git a/svgpathtools/__init__.py b/svgpathtools/__init__.py new file mode 100644 index 0000000..fe52bc0 --- /dev/null +++ b/svgpathtools/__init__.py @@ -0,0 +1,19 @@ +from .bezier import (bezier_point, bezier2polynomial, + polynomial2bezier, split_bezier, + bezier_bounding_box, bezier_intersections, + bezier_by_line_intersections) +from .path import (Path, Line, QuadraticBezier, CubicBezier, Arc, + bezier_segment, is_bezier_segment, is_path_segment, + is_bezier_path, concatpaths, poly2bez, bpoints2bezier, + closest_point_in_path, farthest_point_in_path, + path_encloses_pt, bbox2path) +from .parser import parse_path +from .paths2svg import disvg, wsvg +from .polytools import polyroots, rational_limit, real, imag +from .misctools import hex2rgb, rgb2hex +from .smoothing import smoothed_path, smoothed_joint, is_differentiable, kinks + +try: + from .svg2paths import svg2paths +except ImportError: + pass \ No newline at end of file diff --git a/svgpathtools/bezier.py b/svgpathtools/bezier.py new file mode 100644 index 0000000..b910863 --- /dev/null +++ b/svgpathtools/bezier.py @@ -0,0 +1,374 @@ +"""This submodule contains tools that deal with generic, degree n, Bezier +curves. +Note: Bezier curves here are always represented by the tuple of their control +points given by their standard representation.""" + +# External dependencies: +from __future__ import division, absolute_import, print_function +from math import factorial as fac, ceil, log, sqrt +from numpy import poly1d + +# Internal dependencies +from .polytools import real, imag, polyroots, polyroots01 + + +# Evaluation ################################################################## + +def n_choose_k(n, k): + return fac(n)//fac(k)//fac(n-k) + + +def bernstein(n, t): + """returns a list of the Bernstein basis polynomials b_{i, n} evaluated at + t, for i =0...n""" + t1 = 1-t + return [n_choose_k(n, k) * t1**(n-k) * t**k for k in range(n+1)] + + +def bezier_point(p, t): + """Evaluates the Bezier curve given by it's control points, p, at t. + Note: Uses Horner's rule for cubic and lower order Bezier curves. + Warning: Be concerned about numerical stability when using this function + with high order curves.""" + + # begin arc support block ######################## + try: + p.large_arc + return p.point(t) + except: + pass + # end arc support block ########################## + + deg = len(p) - 1 + if deg == 3: + return p[0] + t*( + 3*(p[1] - p[0]) + t*( + 3*(p[0] + p[2]) - 6*p[1] + t*( + -p[0] + 3*(p[1] - p[2]) + p[3]))) + elif deg == 2: + return p[0] + t*( + 2*(p[1] - p[0]) + t*( + p[0] - 2*p[1] + p[2])) + elif deg == 1: + return p[0] + t*(p[1] - p[0]) + elif deg == 0: + return p[0] + else: + bern = bernstein(deg, t) + return sum(bern[k]*p[k] for k in range(deg+1)) + + +# Conversion ################################################################## + +def bezier2polynomial(p, numpy_ordering=True, return_poly1d=False): + """Converts a tuple of Bezier control points to a tuple of coefficients + of the expanded polynomial. + return_poly1d : returns a numpy.poly1d object. This makes computations + of derivatives/anti-derivatives and many other operations quite quick. + numpy_ordering : By default (to accommodate numpy) the coefficients will + be output in reverse standard order.""" + if len(p) == 4: + coeffs = (-p[0] + 3*(p[1] - p[2]) + p[3], + 3*(p[0] - 2*p[1] + p[2]), + 3*(p[1]-p[0]), + p[0]) + elif len(p) == 3: + coeffs = (p[0] - 2*p[1] + p[2], + 2*(p[1] - p[0]), + p[0]) + elif len(p) == 2: + coeffs = (p[1]-p[0], + p[0]) + elif len(p) == 1: + coeffs = p + else: + # https://en.wikipedia.org/wiki/Bezier_curve#Polynomial_form + n = len(p) + 1 + coeffs = [fac(n)//fac(n-j) * sum( + (-1)**(i+j) * p[i] / (fac(i) * fac(j-i)) for i in xrange(j+1)) + for j in range(n+1)] + if not numpy_ordering: + coeffs.reverse() + if return_poly1d: + return poly1d(coeffs) + return coeffs + + +def polynomial2bezier(poly): + """Converts a cubic or lower order Polynomial object (or a sequence of + coefficients) to a CubicBezier, QuadraticBezier, or Line object as + appropriate.""" + if isinstance(poly, poly1d): + c = poly.coeffs + else: + c = poly + order = len(c)-1 + if order == 3: + bpoints = (c[3], c[2]/3 + c[3], (c[1] + 2*c[2])/3 + c[3], + c[0] + c[1] + c[2] + c[3]) + elif order == 2: + bpoints = (c[2], c[1]/2 + c[2], c[0] + c[1] + c[2]) + elif order == 1: + bpoints = (c[1], c[0] + c[1]) + else: + raise AssertionError("This function is only implemented for linear, " + "quadratic, and cubic polynomials.") + return bpoints + + +# Curve Splitting ############################################################# + +def split_bezier(bpoints, t): + """Uses deCasteljau's recursion to split the Bezier curve at t into two + Bezier curves of the same order.""" + def split_bezier_recursion(bpoints_left_, bpoints_right_, bpoints_, t_): + if len(bpoints_) == 1: + bpoints_left_.append(bpoints_[0]) + bpoints_right_.append(bpoints_[0]) + else: + new_points = [None]*(len(bpoints_) - 1) + bpoints_left_.append(bpoints_[0]) + bpoints_right_.append(bpoints_[-1]) + for i in range(len(bpoints_) - 1): + new_points[i] = (1 - t_)*bpoints_[i] + t_*bpoints_[i + 1] + bpoints_left_, bpoints_right_ = split_bezier_recursion( + bpoints_left_, bpoints_right_, new_points, t_) + return bpoints_left_, bpoints_right_ + + bpoints_left = [] + bpoints_right = [] + bpoints_left, bpoints_right = \ + split_bezier_recursion(bpoints_left, bpoints_right, bpoints, t) + bpoints_right.reverse() + return bpoints_left, bpoints_right + + +def halve_bezier(p): + + # begin arc support block ######################## + try: + p.large_arc + return p.split(0.5) + except: + pass + # end arc support block ########################## + + if len(p) == 4: + return ([p[0], (p[0] + p[1])/2, (p[0] + 2*p[1] + p[2])/4, + (p[0] + 3*p[1] + 3*p[2] + p[3])/8], + [(p[0] + 3*p[1] + 3*p[2] + p[3])/8, + (p[1] + 2*p[2] + p[3])/4, (p[2] + p[3])/2, p[3]]) + else: + return split_bezier(p, 0.5) + + +# Bounding Boxes ############################################################## + +def bezier_real_minmax(p): + """returns the minimum and maximum for any real cubic bezier""" + local_extremizers = [0, 1] + if len(p) == 4: # cubic case + a = [p.real for p in p] + denom = a[0] - 3*a[1] + 3*a[2] - a[3] + if denom != 0: + delta = a[1]**2 - (a[0] + a[1])*a[2] + a[2]**2 + (a[0] - a[1])*a[3] + if delta >= 0: # otherwise no local extrema + sqdelta = sqrt(delta) + tau = a[0] - 2*a[1] + a[2] + r1 = (tau + sqdelta)/denom + r2 = (tau - sqdelta)/denom + if 0 < r1 < 1: + local_extremizers.append(r1) + if 0 < r2 < 1: + local_extremizers.append(r2) + local_extrema = [bezier_point(a, t) for t in local_extremizers] + return min(local_extrema), max(local_extrema) + + # find reverse standard coefficients of the derivative + dcoeffs = bezier2polynomial(a, return_poly1d=True).deriv().coeffs + + # find real roots, r, such that 0 <= r <= 1 + local_extremizers += polyroots01(dcoeffs) + local_extrema = [bezier_point(a, t) for t in local_extremizers] + return min(local_extrema), max(local_extrema) + + +def bezier_bounding_box(bez): + """returns the bounding box for the segment in the form + (xmin, xmax, ymin, ymax). + Warning: For the non-cubic case this is not particularly efficient.""" + + # begin arc support block ######################## + try: + bla = bez.large_arc + return bez.bbox() # added to support Arc objects + except: + pass + # end arc support block ########################## + + if len(bez) == 4: + xmin, xmax = bezier_real_minmax([p.real for p in bez]) + ymin, ymax = bezier_real_minmax([p.imag for p in bez]) + return xmin, xmax, ymin, ymax + poly = bezier2polynomial(bez, return_poly1d=True) + x = real(poly) + y = imag(poly) + dx = x.deriv() + dy = y.deriv() + x_extremizers = [0, 1] + polyroots(dx, realroots=True, + condition=lambda r: 0 < r < 1) + y_extremizers = [0, 1] + polyroots(dy, realroots=True, + condition=lambda r: 0 < r < 1) + x_extrema = [x(t) for t in x_extremizers] + y_extrema = [y(t) for t in y_extremizers] + return min(x_extrema), max(x_extrema), min(y_extrema), max(y_extrema) + + +def box_area(xmin, xmax, ymin, ymax): + """ + INPUT: 2-tuple of cubics (given by control points) + OUTPUT: boolean + """ + return (xmax - xmin)*(ymax - ymin) + + +def interval_intersection_width(a, b, c, d): + """returns the width of the intersection of intervals [a,b] and [c,d] + (thinking of these as intervals on the real number line)""" + return max(0, min(b, d) - max(a, c)) + + +def boxes_intersect(box1, box2): + """Determines if two rectangles, each input as a tuple + (xmin, xmax, ymin, ymax), intersect.""" + xmin1, xmax1, ymin1, ymax1 = box1 + xmin2, xmax2, ymin2, ymax2 = box2 + if interval_intersection_width(xmin1, xmax1, xmin2, xmax2) and \ + interval_intersection_width(ymin1, ymax1, ymin2, ymax2): + return True + else: + return False + + +# Intersections ############################################################### + +class ApproxSolutionSet(list): + """A class that behaves like a set but treats two elements , x and y, as + equivalent if abs(x-y) < self.tol""" + def __init__(self, tol): + self.tol = tol + + def __contains__(self, x): + for y in self: + if abs(x - y) < self.tol: + return True + return False + + def appadd(self, pt): + if pt not in self: + self.append(pt) + + +class BPair(object): + def __init__(self, bez1, bez2, t1, t2): + self.bez1 = bez1 + self.bez2 = bez2 + self.t1 = t1 # t value to get the mid point of this curve from cub1 + self.t2 = t2 # t value to get the mid point of this curve from cub2 + + +def bezier_intersections(bez1, bez2, longer_length, tol=1e-8, tol_deC=1e-8): + """INPUT: + bez1, bez2 = [P0,P1,P2,...PN], [Q0,Q1,Q2,...,PN] defining the two + Bezier curves to check for intersections between. + longer_length - the length (or an upper bound) on the longer of the two + Bezier curves. Determines the maximum iterations needed together with tol. + tol - is the smallest distance that two solutions can differ by and still + be considered distinct solutions. + OUTPUT: a list of tuples (t,s) in [0,1]x[0,1] such that + bezier_point(cubs[0],t) - bezier_point(cubs[1],s) < tol_deC + Note: This will return exactly one such tuple for each intersection + (assuming tol_deC is small enough)""" + maxits = int(ceil(1-log(tol_deC/longer_length)/log(2))) + pair_list = [BPair(bez1, bez2, 0.5, 0.5)] + intersection_list = [] + k = 0 + approx_point_set = ApproxSolutionSet(tol) + while pair_list and k < maxits: + new_pairs = [] + delta = 0.5**(k + 2) + for pair in pair_list: + bbox1 = bezier_bounding_box(pair.bez1) + bbox2 = bezier_bounding_box(pair.bez2) + if boxes_intersect(bbox1, bbox2): + if box_area(*bbox1) < tol_deC and box_area(*bbox2) < tol_deC: + point = bezier_point(bez1, pair.t1) + if point not in approx_point_set: + approx_point_set.append(point) + # this is the point in the middle of the pair + intersection_list.append((pair.t1, pair.t2)) + + # this prevents the output of redundant intersection points + for otherPair in pair_list: + if pair.bez1 == otherPair.bez1 or \ + pair.bez2 == otherPair.bez2 or \ + pair.bez1 == otherPair.bez2 or \ + pair.bez2 == otherPair.bez1: + pair_list.remove(otherPair) + else: + (c11, c12) = halve_bezier(pair.bez1) + (t11, t12) = (pair.t1 - delta, pair.t1 + delta) + (c21, c22) = halve_bezier(pair.bez2) + (t21, t22) = (pair.t2 - delta, pair.t2 + delta) + new_pairs += [BPair(c11, c21, t11, t21), + BPair(c11, c22, t11, t22), + BPair(c12, c21, t12, t21), + BPair(c12, c22, t12, t22)] + pair_list = new_pairs + k += 1 + if k >= maxits: + raise Exception("bezier_intersections has reached maximum " + "iterations without terminating... " + "either there's a problem/bug or you can fix by " + "raising the max iterations or lowering tol_deC") + return intersection_list + + +def bezier_by_line_intersections(bezier, line): + """Returns tuples (t1,t2) such that bezier.point(t1) ~= line.point(t2).""" + # The method here is to translate (shift) then rotate the complex plane so + # that line starts at the origin and proceeds along the positive real axis. + # After this transformation, the intersection points are the real roots of + # the imaginary component of the bezier for which the real component is + # between 0 and abs(line[1]-line[0])]. + assert len(line[:]) == 2 + assert line[0] != line[1] + if not any(p != bezier[0] for p in bezier): + raise ValueError("bezier is nodal, use " + "bezier_by_line_intersection(bezier[0], line) " + "instead for a bool to be returned.") + + # First let's shift the complex plane so that line starts at the origin + shifted_bezier = [z - line[0] for z in bezier] + shifted_line_end = line[1] - line[0] + line_length = abs(shifted_line_end) + + # Now let's rotate the complex plane so that line falls on the x-axis + rotation_matrix = line_length/shifted_line_end + transformed_bezier = [rotation_matrix*z for z in shifted_bezier] + + # Now all intersections should be roots of the imaginary component of + # the transformed bezier + transformed_bezier_imag = [p.imag for p in transformed_bezier] + coeffs_y = bezier2polynomial(transformed_bezier_imag) + roots_y = list(polyroots01(coeffs_y)) # returns real roots 0 <= r <= 1 + + transformed_bezier_real = [p.real for p in transformed_bezier] + intersection_list = [] + for bez_t in set(roots_y): + xval = bezier_point(transformed_bezier_real, bez_t) + if 0 <= xval <= line_length: + line_t = xval/line_length + intersection_list.append((bez_t, line_t)) + return intersection_list + diff --git a/svgpathtools/misctools.py b/svgpathtools/misctools.py new file mode 100644 index 0000000..787d000 --- /dev/null +++ b/svgpathtools/misctools.py @@ -0,0 +1,64 @@ +"""This submodule contains miscellaneous tools that are used internally, but +aren't specific to SVGs or related mathematical objects.""" + +# External dependencies: +from __future__ import division, absolute_import, print_function +import os +import sys +import webbrowser + + +# stackoverflow.com/questions/214359/converting-hex-color-to-rgb-and-vice-versa +def hex2rgb(value): + """Converts a hexadeximal color string to an RGB 3-tuple + + EXAMPLE + ------- + >>> hex2rgb('#0000FF') + (0, 0, 255) + """ + value = value.lstrip('#') + lv = len(value) + return tuple(int(value[i:i+lv//3], 16) for i in range(0, lv, lv//3)) + + +# stackoverflow.com/questions/214359/converting-hex-color-to-rgb-and-vice-versa +def rgb2hex(rgb): + """Converts an RGB 3-tuple to a hexadeximal color string. + + EXAMPLE + ------- + >>> rgb2hex((0,0,255)) + '#0000FF' + """ + return ('#%02x%02x%02x' % rgb).upper() + + +def isclose(a, b, rtol=1e-5, atol=1e-8): + """This is essentially np.isclose, but slightly faster.""" + return abs(a - b) < (atol + rtol * abs(b)) + + +def open_in_browser(file_location): + """Attempt to open file located at file_location in the default web + browser.""" + + # If just the name of the file was given, check if it's in the Current + # Working Directory. + if not os.path.isfile(file_location): + file_location = os.path.join(os.getcwd(), file_location) + if not os.path.isfile(file_location): + raise IOError("\n\nFile not found.") + + # For some reason OSX requires this adjustment (tested on 10.10.4) + if sys.platform == "darwin": + file_location = "file:///"+file_location + + new = 2 # open in a new tab, if possible + webbrowser.get().open(file_location, new=new) + + +BugException = Exception("This code should never be reached. You've found a " + "bug. Please submit an issue to \n" + "https://github.com/mathandy/svgpathtools/issues" + "\nwith an easily reproducible example.") diff --git a/svgpathtools/parser.py b/svgpathtools/parser.py new file mode 100644 index 0000000..3925fb6 --- /dev/null +++ b/svgpathtools/parser.py @@ -0,0 +1,195 @@ +"""This submodule contains the path_parse() function used to convert SVG path +element d-strings into svgpathtools Path objects. +Note: This file was taken (nearly) as is from the svg.path module +(v 2.0).""" + +# External dependencies +from __future__ import division, absolute_import, print_function +import re + +# Internal dependencies +from .path import Path, Line, QuadraticBezier, CubicBezier, Arc + + +COMMANDS = set('MmZzLlHhVvCcSsQqTtAa') +UPPERCASE = set('MZLHVCSQTA') + +COMMAND_RE = re.compile("([MmZzLlHhVvCcSsQqTtAa])") +FLOAT_RE = re.compile("[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?") + + +def _tokenize_path(pathdef): + for x in COMMAND_RE.split(pathdef): + if x in COMMANDS: + yield x + for token in FLOAT_RE.findall(x): + yield token + + +def parse_path(pathdef, current_pos=0j): + # In the SVG specs, initial movetos are absolute, even if + # specified as 'm'. This is the default behavior here as well. + # But if you pass in a current_pos variable, the initial moveto + # will be relative to that current_pos. This is useful. + elements = list(_tokenize_path(pathdef)) + # Reverse for easy use of .pop() + elements.reverse() + + segments = Path() + start_pos = None + command = None + + while elements: + + if elements[-1] in COMMANDS: + # New command. + last_command = command # Used by S and T + command = elements.pop() + absolute = command in UPPERCASE + command = command.upper() + else: + # If this element starts with numbers, it is an implicit command + # and we don't change the command. Check that it's allowed: + if command is None: + raise ValueError("Unallowed implicit command in %s, position %s" % ( + pathdef, len(pathdef.split()) - len(elements))) + + if command == 'M': + # Moveto command. + x = elements.pop() + y = elements.pop() + pos = float(x) + float(y) * 1j + if absolute: + current_pos = pos + else: + current_pos += pos + + # when M is called, reset start_pos + # This behavior of Z is defined in svg spec: + # http://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand + start_pos = current_pos + + # Implicit moveto commands are treated as lineto commands. + # So we set command to lineto here, in case there are + # further implicit commands after this moveto. + command = 'L' + + elif command == 'Z': + # Close path + segments.append(Line(current_pos, start_pos)) + segments.closed = True + current_pos = start_pos + start_pos = None + command = None # You can't have implicit commands after closing. + + elif command == 'L': + x = elements.pop() + y = elements.pop() + pos = float(x) + float(y) * 1j + if not absolute: + pos += current_pos + segments.append(Line(current_pos, pos)) + current_pos = pos + + elif command == 'H': + x = elements.pop() + pos = float(x) + current_pos.imag * 1j + if not absolute: + pos += current_pos.real + segments.append(Line(current_pos, pos)) + current_pos = pos + + elif command == 'V': + y = elements.pop() + pos = current_pos.real + float(y) * 1j + if not absolute: + pos += current_pos.imag * 1j + segments.append(Line(current_pos, pos)) + current_pos = pos + + elif command == 'C': + control1 = float(elements.pop()) + float(elements.pop()) * 1j + control2 = float(elements.pop()) + float(elements.pop()) * 1j + end = float(elements.pop()) + float(elements.pop()) * 1j + + if not absolute: + control1 += current_pos + control2 += current_pos + end += current_pos + + segments.append(CubicBezier(current_pos, control1, control2, end)) + current_pos = end + + elif command == 'S': + # Smooth curve. First control point is the "reflection" of + # the second control point in the previous path. + + if last_command not in 'CS': + # If there is no previous command or if the previous command + # was not an C, c, S or s, assume the first control point is + # coincident with the current point. + control1 = current_pos + else: + # The first control point is assumed to be the reflection of + # the second control point on the previous command relative + # to the current point. + control1 = current_pos + current_pos - segments[-1].control2 + + control2 = float(elements.pop()) + float(elements.pop()) * 1j + end = float(elements.pop()) + float(elements.pop()) * 1j + + if not absolute: + control2 += current_pos + end += current_pos + + segments.append(CubicBezier(current_pos, control1, control2, end)) + current_pos = end + + elif command == 'Q': + control = float(elements.pop()) + float(elements.pop()) * 1j + end = float(elements.pop()) + float(elements.pop()) * 1j + + if not absolute: + control += current_pos + end += current_pos + + segments.append(QuadraticBezier(current_pos, control, end)) + current_pos = end + + elif command == 'T': + # Smooth curve. Control point is the "reflection" of + # the second control point in the previous path. + + if last_command not in 'QT': + # If there is no previous command or if the previous command + # was not an Q, q, T or t, assume the first control point is + # coincident with the current point. + control = current_pos + else: + # The control point is assumed to be the reflection of + # the control point on the previous command relative + # to the current point. + control = current_pos + current_pos - segments[-1].control + + end = float(elements.pop()) + float(elements.pop()) * 1j + + if not absolute: + end += current_pos + + segments.append(QuadraticBezier(current_pos, control, end)) + current_pos = end + + elif command == 'A': + radius = float(elements.pop()) + float(elements.pop()) * 1j + rotation = float(elements.pop()) + arc = float(elements.pop()) + sweep = float(elements.pop()) + end = float(elements.pop()) + float(elements.pop()) * 1j + + if not absolute: + end += current_pos + + segments.append(Arc(current_pos, radius, rotation, arc, sweep, end)) + current_pos = end + + return segments diff --git a/svgpathtools/path.py b/svgpathtools/path.py new file mode 100644 index 0000000..fbe8c4e --- /dev/null +++ b/svgpathtools/path.py @@ -0,0 +1,2130 @@ +"""This submodule contains the class definitions of the the main five classes +svgpathtools is built around: Path, Line, QuadraticBezier, CubicBezier, and +Arc.""" + +# External dependencies +from __future__ import division, absolute_import, print_function +from math import sqrt, cos, sin, acos, degrees, radians, log, pi +from cmath import exp, sqrt as csqrt, phase +from collections import MutableSequence +from warnings import warn +from operator import itemgetter +import numpy as np +try: + from scipy.integrate import quad + _quad_available = True +except: + _quad_available = False + +# Internal dependencies +from .bezier import (bezier_intersections, bezier_bounding_box, split_bezier, + bezier_by_line_intersections, polynomial2bezier) +from .misctools import BugException +from .polytools import rational_limit, polyroots, polyroots01, imag, real + + +# Default Parameters ########################################################## + +# path segment .length() parameters for arc length computation +LENGTH_MIN_DEPTH = 5 +LENGTH_ERROR = 1e-12 +USE_SCIPY_QUAD = True # for elliptic Arc segment arc length computation + +# path segment .ilength() parameters for inverse arc length computation +ILENGTH_MIN_DEPTH = 5 +ILENGTH_ERROR = 1e-12 +ILENGTH_S_TOL = 1e-12 +ILENGTH_MAXITS = 10000 + +# compatibility/implementation related warnings and parameters +CLOSED_WARNING_ON = True +_NotImplemented4ArcException = \ + Exception("This method has not yet been implemented for Arc objects.") +# _NotImplemented4QuadraticException = \ +# Exception("This method has not yet been implemented for QuadraticBezier " +# "objects.") +_is_smooth_from_warning = \ + ("The name of this method is somewhat misleading (yet kept for " + "compatibility with scripts created using svg.path 2.0). This method " + "is meant only for d-string creation and should NOT be used to check " + "for kinks. To check a segment for differentiability, use the " + "joins_smoothly_with() method instead or the kinks() function (in " + "smoothing.py).\nTo turn off this warning, set " + "warning_on=False.") + + +# Miscellaneous ############################################################### + +def bezier_segment(*bpoints): + if len(bpoints) == 2: + return Line(*bpoints) + elif len(bpoints) == 4: + return CubicBezier(*bpoints) + elif len(bpoints) == 3: + return QuadraticBezier(*bpoints) + else: + assert len(bpoints) in (2, 3, 4) + + +def is_bezier_segment(seg): + return (isinstance(seg, Line) or + isinstance(seg, QuadraticBezier) or + isinstance(seg, CubicBezier)) + + +def is_path_segment(seg): + return is_bezier_segment(seg) or isinstance(seg, Arc) + + +def is_bezier_path(path): + """Checks that all segments in path are a Line, QuadraticBezier, or + CubicBezier object.""" + return isinstance(path, Path) and all(map(is_bezier_segment, path)) + + +def concatpaths(list_of_paths): + """Takes in a sequence of paths and returns their concatenations into a + single path (following the order of the input sequence).""" + return Path(*[seg for path in list_of_paths for seg in path]) + + +def bbox2path(xmin, xmax, ymin, ymax): + """Converts a bounding box 4-tuple to a Path object.""" + b = Line(xmin + 1j*ymin, xmax + 1j*ymin) + t = Line(xmin + 1j*ymax, xmax + 1j*ymax) + r = Line(xmax + 1j*ymin, xmax + 1j*ymax) + l = Line(xmin + 1j*ymin, xmin + 1j*ymax) + return Path(b, r, t.reversed(), l.reversed()) + + +# Conversion################################################################### + +def bpoints2bezier(bpoints): + """Converts a list of length 2, 3, or 4 to a CubicBezier, QuadraticBezier, + or Line object, respectively. + See also: poly2bez.""" + order = len(bpoints) - 1 + if order == 3: + return CubicBezier(*bpoints) + elif order == 2: + return QuadraticBezier(*bpoints) + elif order == 1: + return Line(*bpoints) + else: + assert len(bpoints) in {2, 3, 4} + + +def poly2bez(poly, return_bpoints=False): + """Converts a cubic or lower order Polynomial object (or a sequence of + coefficients) to a CubicBezier, QuadraticBezier, or Line object as + appropriate. If return_bpoints=True then this will instead only return + the control points of the corresponding Bezier curve. + Note: The inverse operation is available as a method of CubicBezier, + QuadraticBezier and Line objects.""" + bpoints = polynomial2bezier(poly) + if return_bpoints: + return bpoints + else: + return bpoints2bezier(bpoints) + + +def bez2poly(bez, numpy_ordering=True, return_poly1d=False): + """Converts a Bezier object or tuple of Bezier control points to a tuple + of coefficients of the expanded polynomial. + return_poly1d : returns a numpy.poly1d object. This makes computations + of derivatives/anti-derivatives and many other operations quite quick. + numpy_ordering : By default (to accommodate numpy) the coefficients will + be output in reverse standard order. + Note: This function is redundant thanks to the .poly() method included + with all bezier segment classes.""" + if is_bezier_segment(bez): + bez = bez.bpoints() + return bezier2polynomial(bez, + numpy_ordering=numpy_ordering, + return_poly1d=return_poly1d) + + +# Geometric#################################################################### + +def rotate(curve, degs, origin=None): + """Returns curve rotated by `degs` degrees (CCW) around the point `origin` + (a complex number). By default origin is either `curve.point(0.5)`, or in + the case that curve is an Arc object, `origin` defaults to `curve.center`. + """ + def transform(z): + return exp(1j*radians(degs))*(z - origin) + origin + + if origin == None: + if isinstance(curve, Arc): + origin = curve.center + else: + origin = curve.point(0.5) + + if isinstance(curve, Path): + return Path(*[rotate(seg, degs, origin=origin) for seg in curve]) + elif is_bezier_segment(curve): + return bpoints2bezier([transform(bpt) for bpt in curve.bpoints()]) + elif isinstance(curve, Arc): + new_start = transform(curve.start) + new_end = transform(curve.end) + new_rotation = curve.rotation + degs + return Arc(new_start, radius=curve.radius, rotation=new_rotation, + large_arc=curve.large_arc, sweep=curve.sweep, end=new_end) + else: + raise TypeError("Input `curve` should be a Path, Line, " + "QuadraticBezier, CubicBezier, or Arc object.") + + +def translate(curve, z0): + """Shifts the curve by the complex quantity z such that + translate(curve, z0).point(t) = curve.point(t) + z0""" + if isinstance(curve, Path): + return Path(*[translate(seg, z0) for seg in curve]) + elif is_bezier_segment(curve): + return bpoints2bezier([bpt + z0 for bpt in curve.bpoints()]) + elif isinstance(curve, Arc): + new_start = curve.start + z0 + new_end = curve.end + z0 + return Arc(new_start, radius=curve.radius, rotation=curve.rotation, + large_arc=curve.large_arc, sweep=curve.sweep, end=new_end) + else: + raise TypeError("Input `curve` should be a Path, Line, " + "QuadraticBezier, CubicBezier, or Arc object.") + + +def bezier_unit_tangent(seg, t): + """Returns the unit tangent of the segment at t. + + Notes + ----- + If you receive a RuntimeWarning, try the following: + >>> import numpy + >>> old_numpy_error_settings = numpy.seterr(invalid='raise') + This can be undone with: + >>> numpy.seterr(**old_numpy_error_settings) + """ + assert 0 <= t <= 1 + dseg = seg.derivative(t) + + # Note: dseg might be numpy value, use np.seterr(invalid='raise') + try: + unit_tangent = dseg/abs(dseg) + except (ZeroDivisionError, FloatingPointError): + # This may be a removable singularity, if so we just need to compute + # the limit. + # Note: limit{{dseg / abs(dseg)} = sqrt(limit{dseg**2 / abs(dseg)**2}) + dseg_poly = seg.poly().deriv() + dseg_abs_squared_poly = (real(dseg_poly) ** 2 + + imag(dseg_poly) ** 2) + try: + unit_tangent = csqrt(rational_limit(dseg_poly**2, + dseg_abs_squared_poly, t)) + except ValueError: + bef = seg.poly().deriv()(t - 1e-4) + aft = seg.poly().deriv()(t + 1e-4) + mes = ("Unit tangent appears to not be well-defined at " + "t = {}, \n".format(t) + + "seg.poly().deriv()(t - 1e-4) = {}\n".format(bef) + + "seg.poly().deriv()(t + 1e-4) = {}".format(aft)) + raise ValueError(mes) + return unit_tangent + + +def segment_curvature(self, t, use_inf=False): + """returns the curvature of the segment at t. + + Notes + ----- + If you receive a RuntimeWarning, run command + >>> old = np.seterr(invalid='raise') + This can be undone with + >>> np.seterr(**old) + """ + + dz = self.derivative(t) + ddz = self.derivative(t, n=2) + dx, dy = dz.real, dz.imag + ddx, ddy = ddz.real, ddz.imag + old_np_seterr = np.seterr(invalid='raise') + try: + kappa = abs(dx*ddy - dy*ddx)/sqrt(dx*dx + dy*dy)**3 + except (ZeroDivisionError, FloatingPointError): + # tangent vector is zero at t, use polytools to find limit + p = self.poly() + dp = p.deriv() + ddp = dp.deriv() + dx, dy = real(dp), imag(dp) + ddx, ddy = real(ddp), imag(ddp) + f2 = (dx*ddy - dy*ddx)**2 + g2 = (dx*dx + dy*dy)**3 + lim2 = rational_limit(f2, g2, t) + if lim2 < 0: # impossible, must be numerical error + return 0 + kappa = sqrt(lim2) + finally: + np.seterr(**old_np_seterr) + return kappa + + +def bezier_radialrange(seg, origin, return_all_global_extrema=False): + """returns the tuples (d_min, t_min) and (d_max, t_max) which minimize and + maximize, respectively, the distance d = |self.point(t)-origin|. + return_all_global_extrema: Multiple such t_min or t_max values can exist. + By default, this will only return one. Set return_all_global_extrema=True + to return all such global extrema.""" + + def _radius(tau): + return abs(seg.point(tau) - origin) + + shifted_seg_poly = seg.poly() - origin + r_squared = real(shifted_seg_poly) ** 2 + \ + imag(shifted_seg_poly) ** 2 + extremizers = [0, 1] + polyroots01(r_squared.deriv()) + extrema = [(_radius(t), t) for t in extremizers] + + if return_all_global_extrema: + raise NotImplementedError + else: + seg_global_min = min(extrema, key=itemgetter(0)) + seg_global_max = max(extrema, key=itemgetter(0)) + return seg_global_min, seg_global_max + + +def closest_point_in_path(pt, path): + """returns (|path.seg.point(t)-pt|, t, seg_idx) where t and seg_idx + minimize the distance between pt and curve path[idx].point(t) for 0<=t<=1 + and any seg_idx. + Warning: Multiple such global minima can exist. This will only return + one.""" + return path.radialrange(pt)[0] + + +def farthest_point_in_path(pt, path): + """returns (|path.seg.point(t)-pt|, t, seg_idx) where t and seg_idx + maximize the distance between pt and curve path[idx].point(t) for 0<=t<=1 + and any seg_idx. + :rtype : object + :param pt: + :param path: + Warning: Multiple such global maxima can exist. This will only return + one.""" + return path.radialrange(pt)[1] + + +def path_encloses_pt(pt, opt, path): + """returns true if pt is a point enclosed by path (which must be a Path + object satisfying path.isclosed==True). opt is a point you know is + NOT enclosed by path.""" + assert path.isclosed() + intersections = Path(Line(pt, opt)).intersect(path) + if len(intersections) % 2: + return True + else: + return False + + +def segment_length(curve, start, end, start_point, end_point, + error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH, depth=0): + """Recursively approximates the length by straight lines""" + mid = (start + end)/2 + mid_point = curve.point(mid) + length = abs(end_point - start_point) + first_half = abs(mid_point - start_point) + second_half = abs(end_point - mid_point) + + length2 = first_half + second_half + if (length2 - length > error) or (depth < min_depth): + # Calculate the length of each segment: + depth += 1 + return (segment_length(curve, start, mid, start_point, mid_point, + error, min_depth, depth) + + segment_length(curve, mid, end, mid_point, end_point, + error, min_depth, depth)) + # This is accurate enough. + return length2 + + +def inv_arclength(curve, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, + error=ILENGTH_ERROR, min_depth=ILENGTH_MIN_DEPTH): + """INPUT: curve should be a CubicBezier, Line, of Path of CubicBezier + and/or Line objects. + OUTPUT: Returns a float, t, such that the arc length of curve from 0 to + t is approximately s. + s_tol - exit when |s(t) - s| < s_tol where + s(t) = seg.length(0, t, error, min_depth) and seg is either curve or, + if curve is a Path object, then seg is a segment in curve. + error - used to compute lengths of cubics and arcs + min_depth - used to compute lengths of cubics and arcs + Note: This function is not designed to be efficient.""" + + curve_length = curve.length(error=error, min_depth=min_depth) + assert curve_length > 0 + if not 0 <= s <= curve_length: + raise ValueError("s is not in interval [0, curve.length()].") + + if s == 0: + return 0 + if s == curve_length: + return 1 + + if isinstance(curve, Path): + seg_lengths = [seg.length(error=error, min_depth=min_depth) for seg in curve] + lsum = 0 + # Find which segment the point we search for is located on + for k, len_k in enumerate(seg_lengths): + if lsum <= s <= lsum + len_k: + t = inv_arclength(curve[k], s - lsum, s_tol=s_tol, maxits=maxits, error=error, min_depth=min_depth) + return curve.t2T(k, t) + lsum += len_k + return 1 + + elif isinstance(curve, Line): + return s / curve.length(error=error, min_depth=min_depth) + + elif (isinstance(curve, QuadraticBezier) or + isinstance(curve, CubicBezier) or + isinstance(curve, Arc)): + t_upper = 1 + t_lower = 0 + iteration = 0 + while iteration < maxits: + iteration += 1 + t = (t_lower + t_upper)/2 + s_t = curve.length(t1=t, error=error, min_depth=min_depth) + if abs(s_t - s) < s_tol: + return t + elif s_t < s: # t too small + t_lower = t + else: # s < s_t, t too big + t_upper = t + if t_upper == t_lower: + warn("t is as close as a float can be to the correct value, " + "but |s(t) - s| = {} > s_tol".format(abs(s_t-s))) + return t + raise Exception("Maximum iterations reached with s(t) - s = {}." + "".format(s_t - s)) + else: + raise TypeError("First argument must be a Line, QuadraticBezier, " + "CubicBezier, Arc, or Path object.") + +# Operations################################################################### + + +def crop_bezier(seg, t0, t1): + """returns a cropped copy of this segment which starts at self.point(t0) + and ends at self.point(t1).""" + assert t0 < t1 + if t0 == 0: + cropped_seg = seg.split(t1)[0] + elif t1 == 1: + cropped_seg = seg.split(t0)[1] + else: + pt1 = seg.point(t1) + + # trim off the 0 <= t < t0 part + trimmed_seg = crop_bezier(seg, t0, 1) + + # find the adjusted t1 (i.e. the t1 such that + # trimmed_seg.point(t1) ~= pt))and trim off the t1 < t <= 1 part + t1_adj = trimmed_seg.radialrange(pt1)[0][1] + cropped_seg = crop_bezier(trimmed_seg, 0, t1_adj) + return cropped_seg + + +# Main Classes ################################################################ + + +class Line(object): + def __init__(self, start, end): + self.start = start + self.end = end + + def __repr__(self): + return 'Line(start=%s, end=%s)' % (self.start, self.end) + + def __eq__(self, other): + if not isinstance(other, Line): + return NotImplemented + return self.start == other.start and self.end == other.end + + def __ne__(self, other): + if not isinstance(other, Line): + return NotImplemented + return not self == other + + def __getitem__(self, item): + return self.bpoints()[item] + + def __len__(self): + return 2 + + def joins_smoothly_with(self, previous, wrt_parameterization=False): + """Checks if this segment joins smoothly with previous segment. By + default, this only checks that this segment starts moving (at t=0) in + the same direction (and from the same positive) as previous stopped + moving (at t=1). To check if the tangent magnitudes also match, set + wrt_parameterization=True.""" + if wrt_parameterization: + return self.start == previous.end and np.isclose( + self.derivative(0), previous.derivative(1)) + else: + return self.start == previous.end and np.isclose( + self.unit_tangent(0), previous.unit_tangent(1)) + + def point(self, t): + """returns the coordinates of the Bezier curve evaluated at t.""" + distance = self.end - self.start + return self.start + distance*t + + def length(self, t0=0, t1=1, error=None, min_depth=None): + """returns the length of the line segment between t0 and t1.""" + return abs(self.end - self.start)*(t1-t0) + + def ilength(self, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, + error=ILENGTH_ERROR, min_depth=ILENGTH_MIN_DEPTH): + """Returns a float, t, such that self.length(0, t) is approximately s. + See the inv_arclength() docstring for more details.""" + return inv_arclength(self, s, s_tol=s_tol, maxits=maxits, error=error, + min_depth=min_depth) + + def bpoints(self): + """returns the Bezier control points of the segment.""" + return self.start, self.end + + def poly(self, return_coeffs=False): + """returns the line as a Polynomial object.""" + p = self.bpoints() + coeffs = ([p[1] - p[0], p[0]]) + if return_coeffs: + return coeffs + else: + return np.poly1d(coeffs) + + def derivative(self, t=None, n=1): + """returns the nth derivative of the segment at t.""" + assert self.end != self.start + if n == 1: + return self.end - self.start + elif n > 1: + return 0 + else: + raise ValueError("n should be a positive integer.") + + def unit_tangent(self, t=None): + """returns the unit tangent of the segment at t.""" + assert self.end != self.start + dseg = self.end - self.start + return dseg/abs(dseg) + + def normal(self, t=None): + """returns the (right hand rule) unit normal vector to self at t.""" + return -1j*self.unit_tangent(t) + + def curvature(self, t): + """returns the curvature of the line, which is always zero.""" + return 0 + + def reversed(self): + """returns a copy of the Line object with its orientation reversed.""" + return Line(self.end, self.start) + + def intersect(self, other_seg, tol=None): + """Finds the intersections of two segments. + returns a list of tuples (t1, t2) such that + self.point(t1) == other_seg.point(t2). + Note: This will fail if the two segments coincide for more than a + finite collection of points. + tol is not used.""" + if isinstance(other_seg, Line): + assert other_seg.end != other_seg.start and self.end != self.start + assert self != other_seg + # Solve the system [p1-p0, q1-q0]*[t1, t2]^T = q0 - p0 + # where self == Line(p0, p1) and other_seg == Line(q0, q1) + a = (self.start.real, self.end.real) + b = (self.start.imag, self.end.imag) + c = (other_seg.start.real, other_seg.end.real) + d = (other_seg.start.imag, other_seg.end.imag) + denom = ((a[1] - a[0])*(d[0] - d[1]) - + (b[1] - b[0])*(c[0] - c[1])) + if denom == 0: + return [] + t1 = (c[0]*(b[0] - d[1]) - + c[1]*(b[0] - d[0]) - + a[0]*(d[0] - d[1]))/denom + t2 = -(a[1]*(b[0] - d[0]) - + a[0]*(b[1] - d[0]) - + c[0]*(b[0] - b[1]))/denom + if 0 <= t1 <= 1 and 0 <= t2 <= 1: + return [(t1, t2)] + return [] + elif isinstance(other_seg, QuadraticBezier): + return bezier_by_line_intersections(other_seg, self) + elif isinstance(other_seg, CubicBezier): + return bezier_by_line_intersections(other_seg, self) + elif isinstance(other_seg, Arc): + t2t1s = other_seg.intersect(self) + return [(t1, t2) for t2, t1 in t2t1s] + elif isinstance(other_seg, Path): + raise TypeError( + "other_seg must be a path segment, not a Path object, use " + "Path.intersect().") + else: + raise TypeError("other_seg must be a path segment.") + + def bbox(self): + """returns the bounding box for the segment in the form + (xmin, xmax, ymin, ymax).""" + xmin = min(self.start.real, self.end.real) + xmax = max(self.start.real, self.end.real) + ymin = min(self.start.imag, self.end.imag) + ymax = max(self.start.imag, self.end.imag) + return xmin, xmax, ymin, ymax + + def cropped(self, t0, t1): + """returns a cropped copy of this segment which starts at + self.point(t0) and ends at self.point(t1).""" + return Line(self.point(t0), self.point(t1)) + + def split(self, t): + """returns two segments, whose union is this segment and which join at + self.point(t).""" + pt = self.point(t) + return Line(self.start, pt), Line(pt, self.end) + + def radialrange(self, origin, return_all_global_extrema=False): + """returns the tuples (d_min, t_min) and (d_max, t_max) which minimize + and maximize, respectively, the distance d = |self.point(t)-origin|.""" + return bezier_radialrange(self, origin, + return_all_global_extrema=return_all_global_extrema) + + def rotated(self, degs, origin=None): + """Returns a copy of self rotated by `degs` degrees (CCW) around the + point `origin` (a complex number). By default `origin` is either + `self.point(0.5)`, or in the case that self is an Arc object, + `origin` defaults to `self.center`.""" + return rotate(self, degs, origin=self.point(0.5)) + + def translated(self, z0): + """Returns a copy of self shifted by the complex quantity `z0` such + that self.translated(z0).point(t) = self.point(t) + z0 for any t.""" + return translate(self, z0) + + +class QuadraticBezier(object): + # For compatibility with old pickle files. + _length_info = {'length': None, 'bpoints': None} + + def __init__(self, start, control, end): + self.start = start + self.end = end + self.control = control + + # used to know if self._length needs to be updated + self._length_info = {'length': None, 'bpoints': None} + + def __repr__(self): + return 'QuadraticBezier(start=%s, control=%s, end=%s)' % ( + self.start, self.control, self.end) + + def __eq__(self, other): + if not isinstance(other, QuadraticBezier): + return NotImplemented + return self.start == other.start and self.end == other.end \ + and self.control == other.control + + def __ne__(self, other): + if not isinstance(other, QuadraticBezier): + return NotImplemented + return not self == other + + def __getitem__(self, item): + return self.bpoints()[item] + + def __len__(self): + return 3 + + def is_smooth_from(self, previous, warning_on=True): + """[Warning: The name of this method is somewhat misleading (yet kept + for compatibility with scripts created using svg.path 2.0). This + method is meant only for d string creation and should not be used to + check for kinks. To check a segment for differentiability, use the + joins_smoothly_with() method instead.]""" + if warning_on: + warn(_is_smooth_from_warning) + if isinstance(previous, QuadraticBezier): + return (self.start == previous.end and + (self.control - self.start) == ( + previous.end - previous.control)) + else: + return self.control == self.start + + def joins_smoothly_with(self, previous, wrt_parameterization=False, + error=0): + """Checks if this segment joins smoothly with previous segment. By + default, this only checks that this segment starts moving (at t=0) in + the same direction (and from the same positive) as previous stopped + moving (at t=1). To check if the tangent magnitudes also match, set + wrt_parameterization=True.""" + if wrt_parameterization: + return self.start == previous.end and abs( + self.derivative(0) - previous.derivative(1)) <= error + else: + return self.start == previous.end and abs( + self.unit_tangent(0) - previous.unit_tangent(1)) <= error + + def point(self, t): + """returns the coordinates of the Bezier curve evaluated at t.""" + return (1 - t)**2*self.start + 2*(1 - t)*t*self.control + t**2*self.end + + def length(self, t0=0, t1=1, error=None, min_depth=None): + if t0 == 1 and t1 == 0: + if self._length_info['bpoints'] == self.bpoints(): + return self._length_info['length'] + a = self.start - 2*self.control + self.end + b = 2*(self.control - self.start) + a_dot_b = a.real*b.real + a.imag*b.imag + + if abs(a) < 1e-12: + s = abs(b)*(t1 - t0) + elif abs(a_dot_b + abs(a)*abs(b)) < 1e-12: + tstar = abs(b)/(2*abs(a)) + if t1 < tstar: + return abs(a)*(t0**2 - t1**2) - abs(b)*(t0 - t1) + elif tstar < t0: + return abs(a)*(t1**2 - t0**2) - abs(b)*(t1 - t0) + else: + return abs(a)*(t1**2 + t0**2) - abs(b)*(t1 + t0) + \ + abs(b)**2/(2*abs(a)) + else: + c2 = 4*(a.real**2 + a.imag**2) + c1 = 4*a_dot_b + c0 = b.real**2 + b.imag**2 + + beta = c1/(2*c2) + gamma = c0/c2 - beta**2 + + dq1_mag = sqrt(c2*t1**2 + c1*t1 + c0) + dq0_mag = sqrt(c2*t0**2 + c1*t0 + c0) + logarand = (sqrt(c2)*(t1 + beta) + dq1_mag) / \ + (sqrt(c2)*(t0 + beta) + dq0_mag) + + s = (t1 + beta)*dq1_mag - (t0 + beta)*dq0_mag + \ + gamma*sqrt(c2)*log(logarand) + s /= 2 + + if t0 == 1 and t1 == 0: + self._length_info['length'] = s + self._length_info['bpoints'] = self.bpoints() + return self._length_info['length'] + else: + return s + + def ilength(self, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, + error=ILENGTH_ERROR, min_depth=ILENGTH_MIN_DEPTH): + """Returns a float, t, such that self.length(0, t) is approximately s. + See the inv_arclength() docstring for more details.""" + return inv_arclength(self, s, s_tol=s_tol, maxits=maxits, error=error, + min_depth=min_depth) + + def bpoints(self): + """returns the Bezier control points of the segment.""" + return self.start, self.control, self.end + + def poly(self, return_coeffs=False): + """returns the quadratic as a Polynomial object.""" + p = self.bpoints() + coeffs = (p[0] - 2*p[1] + p[2], 2*(p[1] - p[0]), p[0]) + if return_coeffs: + return coeffs + else: + return np.poly1d(coeffs) + + def derivative(self, t, n=1): + """returns the nth derivative of the segment at t. + Note: Bezier curves can have points where their derivative vanishes. + If you are interested in the tangent direction, use the unit_tangent() + method instead.""" + p = self.bpoints() + if n == 1: + return 2*((p[1] - p[0])*(1 - t) + (p[2] - p[1])*t) + elif n == 2: + return 2*(p[2] - 2*p[1] + p[0]) + elif n > 2: + return 0 + else: + raise ValueError("n should be a positive integer.") + + def unit_tangent(self, t): + """returns the unit tangent vector of the segment at t (centered at + the origin and expressed as a complex number). If the tangent + vector's magnitude is zero, this method will find the limit of + self.derivative(tau)/abs(self.derivative(tau)) as tau approaches t.""" + return bezier_unit_tangent(self, t) + + def normal(self, t): + """returns the (right hand rule) unit normal vector to self at t.""" + return -1j*self.unit_tangent(t) + + def curvature(self, t): + """returns the curvature of the segment at t.""" + return segment_curvature(self, t) + + def reversed(self): + """returns a copy of the QuadraticBezier object with its orientation + reversed.""" + new_quad = QuadraticBezier(self.end, self.control, self.start) + if self._length_info['length']: + new_quad._length_info = self._length_info + new_quad._length_info['bpoints'] = ( + self.end, self.control, self.start) + return new_quad + + def intersect(self, other_seg, tol=1e-12): + """Finds the intersections of two segments. + returns a list of tuples (t1, t2) such that + self.point(t1) == other_seg.point(t2). + Note: This will fail if the two segments coincide for more than a + finite collection of points.""" + if isinstance(other_seg, Line): + return bezier_by_line_intersections(self, other_seg) + elif isinstance(other_seg, QuadraticBezier): + assert self != other_seg + longer_length = max(self.length(), other_seg.length()) + return bezier_intersections(self, other_seg, + longer_length=longer_length, + tol=tol, tol_deC=tol) + elif isinstance(other_seg, CubicBezier): + longer_length = max(self.length(), other_seg.length()) + return bezier_intersections(self, other_seg, + longer_length=longer_length, + tol=tol, tol_deC=tol) + elif isinstance(other_seg, Arc): + t1 = other_seg.intersect(self) + return t1, t2 + elif isinstance(other_seg, Path): + raise TypeError( + "other_seg must be a path segment, not a Path object, use " + "Path.intersect().") + else: + raise TypeError("other_seg must be a path segment.") + + def bbox(self): + """returns the bounding box for the segment in the form + (xmin, xmax, ymin, ymax).""" + return bezier_bounding_box(self) + + def split(self, t): + """returns two segments, whose union is this segment and which join at + self.point(t).""" + bpoints1, bpoints2 = split_bezier(self.bpoints(), t) + return QuadraticBezier(*bpoints1), QuadraticBezier(*bpoints2) + + def cropped(self, t0, t1): + """returns a cropped copy of this segment which starts at + self.point(t0) and ends at self.point(t1).""" + return QuadraticBezier(*crop_bezier(self, t0, t1)) + + def radialrange(self, origin, return_all_global_extrema=False): + """returns the tuples (d_min, t_min) and (d_max, t_max) which minimize + and maximize, respectively, the distance d = |self.point(t)-origin|.""" + return bezier_radialrange(self, origin, + return_all_global_extrema=return_all_global_extrema) + + def rotated(self, degs, origin=None): + """Returns a copy of self rotated by `degs` degrees (CCW) around the + point `origin` (a complex number). By default `origin` is either + `self.point(0.5)`, or in the case that self is an Arc object, + `origin` defaults to `self.center`.""" + return rotate(self, degs, origin=self.point(0.5)) + + def translated(self, z0): + """Returns a copy of self shifted by the complex quantity `z0` such + that self.translated(z0).point(t) = self.point(t) + z0 for any t.""" + return translate(self, z0) + + +class CubicBezier(object): + # For compatibility with old pickle files. + _length_info = {'length': None, 'bpoints': None, 'error': None, + 'min_depth': None} + + def __init__(self, start, control1, control2, end): + self.start = start + self.control1 = control1 + self.control2 = control2 + self.end = end + + # used to know if self._length needs to be updated + self._length_info = {'length': None, 'bpoints': None, 'error': None, + 'min_depth': None} + + def __repr__(self): + return 'CubicBezier(start=%s, control1=%s, control2=%s, end=%s)' % ( + self.start, self.control1, self.control2, self.end) + + def __eq__(self, other): + if not isinstance(other, CubicBezier): + return NotImplemented + return self.start == other.start and self.end == other.end \ + and self.control1 == other.control1 \ + and self.control2 == other.control2 + + def __ne__(self, other): + if not isinstance(other, CubicBezier): + return NotImplemented + return not self == other + + def __getitem__(self, item): + return self.bpoints()[item] + + def __len__(self): + return 4 + + def is_smooth_from(self, previous, warning_on=True): + """[Warning: The name of this method is somewhat misleading (yet kept + for compatibility with scripts created using svg.path 2.0). This + method is meant only for d string creation and should not be used to + check for kinks. To check a segment for differentiability, use the + joins_smoothly_with() method instead.]""" + if warning_on: + warn(_is_smooth_from_warning) + if isinstance(previous, CubicBezier): + return (self.start == previous.end and + (self.control1 - self.start) == ( + previous.end - previous.control2)) + else: + return self.control1 == self.start + + def joins_smoothly_with(self, previous, wrt_parameterization=False): + """Checks if this segment joins smoothly with previous segment. By + default, this only checks that this segment starts moving (at t=0) in + the same direction (and from the same positive) as previous stopped + moving (at t=1). To check if the tangent magnitudes also match, set + wrt_parameterization=True.""" + if wrt_parameterization: + return self.start == previous.end and np.isclose( + self.derivative(0), previous.derivative(1)) + else: + return self.start == previous.end and np.isclose( + self.unit_tangent(0), previous.unit_tangent(1)) + + def point(self, t): + """Evaluate the cubic Bezier curve at t using Horner's rule.""" + # algebraically equivalent to + # P0*(1-t)**3 + 3*P1*t*(1-t)**2 + 3*P2*(1-t)*t**2 + P3*t**3 + # for (P0, P1, P2, P3) = self.bpoints() + return self.start + t*( + 3*(self.control1 - self.start) + t*( + 3*(self.start + self.control2) - 6*self.control1 + t*( + -self.start + 3*(self.control1 - self.control2) + self.end + ))) + + def length(self, t0=0, t1=1, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH): + """Calculate the length of the path up to a certain position""" + if t0 == 0 and t1 == 1: + if self._length_info['bpoints'] == self.bpoints() \ + and self._length_info['error'] >= error \ + and self._length_info['min_depth'] >= min_depth: + return self._length_info['length'] + + # using scipy.integrate.quad is quick + if _quad_available: + s = quad(lambda tau: abs(self.derivative(tau)), t0, t1, + epsabs=error, limit=1000)[0] + else: + s = segment_length(self, t0, t1, self.point(t0), self.point(t1), + error, min_depth, 0) + + if t0 == 0 and t1 == 1: + self._length_info['length'] = s + self._length_info['bpoints'] = self.bpoints() + self._length_info['error'] = error + self._length_info['min_depth'] = min_depth + return self._length_info['length'] + else: + return s + + def ilength(self, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, + error=ILENGTH_ERROR, min_depth=ILENGTH_MIN_DEPTH): + """Returns a float, t, such that self.length(0, t) is approximately s. + See the inv_arclength() docstring for more details.""" + return inv_arclength(self, s, s_tol=s_tol, maxits=maxits, error=error, + min_depth=min_depth) + + def bpoints(self): + """returns the Bezier control points of the segment.""" + return self.start, self.control1, self.control2, self.end + + def poly(self, return_coeffs=False): + """Returns a the cubic as a Polynomial object.""" + p = self.bpoints() + coeffs = (-p[0] + 3*(p[1] - p[2]) + p[3], + 3*(p[0] - 2*p[1] + p[2]), + 3*(-p[0] + p[1]), + p[0]) + if return_coeffs: + return coeffs + else: + return np.poly1d(coeffs) + + def derivative(self, t, n=1): + """returns the nth derivative of the segment at t. + Note: Bezier curves can have points where their derivative vanishes. + If you are interested in the tangent direction, use the unit_tangent() + method instead.""" + p = self.bpoints() + if n == 1: + return 3*(p[1] - p[0])*(1 - t)**2 + 6*(p[2] - p[1])*(1 - t)*t + 3*( + p[3] - p[2])*t**2 + elif n == 2: + return 6*( + (1 - t)*(p[2] - 2*p[1] + p[0]) + t*(p[3] - 2*p[2] + p[1])) + elif n == 3: + return 6*(p[3] - 3*(p[2] - p[1]) - p[0]) + elif n > 3: + return 0 + else: + raise ValueError("n should be a positive integer.") + + def unit_tangent(self, t): + """returns the unit tangent vector of the segment at t (centered at + the origin and expressed as a complex number). If the tangent + vector's magnitude is zero, this method will find the limit of + self.derivative(tau)/abs(self.derivative(tau)) as tau approaches t.""" + return bezier_unit_tangent(self, t) + + def normal(self, t): + """returns the (right hand rule) unit normal vector to self at t.""" + return -1j * self.unit_tangent(t) + + def curvature(self, t): + """returns the curvature of the segment at t.""" + return segment_curvature(self, t) + + def reversed(self): + """returns a copy of the CubicBezier object with its orientation + reversed.""" + new_cub = CubicBezier(self.end, self.control2, self.control1, + self.start) + if self._length_info['length']: + new_cub._length_info = self._length_info + new_cub._length_info['bpoints'] = ( + self.end, self.control2, self.control1, self.start) + return new_cub + + def intersect(self, other_seg, tol=1e-12): + """Finds the intersections of two segments. + returns a list of tuples (t1, t2) such that + self.point(t1) == other_seg.point(t2). + Note: This will fail if the two segments coincide for more than a + finite collection of points.""" + if isinstance(other_seg, Line): + return bezier_by_line_intersections(self, other_seg) + elif (isinstance(other_seg, QuadraticBezier) or + isinstance(other_seg, CubicBezier)): + assert self != other_seg + longer_length = max(self.length(), other_seg.length()) + return bezier_intersections(self, other_seg, + longer_length=longer_length, + tol=tol, tol_deC=tol) + elif isinstance(other_seg, Arc): + t2t1s = other_seg.intersect(self) + return [(t1, t2) for t2, t1 in t2t1s] + elif isinstance(other_seg, Path): + raise TypeError( + "other_seg must be a path segment, not a Path object, use " + "Path.intersect().") + else: + raise TypeError("other_seg must be a path segment.") + + def bbox(self): + """returns the bounding box for the segment in the form + (xmin, xmax, ymin, ymax).""" + return bezier_bounding_box(self) + + def split(self, t): + """returns two segments, whose union is this segment and which join at + self.point(t).""" + bpoints1, bpoints2 = split_bezier(self.bpoints(), t) + return CubicBezier(*bpoints1), CubicBezier(*bpoints2) + + def cropped(self, t0, t1): + """returns a cropped copy of this segment which starts at + self.point(t0) and ends at self.point(t1).""" + return CubicBezier(*crop_bezier(self, t0, t1)) + + def radialrange(self, origin, return_all_global_extrema=False): + """returns the tuples (d_min, t_min) and (d_max, t_max) which minimize + and maximize, respectively, the distance d = |self.point(t)-origin|.""" + return bezier_radialrange(self, origin, + return_all_global_extrema=return_all_global_extrema) + + def rotated(self, degs, origin=None): + """Returns a copy of self rotated by `degs` degrees (CCW) around the + point `origin` (a complex number). By default `origin` is either + `self.point(0.5)`, or in the case that self is an Arc object, + `origin` defaults to `self.center`.""" + return rotate(self, degs, origin=self.point(0.5)) + + def translated(self, z0): + """Returns a copy of self shifted by the complex quantity `z0` such + that self.translated(z0).point(t) = self.point(t) + z0 for any t.""" + return translate(self, z0) + + +class Arc(object): + def __init__(self, start, radius, rotation, large_arc, sweep, end, + autoscale_radius=True): + """ + This should be thought of as a part of an ellipse connecting two + points on that ellipse, start and end. + Parameters + ---------- + start : complex + The start point of the large_arc. + radius : complex + rx + 1j*ry, where rx and ry are the radii of the ellipse (also + known as its semi-major and semi-minor axes, or vice-versa or if + rx < ry). + Note: If rx = 0 or ry = 0 then this arc is treated as a + straight line segment joining the endpoints. + Note: If rx or ry has a negative sign, the sign is dropped; the + absolute value is used instead. + Note: If no such ellipse exists, the radius will be scaled so + that one does (unless autoscale_radius is set to False). + rotation : float + This is the CCW angle (in degrees) from the positive x-axis of the + current coordinate system to the x-axis of the ellipse. + large_arc : bool + This is the large_arc flag. Given two points on an ellipse, + there are two elliptical arcs connecting those points, the first + going the short way around the ellipse, and the second going the + long way around the ellipse. If large_arc is 0, the shorter + elliptical large_arc will be used. If large_arc is 1, then longer + elliptical will be used. + In other words, it should be 0 for arcs spanning less than or + equal to 180 degrees and 1 for arcs spanning greater than 180 + degrees. + sweep : bool + This is the sweep flag. For any acceptable parameters start, end, + rotation, and radius, there are two ellipses with the given major + and minor axes (radii) which connect start and end. One which + connects them in a CCW fashion and one which connected them in a + CW fashion. If sweep is 1, the CCW ellipse will be used. If + sweep is 0, the CW ellipse will be used. + + end : complex + The end point of the large_arc (must be distinct from start). + + Note on CW and CCW: The notions of CW and CCW are reversed in some + sense when viewing SVGs (as the y coordinate starts at the top of the + image and increases towards the bottom). + + Derived Parameters + ------------------ + self._parameterize() sets self.center, self.theta and self.delta + for use in self.point() and other methods. If + autoscale_radius == True, then this will also scale self.radius in the + case that no ellipse exists with the given parameters (see usage + below). + + self.theta : float + This is the phase (in degrees) of self.u1transform(self.start). + It is $\theta_1$ in the official documentation and ranges from + -180 to 180. + + self.delta : float + This is the angular distance (in degrees) between the start and + end of the arc after the arc has been sent to the unit circle + through self.u1transform(). + It is $\Delta\theta$ in the official documentation and ranges from + -360 to 360; being positive when the arc travels CCW and negative + otherwise (i.e. is positive/negative when sweep == True/False). + + self.center : complex + This is the center of the arc's ellipse. + """ + + self.start = start + self.radius = abs(radius.real) + 1j*abs(radius.imag) + self.rotation = rotation + self.large_arc = bool(large_arc) + self.sweep = bool(sweep) + self.end = end + self.autoscale_radius = autoscale_radius + + # Convenience parameters + self.phi = radians(self.rotation) + self.rot_matrix = exp(1j*self.phi) + + # Derive derived parameters + self._parameterize() + + def __repr__(self): + params = (self.start, self.radius, self.rotation, + self.large_arc, self.sweep, self.end) + return ("Arc(start={}, radius={}, rotation={}, " + "large_arc={}, sweep={}, end={})".format(*params)) + + def __eq__(self, other): + if not isinstance(other, Arc): + return NotImplemented + return self.start == other.start and self.end == other.end \ + and self.radius == other.radius \ + and self.rotation == other.rotation \ + and self.large_arc == other.large_arc and self.sweep == other.sweep + + def __ne__(self, other): + if not isinstance(other, Arc): + return NotImplemented + return not self == other + + def _parameterize(self): + # start cannot be the same as end as the ellipse would + # not be well defined + assert self.start != self.end + + # See http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes + # my notation roughly follows theirs + rx = self.radius.real + ry = self.radius.imag + rx_sqd = rx*rx + ry_sqd = ry*ry + + # Transform z-> z' = x' + 1j*y' + # = self.rot_matrix**(-1)*(z - (end+start)/2) + # coordinates. This translates the ellipse so that the midpoint + # between self.end and self.start lies on the origin and rotates + # the ellipse so that the its axes align with the xy-coordinate axes. + # Note: This sends self.end to -self.start + zp1 = (1/self.rot_matrix)*(self.start - self.end)/2 + x1p, y1p = zp1.real, zp1.imag + x1p_sqd = x1p*x1p + y1p_sqd = y1p*y1p + + # Correct out of range radii + # Note: an ellipse going through start and end with radius and phi + # exists if and only if radius_check is true + radius_check = (x1p_sqd/rx_sqd) + (y1p_sqd/ry_sqd) + if radius_check > 1: + if self.autoscale_radius: + rx *= sqrt(radius_check) + ry *= sqrt(radius_check) + self.radius = rx + 1j*ry + rx_sqd = rx*rx + ry_sqd = ry*ry + else: + raise ValueError("No such elliptic arc exists.") + + # Compute c'=(c_x', c_y'), the center of the ellipse in (x', y') coords + # Noting that, in our new coord system, (x_2', y_2') = (-x_1', -x_2') + # and our ellipse is cut out by of the plane by the algebraic equation + # (x'-c_x')**2 / r_x**2 + (y'-c_y')**2 / r_y**2 = 1, + # we can find c' by solving the system of two quadratics given by + # plugging our transformed endpoints (x_1', y_1') and (x_2', y_2') + tmp = rx_sqd*y1p_sqd + ry_sqd*x1p_sqd + radicand = (rx_sqd*ry_sqd - tmp) / tmp + try: + radical = sqrt(radicand) + except ValueError: + radical = 0 + if self.large_arc == self.sweep: + cp = -radical*(rx*y1p/ry - 1j*ry*x1p/rx) + else: + cp = radical*(rx*y1p/ry - 1j*ry*x1p/rx) + + # The center in (x,y) coordinates is easy to find knowing c' + self.center = exp(1j*self.phi)*cp + (self.start + self.end)/2 + + # Now we do a second transformation, from (x', y') to (u_x, u_y) + # coordinates, which is a translation moving the center of the + # ellipse to the origin and a dilation stretching the ellipse to be + # the unit circle + u1 = (x1p - cp.real)/rx + 1j*(y1p - cp.imag)/ry # transformed start + u2 = (-x1p - cp.real)/rx + 1j*(-y1p - cp.imag)/ry # transformed end + + # Now compute theta and delta (we'll define them as we go) + # delta is the angular distance of the arc (w.r.t the circle) + # theta is the angle between the positive x'-axis and the start point + # on the circle + if u1.imag > 0: + self.theta = degrees(acos(u1.real)) + elif u1.imag < 0: + self.theta = -degrees(acos(u1.real)) + else: + if u1.real > 0: # start is on pos u_x axis + self.theta = 0 + else: # start is on neg u_x axis + # Note: This behavior disagrees with behavior documented in + # http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes + # where theta is set to 0 in this case. + self.theta = 180 + + det_uv = u1.real*u2.imag - u1.imag*u2.real + + acosand = u1.real*u2.real + u1.imag*u2.imag + if acosand > 1 or acosand < -1: + acosand = round(acosand) + if det_uv > 0: + self.delta = degrees(acos(acosand)) + elif det_uv < 0: + self.delta = -degrees(acos(acosand)) + else: + if u1.real*u2.real + u1.imag*u2.imag > 0: + # u1 == u2 + self.delta = 0 + else: + # u1 == -u2 + # Note: This behavior disagrees with behavior documented in + # http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes + # where delta is set to 0 in this case. + self.delta = 180 + + if not self.sweep and self.delta >= 0: + self.delta -= 360 + elif self.large_arc and self.delta <= 0: + self.delta += 360 + + def point(self, t): + if t == 0: + return self.start + if t == 1: + return self.end + angle = radians(self.theta + t*self.delta) + cosphi = self.rot_matrix.real + sinphi = self.rot_matrix.imag + rx = self.radius.real + ry = self.radius.imag + + # z = self.rot_matrix*(rx*cos(angle) + 1j*ry*sin(angle)) + self.center + x = rx*cosphi*cos(angle) - ry*sinphi*sin(angle) + self.center.real + y = rx*sinphi*cos(angle) + ry*cosphi*sin(angle) + self.center.imag + return complex(x, y) + + def centeriso(self, z): + """This is an isometry that translates and rotates self so that it + is centered on the origin and has its axes aligned with the xy axes.""" + return (1/self.rot_matrix)*(z - self.center) + + def icenteriso(self, zeta): + """This is an isometry, the inverse of standardiso().""" + return self.rot_matrix*zeta + self.center + + def u1transform(self, z): + """This is an affine transformation (same as used in + self._parameterize()) that sends self to the unit circle.""" + zeta = (1/self.rot_matrix)*(z - self.center) # same as centeriso(z) + x, y = real(zeta), imag(zeta) + return x/self.radius.real + 1j*y/self.radius.imag + + def iu1transform(self, zeta): + """This is an affine transformation, the inverse of + self.u1transform().""" + x = real(zeta) + y = imag(zeta) + z = x*self.radius.real + y*self.radius.imag + return self.rot_matrix*z + self.center + + def length(self, t0=0, t1=1, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH): + """The length of an elliptical large_arc segment requires numerical + integration, and in that case it's simpler to just do a geometric + approximation, as for cubic bezier curves.""" + assert 0 <= t0 <= 1 and 0 <= t1 <= 1 + if _quad_available: + return quad(lambda tau: abs(self.derivative(tau)), t0, t1, + epsabs=error, limit=1000)[0] + else: + return segment_length(self, t0, t1, self.point(t0), self.point(t1), + error, min_depth, 0) + + def ilength(self, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, + error=ILENGTH_ERROR, min_depth=ILENGTH_MIN_DEPTH): + """Returns a float, t, such that self.length(0, t) is approximately s. + See the inv_arclength() docstring for more details.""" + return inv_arclength(self, s, s_tol=s_tol, maxits=maxits, error=error, + min_depth=min_depth) + + def joins_smoothly_with(self, previous, wrt_parameterization=False, + error=0): + """Checks if this segment joins smoothly with previous segment. By + default, this only checks that this segment starts moving (at t=0) in + the same direction (and from the same positive) as previous stopped + moving (at t=1). To check if the tangent magnitudes also match, set + wrt_parameterization=True.""" + if wrt_parameterization: + return self.start == previous.end and abs( + self.derivative(0) - previous.derivative(1)) <= error + else: + return self.start == previous.end and abs( + self.unit_tangent(0) - previous.unit_tangent(1)) <= error + + def derivative(self, t, n=1): + """returns the nth derivative of the segment at t.""" + angle = radians(self.theta + t*self.delta) + phi = radians(self.rotation) + rx = self.radius.real + ry = self.radius.imag + k = (self.delta*2*pi/360)**n # ((d/dt)angle)**n + + if n % 4 == 0 and n > 0: + return rx*cos(phi)*cos(angle) - ry*sin(phi)*sin(angle) + 1j*( + rx*sin(phi)*cos(angle) + ry*cos(phi)*sin(angle)) + elif n % 4 == 1: + return k*(-rx*cos(phi)*sin(angle) - ry*sin(phi)*cos(angle) + 1j*( + -rx*sin(phi)*sin(angle) + ry*cos(phi)*cos(angle))) + elif n % 4 == 2: + return k*(-rx*cos(phi)*cos(angle) + ry*sin(phi)*sin(angle) + 1j*( + -rx*sin(phi)*cos(angle) - ry*cos(phi)*sin(angle))) + elif n % 4 == 3: + return k*(rx*cos(phi)*sin(angle) + ry*sin(phi)*cos(angle) + 1j*( + rx*sin(phi)*sin(angle) - ry*cos(phi)*cos(angle))) + else: + raise ValueError("n should be a positive integer.") + + def unit_tangent(self, t): + """returns the unit tangent vector of the segment at t (centered at + the origin and expressed as a complex number).""" + dseg = self.derivative(t) + return dseg/abs(dseg) + + def normal(self, t): + """returns the (right hand rule) unit normal vector to self at t.""" + return -1j*self.unit_tangent(t) + + def curvature(self, t): + """returns the curvature of the segment at t.""" + return segment_curvature(self, t) + + def reversed(self): + """returns a copy of the Arc object with its orientation reversed.""" + return Arc(self.end, self.radius, self.rotation, self.large_arc, + not self.sweep, self.start) + + def phase2t(self, psi): + """Given phase -pi < psi <= pi, + returns the t value such that + exp(1j*psi) = self.u1transform(self.point(t)). + Note: This is non-trivial beca + """ + def _deg(rads, domain_lower_limit): + # Convert rads to degrees in [0, 360) domain + degs = degrees(rads % (2*pi)) + + # Convert to [domain_lower_limit, domain_lower_limit + 360) domain + k = domain_lower_limit // 360 + degs += k * 360 + if degs < domain_lower_limit: + degs += 360 + return degs + + if self.delta > 0: + degs = _deg(psi, domain_lower_limit=self.theta) + else: + degs = _deg(psi, domain_lower_limit=self.theta) + return (degs - self.theta)/self.delta + + + def intersect(self, other_seg, tol=1e-12): + """NOT IMPLEMENTED. Finds the intersections of two segments. + returns a list of tuples (t1, t2) such that + self.point(t1) == other_seg.point(t2). + Note: This will fail if the two segments coincide for more than a + finite collection of points.""" + + if is_bezier_segment(other_seg): + u1poly = self.u1transform(other_seg.poly()) + u1poly_mag2 = real(u1poly)**2 + imag(u1poly)**2 + t2s = polyroots01(u1poly_mag2 - 1) + t1s = [self.phase2t(phase(u1poly(t2))) for t2 in t2s] + return zip(t1s, t2s) + elif isinstance(other_seg, Arc): + # This could be made explicit to increase efficiency + longer_length = max(self.length(), other_seg.length()) + inters = bezier_intersections(self, other_seg, + longer_length=longer_length, + tol=tol, tol_deC=tol) + + # ad hoc fix for redundant solutions + if len(inters) > 2: + def keyfcn(tpair): + t1, t2 = tpair + return abs(self.point(t1) - other_seg.point(t2)) + inters.sort(key=keyfcn) + for idx in range(1, len(inters)-1): + if (abs(inters[idx][0] - inters[idx + 1][0]) + < abs(inters[idx][0] - inters[0][0])): + return [inters[0], inters[idx]] + else: + return [inters[0], inters[-1]] + return inters + else: + raise TypeError("other_seg should be a Arc, Line, " + "QuadraticBezier, or CubicBezier object.") + + def bbox(self): + """returns a bounding box for the segment in the form + (xmin, xmax, ymin, ymax).""" + # a(t) = radians(self.theta + self.delta*t) + # = (2*pi/360)*(self.theta + self.delta*t) + # x'=0: ~~~~~~~~~ + # -rx*cos(phi)*sin(a(t)) = ry*sin(phi)*cos(a(t)) + # -(rx/ry)*cot(phi)*tan(a(t)) = 1 + # a(t) = arctan(-(ry/rx)tan(phi)) + pi*k === atan_x + # y'=0: ~~~~~~~~~~ + # rx*sin(phi)*sin(a(t)) = ry*cos(phi)*cos(a(t)) + # (rx/ry)*tan(phi)*tan(a(t)) = 1 + # a(t) = arctan((ry/rx)*cot(phi)) + # atanres = arctan((ry/rx)*cot(phi)) === atan_y + # ~~~~~~~~ + # (2*pi/360)*(self.theta + self.delta*t) = atanres + pi*k + # Therfore, for both x' and y', we have... + # t = ((atan_{x/y} + pi*k)*(360/(2*pi)) - self.theta)/self.delta + # for all k s.t. 0 < t < 1 + from math import atan, tan + + if cos(self.phi) == 0: + atan_x = pi/2 + atan_y = 0 + elif sin(self.phi) == 0: + atan_x = 0 + atan_y = pi/2 + else: + rx, ry = self.radius.real, self.radius.imag + atan_x = atan(-(ry/rx)*tan(self.phi)) + atan_y = atan((ry/rx)/tan(self.phi)) + + def angle_inv(ang, k): # inverse of angle from Arc.derivative() + return ((ang + pi*k)*(360/(2*pi)) - self.theta)/self.delta + + xtrema = [self.start.real, self.end.real] + ytrema = [self.start.imag, self.end.imag] + + for k in range(-4, 5): + tx = angle_inv(atan_x, k) + ty = angle_inv(atan_y, k) + if 0 <= tx <= 1: + xtrema.append(self.point(tx).real) + if 0 <= ty <= 1: + ytrema.append(self.point(ty).imag) + xmin = max(xtrema) + return min(xtrema), max(xtrema), min(ytrema), max(ytrema) + + + def split(self, t): + """returns two segments, whose union is this segment and which join + at self.point(t).""" + return self.cropped(0, t), self.cropped(t, 1) + + def cropped(self, t0, t1): + """returns a cropped copy of this segment which starts at + self.point(t0) and ends at self.point(t1).""" + if abs(self.delta*(t1 - t0)) <= 180: + new_large_arc = 0 + else: + new_large_arc = 1 + return Arc(self.point(t0), radius=self.radius, rotation=self.rotation, + large_arc=new_large_arc, sweep=self.sweep, + end=self.point(t1), autoscale_radius=self.autoscale_radius) + + def radialrange(self, origin, return_all_global_extrema=False): + """returns the tuples (d_min, t_min) and (d_max, t_max) which minimize + and maximize, respectively, the distance, + d = |self.point(t)-origin|.""" + + u1orig = self.u1transform(origin) + if abs(u1orig) == 1: # origin lies on ellipse + t = self.phase2t(phase(u1orig)) + d_min = 0 + + # Transform to a coordinate system where the ellipse is centered + # at the origin and its axes are horizontal/vertical + zeta0 = self.centeriso(origin) + a, b = self.radius.real, self.radius.imag + x0, y0 = zeta0.real, zeta0.imag + + # Find t s.t. z'(t) + a2mb2 = (a**2 - b**2) + if u1orig.imag: # x != x0 + + coeffs = [a2mb2**2, + 2*a2mb2*b**2*y0, + (-a**4 + (2*a**2 - b**2 + y0**2)*b**2 + x0**2)*b**2, + -2*a2mb2*b**4*y0, + -b**6*y0**2] + ys = polyroots(coeffs, realroots=True, + condition=lambda r: -b <= r <= b) + xs = (a*sqrt(1 - y**2/b**2) for y in ys) + + + + ts = [self.phase2t(phase(self.u1transform(self.icenteriso( + complex(x, y))))) for x, y in zip(xs, ys)] + + else: # This case is very similar, see notes and assume instead y0!=y + b2ma2 = (b**2 - a**2) + coeffs = [b2ma2**2, + 2*b2ma2*a**2*x0, + (-b**4 + (2*b**2 - a**2 + x0**2)*a**2 + y0**2)*a**2, + -2*b2ma2*a**4*x0, + -a**6*x0**2] + xs = polyroots(coeffs, realroots=True, + condition=lambda r: -a <= r <= a) + ys = (b*sqrt(1 - x**2/a**2) for x in xs) + + ts = [self.phase2t(phase(self.u1transform(self.icenteriso( + complex(x, y))))) for x, y in zip(xs, ys)] + + raise _NotImplemented4ArcException + + def rotated(self, degs, origin=None): + """Returns a copy of self rotated by `degs` degrees (CCW) around the + point `origin` (a complex number). By default `origin` is either + `self.point(0.5)`, or in the case that self is an Arc object, + `origin` defaults to `self.center`.""" + return rotate(self, degs, origin=self.center) + + def translated(self, z0): + """Returns a copy of self shifted by the complex quantity `z0` such + that self.translated(z0).point(t) = self.point(t) + z0 for any t.""" + return translate(self, z0) + + +def is_bezier_segment(x): + return (isinstance(x, Line) or + isinstance(x, QuadraticBezier) or + isinstance(x, CubicBezier)) + + +def is_path_segment(x): + return is_bezier_segment(x) or isinstance(x, Arc) + + +class Path(MutableSequence): + """A Path is a sequence of path segments""" + + # Put it here, so there is a default if unpickled. + _closed = False + _start = None + _end = None + + def __init__(self, *segments, **kw): + self._segments = list(segments) + self._length = None + self._lengths = None + if 'closed' in kw: + self.closed = kw['closed'] # DEPRECATED + if self._segments: + self._start = self._segments[0].start + self._end = self._segments[-1].end + else: + self._start = None + self._end = None + + def __getitem__(self, index): + return self._segments[index] + + def __setitem__(self, index, value): + self._segments[index] = value + self._length = None + self._start = self._segments[0].start + self._end = self._segments[-1].end + + def __delitem__(self, index): + del self._segments[index] + self._length = None + self._start = self._segments[0].start + self._end = self._segments[-1].end + + def __iter__(self): + return self._segments.__iter__() + + def __contains__(self, x): + return self._segments.__contains__(x) + + def insert(self, index, value): + self._segments.insert(index, value) + self._length = None + self._start = self._segments[0].start + self._end = self._segments[-1].end + + def reversed(self): + """returns a copy of the Path object with its orientation reversed.""" + newpath = [seg.reversed() for seg in self] + newpath.reverse() + return Path(*newpath) + + def __len__(self): + return len(self._segments) + + def __repr__(self): + return "Path({})".format( + ",\n ".join(repr(x) for x in self._segments)) + + def __eq__(self, other): + if not isinstance(other, Path): + return NotImplemented + if len(self) != len(other): + return False + for s, o in zip(self._segments, other._segments): + if not s == o: + return False + return True + + def __ne__(self, other): + if not isinstance(other, Path): + return NotImplemented + return not self == other + + def _calc_lengths(self, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH): + if self._length is not None: + return + + lengths = [each.length(error=error, min_depth=min_depth) for each in + self._segments] + self._length = sum(lengths) + self._lengths = [each/self._length for each in lengths] + + def point(self, pos): + + # Shortcuts + if pos == 0.0: + return self._segments[0].point(pos) + if pos == 1.0: + return self._segments[-1].point(pos) + + self._calc_lengths() + # Find which segment the point we search for is located on: + segment_start = 0 + for index, segment in enumerate(self._segments): + segment_end = segment_start + self._lengths[index] + if segment_end >= pos: + # This is the segment! How far in on the segment is the point? + segment_pos = (pos - segment_start)/( + segment_end - segment_start) + return segment.point(segment_pos) + segment_start = segment_end + + def length(self, T0=0, T1=1, error=LENGTH_ERROR, min_depth=LENGTH_MIN_DEPTH): + self._calc_lengths(error=error, min_depth=min_depth) + if T0 == 0 and T1 == 1: + return self._length + else: + if len(self) == 1: + return self[0].length(t0=T0, t1=T1) + idx0, t0 = self.T2t(T0) + idx1, t1 = self.T2t(T1) + if idx0 == idx1: + return self[idx0].length(t0=t0, t1=t1) + return (self[idx0].length(t0=t0) + + sum(self[idx].length() for idx in range(idx0 + 1, idx1)) + + self[idx1].length(t1=t1)) + + def ilength(self, s, s_tol=ILENGTH_S_TOL, maxits=ILENGTH_MAXITS, + error=ILENGTH_ERROR, min_depth=ILENGTH_MIN_DEPTH): + """Returns a float, t, such that self.length(0, t) is approximately s. + See the inv_arclength() docstring for more details.""" + return inv_arclength(self, s, s_tol=s_tol, maxits=maxits, error=error, + min_depth=min_depth) + + def iscontinuous(self): + """Checks if a path is continuous with respect to its + parameterization.""" + return all(self[i].end == self[i+1].start for i in range(len(self) - 1)) + + def continuous_subpaths(self): + """Breaks self into its continuous components, returning a list of + continuous subpaths. + I.e. + (all(subpath.iscontinuous() for subpath in self.continuous_subpaths()) + and self == concatpaths(self.continuous_subpaths())) + ) + """ + subpaths = [] + subpath_start = 0 + for i in range(len(self) - 1): + if self[i].end != self[(i+1) % len(self)].start: + subpaths.append(Path(*self[subpath_start: i+1])) + subpath_start = i+1 + subpaths.append(Path(*self[subpath_start: len(self)])) + return subpaths + + def isclosed(self): + """This function determines if a connected path is closed.""" + assert len(self) != 0 + assert self.iscontinuous() + return self.start == self.end + + def isclosedac(self): + assert len(self) != 0 + return self.start == self.end + + def _is_closable(self): + end = self[-1].end + for segment in self: + if segment.start == end: + return True + return False + + @property + def closed(self, warning_on=CLOSED_WARNING_ON): + """The closed attribute is deprecated, please use the isclosed() + method instead. See _closed_warning for more information.""" + mes = ("This attribute is deprecated, consider using isclosed() " + "method instead.\n\nThis attribute is kept for compatibility " + "with scripts created using svg.path (v2.0). You can prevent " + "this warning in the future by setting " + "CLOSED_WARNING_ON=False.") + if warning_on: + warn(mes) + return self._closed and self._is_closable() + + @closed.setter + def closed(self, value): + value = bool(value) + if value and not self._is_closable(): + raise ValueError("End does not coincide with a segment start.") + self._closed = value + + @property + def start(self): + if not self._start: + self._start = self._segments[0].start + return self._start + + @start.setter + def start(self, pt): + self._start = pt + self._segments[0].start = pt + + @property + def end(self): + if not self._end: + self._end = self._segments[-1].end + return self._end + + @end.setter + def end(self, pt): + self._end = pt + self._segments[-1].end = pt + + def d(self, useSandT=False, use_closed_attrib=False): + """Returns a path d-string for the path object. + For an explanation of useSandT and use_closed_attrib, see the + compatibility notes in the README.""" + + if use_closed_attrib: + self_closed = self.closed(warning_on=False) + if self_closed: + segments = self[:-1] + else: + segments = self[:] + else: + self_closed = False + segments = self[:] + + current_pos = None + parts = [] + previous_segment = None + end = self[-1].end + + for segment in segments: + seg_start = segment.start + # If the start of this segment does not coincide with the end of + # the last segment or if this segment is actually the close point + # of a closed path, then we should start a new subpath here. + if current_pos != seg_start or \ + (self_closed and seg_start == end and use_closed_attrib): + parts.append('M {},{}'.format(seg_start.real, seg_start.imag)) + + if isinstance(segment, Line): + args = segment.end.real, segment.end.imag + parts.append('L {},{}'.format(*args)) + elif isinstance(segment, CubicBezier): + if useSandT and segment.is_smooth_from(previous_segment, + warning_on=False): + args = (segment.control2.real, segment.control2.imag, + segment.end.real, segment.end.imag) + parts.append('S {},{} {},{}'.format(*args)) + else: + args = (segment.control1.real, segment.control1.imag, + segment.control2.real, segment.control2.imag, + segment.end.real, segment.end.imag) + parts.append('C {},{} {},{} {},{}'.format(*args)) + elif isinstance(segment, QuadraticBezier): + if useSandT and segment.is_smooth_from(previous_segment, + warning_on=False): + args = segment.end.real, segment.end.imag + parts.append('T {},{}'.format(*args)) + else: + args = (segment.control.real, segment.control.imag, + segment.end.real, segment.end.imag) + parts.append('Q {},{} {},{}'.format(*args)) + + elif isinstance(segment, Arc): + args = (segment.radius.real, segment.radius.imag, + segment.rotation,int(segment.large_arc), + int(segment.sweep),segment.end.real, segment.end.imag) + parts.append('A {},{} {} {:d},{:d} {},{}'.format(*args)) + current_pos = segment.end + previous_segment = segment + + if self_closed: + parts.append('Z') + + return ' '.join(parts) + + def joins_smoothly_with(self, previous, wrt_parameterization=False): + """Checks if this Path object joins smoothly with previous + path/segment. By default, this only checks that this Path starts + moving (at t=0) in the same direction (and from the same positive) as + previous stopped moving (at t=1). To check if the tangent magnitudes + also match, set wrt_parameterization=True.""" + if wrt_parameterization: + return self[0].start == previous.end and self.derivative( + 0) == previous.derivative(1) + else: + return self[0].start == previous.end and self.unit_tangent( + 0) == previous.unit_tangent(1) + + def T2t(self, T): + """returns the segment index, seg_idx, and segment parameter, t, + corresponding to the path parameter T. In other words, this is the + inverse of the Path.t2T() method.""" + if T == 1: + return len(self)-1, 1 + if T == 0: + return 0, 0 + self._calc_lengths() + # Find which segment self.point(T) falls on: + T0 = 0 # the T-value the current segment starts on + for seg_idx, seg_length in enumerate(self._lengths): + T1 = T0 + seg_length # the T-value the current segment ends on + if T1 >= T: + # This is the segment! + t = (T - T0)/seg_length + return seg_idx, t + T0 = T1 + + assert 0 <= T <= 1 + raise BugException + + def t2T(self, seg, t): + """returns the path parameter T which corresponds to the segment + parameter t. In other words, for any Path object, path, and any + segment in path, seg, T(t) = path.t2T(seg, t) is the unique + reparameterization such that path.point(T(t)) == seg.point(t) for all + 0 <= t <= 1. + Input Note: seg can be a segment in the Path object or its + corresponding index.""" + self._calc_lengths() + # Accept an index or a segment for seg + if isinstance(seg, int): + seg_idx = seg + else: + try: + seg_idx = self.index(seg) + except ValueError: + assert is_path_segment(seg) or isinstance(seg, int) + raise + + segment_start = sum(self._lengths[:seg_idx]) + segment_end = segment_start + self._lengths[seg_idx] + T = (segment_end - segment_start)*t + segment_start + return T + + def derivative(self, T, n=1): + """returns the tangent vector of the Path at T (centered at the origin + and expressed as a complex number). + Note: Bezier curves can have points where their derivative vanishes. + If you are interested in the tangent direction, use unit_tangent() + method instead.""" + seg_idx, t = self.T2t(T) + seg = self._segments[seg_idx] + return seg.derivative(t, n=n)/seg.length()**n + + def unit_tangent(self, T): + """returns the unit tangent vector of the Path at T (centered at the + origin and expressed as a complex number). If the tangent vector's + magnitude is zero, this method will find the limit of + self.derivative(tau)/abs(self.derivative(tau)) as tau approaches T.""" + seg_idx, t = self.T2t(T) + return self._segments[seg_idx].unit_tangent(t) + + def normal(self, t): + """returns the (right hand rule) unit normal vector to self at t.""" + return -1j*self.unit_tangent(t) + + def curvature(self, T): + """returns the curvature of this Path object at T and outputs + float('inf') if not differentiable at T.""" + seg_idx, t = self.T2t(T) + seg = self[seg_idx] + if np.isclose(t, 0) and (seg_idx != 0 or self.isclosed()): + previous_seg_in_path = self._segments[ + (seg_idx - 1) % len(self._segments)] + if not seg.joins_smoothl_with(previous_seg_in_path): + return float('inf') + elif np.isclose(t, 1) and (seg_idx != len(self) - 1 or self.isclosed()): + next_seg_in_path = self._segments[ + (seg_idx + 1) % len(self._segments)] + if not next_seg_in_path.joins_smoothly_with(seg): + return float('inf') + dz = self.derivative(t) + ddz = self.derivative(t, n=2) + dx, dy = dz.real, dz.imag + ddx, ddy = ddz.real, ddz.imag + return abs(dx*ddy - dy*ddx)/(dx*dx + dy*dy)**1.5 + + def area(self): + """returns the area enclosed by this Path object. + Note: negative area results from CW (as opposed to CCW) + parameterization of the Path object.""" + assert self.isclosed() + area_enclosed = 0 + for seg in self: + x = real(seg.poly()) + dy = imag(seg.poly()).deriv() + integrand = x*dy + integral = integrand.integ() + area_enclosed += integral(1) - integral(0) + return area_enclosed + + def intersect(self, other_curve, justonemode=False, tol=1e-12): + """returns list of pairs of pairs ((T1, seg1, t1), (T2, seg2, t2)) + giving the intersection points. + If justonemode==True, then returns just the first + intersection found. + tol is used to check for redundant intersections (see comment above + the code block where tol is used). + Note: If the two path objects coincide for more than a finite set of + points, this code will fail.""" + path1 = self + if isinstance(other_curve, Path): + path2 = other_curve + else: + path2 = Path(other_curve) + assert path1 != path2 + intersection_list = [] + for seg1 in path1: + for seg2 in path2: + if justonemode and intersection_list: + return intersection_list[0] + for t1, t2 in seg1.intersect(seg2, tol=tol): + T1 = path1.t2T(seg1, t1) + T2 = path2.t2T(seg2, t2) + intersection_list.append(((T1, seg1, t1), (T2, seg2, t2))) + if justonemode and intersection_list: + return intersection_list[0] + + # Note: If the intersection takes place at a joint (point one seg ends + # and next begins in path) then intersection_list may contain a + # redundant intersection. This code block checks for and removes said + # redundancies. + if intersection_list: + pts = [seg1.point(_t1) for _T1, _seg1, _t1 in zip(*intersection_list)[0]] + indices2remove = [] + for ind1 in range(len(pts)): + for ind2 in range(ind1 + 1, len(pts)): + if abs(pts[ind1] - pts[ind2]) < tol: + # then there's a redundancy. Remove it. + indices2remove.append(ind2) + intersection_list = [inter for ind, inter in + enumerate(intersection_list) if + ind not in indices2remove] + return intersection_list + + def bbox(self): + """returns a bounding box for the input Path object in the form + (xmin, xmax, ymin, ymax).""" + bbs = [seg.bbox() for seg in self._segments] + xmins, xmaxs, ymins, ymaxs = zip(*bbs) + xmin = min(xmins) + xmax = max(xmaxs) + ymin = min(ymins) + ymax = max(ymaxs) + return xmin, xmax, ymin, ymax + + def cropped(self, T0, T1): + """returns a cropped copy of the path.""" + assert T0 != T1 + if T1 == 1: + seg1 = self[-1] + t_seg1 = 1 + i1 = len(self) - 1 + else: + seg1_idx, t_seg1 = self.T2t(T1) + seg1 = self[seg1_idx] + if np.isclose(t_seg1, 0): + i1 = (self.index(seg1) - 1) % len(self) + seg1 = self[i1] + t_seg1 = 1 + else: + i1 = self.index(seg1) + if T0 == 0: + seg0 = self[0] + t_seg0 = 0 + i0 = 0 + else: + seg0_idx, t_seg0 = self.T2t(T0) + seg0 = self[seg0_idx] + if np.isclose(t_seg0, 1): + i0 = (self.index(seg0) + 1) % len(self) + seg0 = self[i0] + t_seg0 = 0 + else: + i0 = self.index(seg0) + + if T0 < T1 and i0 == i1: + new_path = Path(seg0.cropped(t_seg0, t_seg1)) + else: + new_path = Path(seg0.cropped(t_seg0, 1)) + + # T1 global_max[0]: + global_max = seg_global_max + (seg_idx,) + return global_min, global_max + + def rotated(self, degs, origin=None): + """Returns a copy of self rotated by `degs` degrees (CCW) around the + point `origin` (a complex number). By default `origin` is either + `self.point(0.5)`, or in the case that self is an Arc object, + `origin` defaults to `self.center`.""" + return rotate(self, degs, origin=self.point(0.5)) + + def translated(self, z0): + """Returns a copy of self shifted by the complex quantity `z0` such + that self.translated(z0).point(t) = self.point(t) + z0 for any t.""" + return translate(self, z0) diff --git a/svgpathtools/paths2svg.py b/svgpathtools/paths2svg.py new file mode 100644 index 0000000..0ec0f9a --- /dev/null +++ b/svgpathtools/paths2svg.py @@ -0,0 +1,379 @@ +"""This submodule contains tools for creating svg files from paths and path +segments.""" + +# External dependencies: +from __future__ import division, absolute_import, print_function +from math import ceil +from os import getcwd, path as os_path, makedirs +from xml.dom.minidom import parse as md_xml_parse +from svgwrite import Drawing, text as txt +from time import time +from warnings import warn + +# Internal dependencies +from .path import Path, Line, is_path_segment +from .misctools import open_in_browser + +# Used to convert a string colors (identified by single chars) to a list. +color_dict = {'a': 'aqua', + 'b': 'blue', + 'c': 'cyan', + 'd': 'darkblue', + 'e': '', + 'f': '', + 'g': 'green', + 'h': '', + 'i': '', + 'j': '', + 'k': 'black', + 'l': 'lime', + 'm': 'magenta', + 'n': 'brown', + 'o': 'orange', + 'p': 'pink', + 'q': 'turquoise', + 'r': 'red', + 's': 'salmon', + 't': 'tan', + 'u': 'purple', + 'v': 'violet', + 'w': 'white', + 'x': '', + 'y': 'yellow', + 'z': 'azure'} + + +def str2colorlist(s, default_color=None): + color_list = [color_dict[ch] for ch in s] + if default_color: + for idx, c in enumerate(color_list): + if not c: + color_list[idx] = default_color + return color_list + + +def is3tuple(c): + return isinstance(c, tuple) and len(c) == 3 + + +def big_bounding_box(paths_n_stuff): + """Finds a BB containing a collection of paths, Bezier path segments, and + points (given as complex numbers).""" + bbs = [] + for thing in paths_n_stuff: + if is_path_segment(thing) or isinstance(thing, Path): + bbs.append(thing.bbox()) + elif isinstance(thing, complex): + bbs.append((thing.real, thing.real, thing.imag, thing.imag)) + else: + try: + complexthing = complex(thing) + bbs.append((complexthing.real, complexthing.real, + complexthing.imag, complexthing.imag)) + except ValueError: + raise TypeError( + "paths_n_stuff can only contains Path, CubicBezier, " + "QuadraticBezier, Line, and complex objects.") + xmins, xmaxs, ymins, ymaxs = zip(*bbs) + xmin = min(xmins) + xmax = max(xmaxs) + ymin = min(ymins) + ymax = max(ymaxs) + return xmin, xmax, ymin, ymax + + +def disvg(paths=None, colors=None, + filename=os_path.join(getcwd(), 'disvg_output.svg'), + stroke_widths=None, nodes=None, node_colors=None, node_radii=None, + openinbrowser=True, timestamp=False, + margin_size=0.1, mindim=600, dimensions=None, + viewbox=None, text=None, text_path=None, font_size=None, + attributes=None): + """Takes in a list of paths and creates an SVG file containing said paths. + REQUIRED INPUTS: + :param paths - a list of paths + + OPTIONAL INPUT: + :param colors - specifies the path stroke color. By default all paths + will be black (#000000). This paramater can be input in a few ways + 1) a list of strings that will be input into the path elements stroke + attribute (so anything that is understood by the svg viewer). + 2) a string of single character colors -- e.g. setting colors='rrr' is + equivalent to setting colors=['red', 'red', 'red'] (see the + 'color_dict' dictionary above for a list of possibilities). + 3) a list of rgb 3-tuples -- e.g. colors = [(255, 0, 0), ...]. + + :param filename - the desired location/filename of the SVG file + created (by default the SVG will be stored in the current working + directory and named 'disvg_output.svg'). + + :param stroke_widths - a list of stroke_widths to use for paths + (default is 0.5% of the SVG's width or length) + + :param nodes - a list of points to draw as filled-in circles + + :param node_colors - a list of colors to use for the nodes (by default + nodes will be red) + + :param node_radii - a list of radii to use for the nodes (by default + nodes will be radius will be 1 percent of the svg's width/length) + + :param text - string or list of strings to be displayed + + :param text_path - if text is a list, then this should be a list of + path (or path segments of the same length. Note: the path must be + long enough to display the text or the text will be cropped by the svg + viewer. + + :param font_size - a single float of list of floats. + + :param openinbrowser - Set to True to automatically open the created + SVG in the user's default web browser. + + :param timestamp - if True, then the a timestamp will be appended to + the output SVG's filename. This will fix issues with rapidly opening + multiple SVGs in your browser. + + :param margin_size - The min margin (empty area framing the collection + of paths) size used for creating the canvas and background of the SVG. + + :param mindim - The minimum dimension (height or width) of the output + SVG (default is 600). + + :param dimensions - The display dimensions of the output SVG. Using + this will override the mindim parameter. + + :param viewbox - This specifies what rectangular patch of R^2 will be + viewable through the outputSVG. It should be input in the form + (min_x, min_y, width, height). This is different from the display + dimension of the svg, which can be set through mindim or dimensions. + + :param attributes - a dictionary of attributes for the input paths. + This will override any other conflicting settings. + + NOTES: + -The unit of length here is assumed to be pixels in all variables. + + -If this function is used multiple times in quick succession to + display multiple SVGs (all using the default filename), the + svgviewer/browser will likely fail to load some of the SVGs in time. + To fix this, use the timestamp attribute, or give the files unique + names, or use a pause command (e.g. time.sleep(1)) between uses. + """ + + _default_relative_node_radius = 5e-3 + _default_relative_stroke_width = 1e-3 + _default_path_color = '#000000' # black + _default_node_color = '#ff0000' # red + _default_font_size = 12 + + # append directory to filename (if not included) + if os_path.dirname(filename) == '': + filename = os_path.join(getcwd(), filename) + + # append time stamp to filename + if timestamp: + fbname, fext = os_path.splitext(filename) + dirname = os_path.dirname(filename) + tstamp = str(time()).replace('.', '') + stfilename = os_path.split(fbname)[1] + '_' + tstamp + fext + filename = os_path.join(dirname, stfilename) + + # check paths and colors are set + if isinstance(paths, Path) or is_path_segment(paths): + paths = [paths] + if paths: + if not colors: + colors = [_default_path_color] * len(paths) + else: + assert len(colors) == len(paths) + if isinstance(colors, str): + colors = str2colorlist(colors, + default_color=_default_path_color) + elif isinstance(colors, list): + for idx, c in enumerate(colors): + if is3tuple(c): + colors[idx] = "rgb" + str(c) + + # check nodes and nodes_colors are set (node_radii are set later) + if nodes: + if not node_colors: + node_colors = [_default_node_color] * len(nodes) + else: + assert len(node_colors) == len(nodes) + if isinstance(node_colors, str): + node_colors = str2colorlist(node_colors, + default_color=_default_node_color) + elif isinstance(node_colors, list): + for idx, c in enumerate(node_colors): + if is3tuple(c): + node_colors[idx] = "rgb" + str(c) + + # set up the viewBox and display dimensions of the output SVG + # along the way, set stroke_widths and node_radii if not provided + assert paths or nodes + stuff2bound = [] + if viewbox: + szx, szy = viewbox[2:4] + else: + if paths: + stuff2bound += paths + if nodes: + stuff2bound += nodes + if text_path: + stuff2bound += text_path + xmin, xmax, ymin, ymax = big_bounding_box(stuff2bound) + dx = xmax - xmin + dy = ymax - ymin + + if dx == 0: + dx = 1 + if dy == 0: + dy = 1 + + # determine stroke_widths to use (if not provided) and max_stroke_width + if paths: + if not stroke_widths: + sw = max(dx, dy) * _default_relative_stroke_width + stroke_widths = [sw]*len(paths) + max_stroke_width = sw + else: + assert len(paths) == len(stroke_widths) + max_stroke_width = max(stroke_widths) + else: + max_stroke_width = 0 + + # determine node_radii to use (if not provided) and max_node_diameter + if nodes: + if not node_radii: + r = max(dx, dy) * _default_relative_node_radius + node_radii = [r]*len(nodes) + max_node_diameter = 2*r + else: + assert len(nodes) == len(node_radii) + max_node_diameter = 2*max(node_radii) + else: + max_node_diameter = 0 + + extra_space_for_style = max(max_stroke_width, max_node_diameter) + xmin -= margin_size*dx + extra_space_for_style/2 + ymin -= margin_size*dy + extra_space_for_style/2 + dx += 2*margin_size*dx + extra_space_for_style + dy += 2*margin_size*dy + extra_space_for_style + viewbox = "%s %s %s %s" % (xmin, ymin, dx, dy) + if dimensions: + szx, szy = dimensions + else: + if dx > dy: + szx = str(mindim) + 'px' + szy = str(int(ceil(mindim * dy / dx))) + 'px' + else: + szx = str(int(ceil(mindim * dx / dy))) + 'px' + szy = str(mindim) + 'px' + + # Create an SVG file + dwg = Drawing(filename=filename, size=(szx, szy), viewBox=viewbox) + + # add paths + if paths: + for i, p in enumerate(paths): + if isinstance(p, Path): + ps = p.d() + elif is_path_segment(p): + ps = Path(p).d() + else: # assume this path, p, was input as a Path d-string + ps = p + + if attributes: + good_attribs = {'d': ps} + for key in attributes[i]: + val = attributes[i][key] + if key != 'd': + try: + dwg.path(ps, **{key: val}) + good_attribs.update({key: val}) + except Exception as e: + warn(str(e)) + + dwg.add(dwg.path(**good_attribs)) + else: + dwg.add(dwg.path(ps, stroke=colors[i], + stroke_width=str(stroke_widths[i]), + fill='none')) + + # add nodes (filled in circles) + if nodes: + for i_pt, pt in enumerate([(z.real, z.imag) for z in nodes]): + dwg.add(dwg.circle(pt, node_radii[i_pt], fill=node_colors[i_pt])) + + # add texts + if text: + assert isinstance(text, str) or (isinstance(text, list) and + isinstance(text_path, list) and + len(text_path) == len(text)) + if isinstance(text, str): + text = [text] + if not font_size: + font_size = [_default_font_size] + if not text_path: + pos = complex(xmin + margin_size*dx, ymin + margin_size*dy) + text_path = [Line(pos, pos + 1).d()] + else: + if font_size: + if isinstance(font_size, list): + assert len(font_size) == len(text) + else: + font_size = [font_size] * len(text) + else: + font_size = [_default_font_size] * len(text) + for idx, s in enumerate(text): + p = text_path[idx] + if isinstance(p, Path): + ps = p.d() + elif is_path_segment(p): + ps = Path(p).d() + else: # assume this path, p, was input as a Path d-string + ps = p + + # paragraph = dwg.add(dwg.g(font_size=font_size[idx])) + # paragraph.add(dwg.textPath(ps, s)) + pathid = 'tp' + str(idx) + dwg.defs.add(dwg.path(d=ps, id=pathid)) + txter = dwg.add(dwg.text('', font_size=font_size[idx])) + txter.add(txt.TextPath('#'+pathid, s)) + + # save svg + if not os_path.exists(os_path.dirname(filename)): + makedirs(os_path.dirname(filename)) + dwg.save() + + # re-open the svg, make the xml pretty, and save it again + xmlstring = md_xml_parse(filename).toprettyxml() + with open(filename, 'w') as f: + f.write(xmlstring) + + # try to open in web browser + if openinbrowser: + try: + open_in_browser(filename) + except: + print("Failed to open output SVG in browser. SVG saved to:") + print(filename) + + +def wsvg(paths=None, colors=None, + filename=os_path.join(getcwd(), 'disvg_output.svg'), + stroke_widths=None, nodes=None, node_colors=None, node_radii=None, + openinbrowser=False, timestamp=False, + margin_size=0.1, mindim=600, dimensions=None, + viewbox=None, text=None, text_path=None, font_size=None, + attributes=None): + """Convenience function; identical to disvg() except that + openinbrowser=False by default. See disvg() docstring for more info.""" + disvg(paths, colors=colors, filename=filename, + stroke_widths=stroke_widths, nodes=nodes, + node_colors=node_colors, node_radii=node_radii, + openinbrowser=openinbrowser, timestamp=timestamp, + margin_size=margin_size, mindim=mindim, dimensions=dimensions, + viewbox=viewbox, text=text, text_path=text_path, + font_size=font_size, attributes=attributes) diff --git a/svgpathtools/pathtools.py b/svgpathtools/pathtools.py new file mode 100644 index 0000000..2e78452 --- /dev/null +++ b/svgpathtools/pathtools.py @@ -0,0 +1,14 @@ +"""This submodule contains additional tools for working with paths composed of +Line and CubicBezier objects. QuadraticBezier and Arc objects are only +partially supported.""" + +# External dependencies: +from __future__ import division, absolute_import, print_function + +# Internal dependencies +from .path import Path, Line, QuadraticBezier, CubicBezier, Arc + + +# Misc######################################################################### + + diff --git a/svgpathtools/polytools.py b/svgpathtools/polytools.py new file mode 100644 index 0000000..3fbdc22 --- /dev/null +++ b/svgpathtools/polytools.py @@ -0,0 +1,80 @@ +"""This submodule contains tools for working with numpy.poly1d objects.""" + +# External Dependencies +from __future__ import division, absolute_import +from itertools import combinations +import numpy as np + +# Internal Dependencies +from .misctools import isclose + + +def polyroots(p, realroots=False, condition=lambda r: True): + """ + Returns the roots of a polynomial with coefficients given in p. + p[0] * x**n + p[1] * x**(n-1) + ... + p[n-1]*x + p[n] + INPUT: + p - Rank-1 array-like object of polynomial coefficients. + realroots - a boolean. If true, only real roots will be returned and the + condition function can be written assuming all roots are real. + condition - a boolean-valued function. Only roots satisfying this will be + returned. If realroots==True, these conditions should assume the roots + are real. + OUTPUT: + A list containing the roots of the polynomial. + NOTE: This uses np.isclose and np.roots""" + roots = np.roots(p) + if realroots: + roots = [r.real for r in roots if isclose(r.imag, 0)] + roots = [r for r in roots if condition(r)] + + duplicates = [] + for idx, (r1, r2) in enumerate(combinations(roots, 2)): + if isclose(r1, r2): + duplicates.append(idx) + return [r for idx, r in enumerate(roots) if idx not in duplicates] + + +def polyroots01(p): + """Returns the real roots between 0 and 1 of the polynomial with + coefficients given in p, + p[0] * x**n + p[1] * x**(n-1) + ... + p[n-1]*x + p[n] + p can also be a np.poly1d object. See polyroots for more information.""" + return polyroots(p, realroots=True, condition=lambda tval: 0 <= tval <= 1) + + +def rational_limit(f, g, t0): + """Computes the limit of the rational function (f/g)(t) + as t approaches t0.""" + assert isinstance(f, np.poly1d) and isinstance(g, np.poly1d) + assert g != np.poly1d([0]) + if g(t0) != 0: + return f(t0)/g(t0) + elif f(t0) == 0: + return rational_limit(f.deriv(), g.deriv(), t0) + else: + raise ValueError("Limit does not exist.") + + +def real(z): + try: + return np.poly1d(z.coeffs.real) + except AttributeError: + return z.real + + +def imag(z): + try: + return np.poly1d(z.coeffs.imag) + except AttributeError: + return z.imag + + +def poly_real_part(poly): + """Deprecated.""" + return np.poly1d(poly.coeffs.real) + + +def poly_imag_part(poly): + """Deprecated.""" + return np.poly1d(poly.coeffs.imag) diff --git a/svgpathtools/smoothing.py b/svgpathtools/smoothing.py new file mode 100644 index 0000000..424ed99 --- /dev/null +++ b/svgpathtools/smoothing.py @@ -0,0 +1,201 @@ +"""This submodule contains functions related to smoothing paths of Bezier +curves.""" + +# External Dependencies +from __future__ import division, absolute_import, print_function + +# Internal Dependencies +from .path import Path, CubicBezier, Line +from .misctools import isclose +from .paths2svg import disvg + + +def is_differentiable(path, tol=1e-8): + for idx in range(len(path)): + u = path[(idx-1) % len(path)].unit_tangent(1) + v = path[idx].unit_tangent(0) + u_dot_v = u.real*v.real + u.imag*v.imag + if abs(u_dot_v - 1) > tol: + return False + return True + + +def kinks(path, tol=1e-8): + """returns indices of segments that start on a non-differentiable joint.""" + kink_list = [] + for idx in xrange(len(path)): + if idx == 0 and not path.isclosed(): + continue + try: + u = path[(idx - 1) % len(path)].unit_tangent(1) + v = path[idx].unit_tangent(0) + u_dot_v = u.real*v.real + u.imag*v.imag + flag = False + except ValueError: + flag = True + + if flag or abs(u_dot_v - 1) > tol: + kink_list.append(idx) + return kink_list + + +def _report_unfixable_kinks(_path, _kink_list): + mes = ("\n%s kinks have been detected at that cannot be smoothed.\n" + "To ignore these kinks and fix all others, run this function " + "again with the second argument 'ignore_unfixable_kinks=True' " + "The locations of the unfixable kinks are at the beginnings of " + "segments: %s" % (len(_kink_list), _kink_list)) + disvg(_path, nodes=[_path[idx].start for idx in _kink_list]) + raise Exception(mes) + + +def smoothed_joint(seg0, seg1, maxjointsize=3, tightness=1.99): + """ See Andy's notes on + Smoothing Bezier Paths for an explanation of the method. + Input: two segments seg0, seg1 such that seg0.end==seg1.start, and + jointsize, a positive number + + Output: seg0_trimmed, elbow, seg1_trimmed, where elbow is a cubic bezier + object that smoothly connects seg0_trimmed and seg1_trimmed. + + """ + assert seg0.end == seg1.start + assert 0 < maxjointsize + assert 0 < tightness < 2 +# sgn = lambda x:x/abs(x) + q = seg0.end + + try: v = seg0.unit_tangent(1) + except: v = seg0.unit_tangent(1 - 1e-4) + try: w = seg1.unit_tangent(0) + except: w = seg1.unit_tangent(1e-4) + + max_a = maxjointsize / 2 + a = min(max_a, min(seg1.length(), seg0.length()) / 20) + if isinstance(seg0, Line) and isinstance(seg1, Line): + ''' + Note: Letting + c(t) = elbow.point(t), v= the unit tangent of seg0 at 1, w = the + unit tangent vector of seg1 at 0, + Q = seg0.point(1) = seg1.point(0), and a,b>0 some constants. + The elbow will be the unique CubicBezier, c, such that + c(0)= Q-av, c(1)=Q+aw, c'(0) = bv, and c'(1) = bw + where a and b are derived above/below from tightness and + maxjointsize. + ''' +# det = v.imag*w.real-v.real*w.imag + # Note: + # If det is negative, the curvature of elbow is negative for all + # real t if and only if b/a > 6 + # If det is positive, the curvature of elbow is negative for all + # real t if and only if b/a < 2 + +# if det < 0: +# b = (6+tightness)*a +# elif det > 0: +# b = (2-tightness)*a +# else: +# raise Exception("seg0 and seg1 are parallel lines.") + b = (2 - tightness)*a + elbow = CubicBezier(q - a*v, q - (a - b/3)*v, q + (a - b/3)*w, q + a*w) + seg0_trimmed = Line(seg0.start, elbow.start) + seg1_trimmed = Line(elbow.end, seg1.end) + return seg0_trimmed, [elbow], seg1_trimmed + elif isinstance(seg0, Line): + ''' + Note: Letting + c(t) = elbow.point(t), v= the unit tangent of seg0 at 1, + w = the unit tangent vector of seg1 at 0, + Q = seg0.point(1) = seg1.point(0), and a,b>0 some constants. + The elbow will be the unique CubicBezier, c, such that + c(0)= Q-av, c(1)=Q, c'(0) = bv, and c'(1) = bw + where a and b are derived above/below from tightness and + maxjointsize. + ''' +# det = v.imag*w.real-v.real*w.imag + # Note: If g has the same sign as det, then the curvature of elbow is + # negative for all real t if and only if b/a < 4 + b = (4 - tightness)*a +# g = sgn(det)*b + elbow = CubicBezier(q - a*v, q + (b/3 - a)*v, q - b/3*w, q) + seg0_trimmed = Line(seg0.start, elbow.start) + return seg0_trimmed, [elbow], seg1 + elif isinstance(seg1, Line): + args = (seg1.reversed(), seg0.reversed(), maxjointsize, tightness) + rseg1_trimmed, relbow, rseg0 = smoothed_joint(*args) + elbow = relbow[0].reversed() + return seg0, [elbow], rseg1_trimmed.reversed() + else: + # find a point on each seg that is about a/2 away from joint. Make + # line between them. + t0 = seg0.ilength(seg0.length() - a/2) + t1 = seg1.ilength(a/2) + seg0_trimmed = seg0.cropped(0, t0) + seg1_trimmed = seg1.cropped(t1, 1) + seg0_line = Line(seg0_trimmed.end, q) + seg1_line = Line(q, seg1_trimmed.start) + + args = (seg0_trimmed, seg0_line, maxjointsize, tightness) + dummy, elbow0, seg0_line_trimmed = smoothed_joint(*args) + + args = (seg1_line, seg1_trimmed, maxjointsize, tightness) + seg1_line_trimmed, elbow1, dummy = smoothed_joint(*args) + + args = (seg0_line_trimmed, seg1_line_trimmed, maxjointsize, tightness) + seg0_line_trimmed, elbowq, seg1_line_trimmed = smoothed_joint(*args) + + elbow = elbow0 + [seg0_line_trimmed] + elbowq + [seg1_line_trimmed] + elbow1 + return seg0_trimmed, elbow, seg1_trimmed + + +def smoothed_path(path, maxjointsize=3, tightness=1.99, ignore_unfixable_kinks=False): + """returns a path with no non-differentiable joints.""" + if len(path) == 1: + return path + + assert path.iscontinuous() + + sharp_kinks = [] + new_path = [path[0]] + for idx in range(len(path)): + if idx == len(path)-1: + if not path.isclosed(): + continue + else: + seg1 = new_path[0] + else: + seg1 = path[idx + 1] + seg0 = new_path[-1] + + try: + unit_tangent0 = seg0.unit_tangent(1) + unit_tangent1 = seg1.unit_tangent(0) + flag = False + except ValueError: + flag = True # unit tangent not well-defined + + if not flag and isclose(unit_tangent0, unit_tangent1): # joint is already smooth + if idx != len(path)-1: + new_path.append(seg1) + continue + else: + kink_idx = (idx + 1) % len(path) # kink at start of this seg + if not flag and isclose(-unit_tangent0, unit_tangent1): + # joint is sharp 180 deg (must be fixed manually) + new_path.append(seg1) + sharp_kinks.append(kink_idx) + else: # joint is not smooth, let's smooth it. + args = (seg0, seg1, maxjointsize, tightness) + new_seg0, elbow_segs, new_seg1 = smoothed_joint(*args) + new_path[-1] = new_seg0 + new_path += elbow_segs + if idx == len(path) - 1: + new_path[0] = new_seg1 + else: + new_path.append(new_seg1) + + # If unfixable kinks were found, let the user know + if sharp_kinks and not ignore_unfixable_kinks: + _report_unfixable_kinks(path, sharp_kinks) + + return Path(*new_path) diff --git a/svgpathtools/svg2paths.py b/svgpathtools/svg2paths.py new file mode 100644 index 0000000..f5f465f --- /dev/null +++ b/svgpathtools/svg2paths.py @@ -0,0 +1,87 @@ +"""This submodule contains tools for creating path objects from SVG files. +The main tool being the svg2paths() function.""" + +# External dependencies +from __future__ import division, absolute_import, print_function +from xml.dom.minidom import parse +from os import path as os_path, getcwd + +# Internal dependencies +from .parser import parse_path + + +def polyline2pathd(polyline_d): + """converts the string from a polyline d-attribute to a string for a Path + object d-attribute""" + points = polyline_d.replace(', ', ',') + points = points.replace(' ,', ',') + points = points.split() + + if points[0] == points[-1]: + closed = True + else: + closed = False + + d = 'M' + points.pop(0).replace(',', ' ') + for p in points: + d += 'L' + p.replace(',', ' ') + if closed: + d += 'z' + return d + + +def svg2paths(svg_file_location, + convert_lines_to_paths=True, + convert_polylines_to_paths=True, + convert_polygons_to_paths=True): + """ + Converts an SVG file into a list of Path objects and a list of + dictionaries containing their attributes. This currently supports + SVG Path, Line, Polyline, and Polygon elements. + :param svg_file_location: the location of the svg file + :param convert_lines_to_paths: Set to False to disclude SVG-Line objects + (converted to Paths) + :param convert_polylines_to_paths: Set to False to disclude SVG-Polyline + objects (converted to Paths) + :param convert_polygons_to_paths: Set to False to disclude SVG-Polygon + objects (converted to Paths) + :return: list of Path objects + """ + if os_path.dirname(svg_file_location) == '': + svg_file_location = os_path.join(getcwd(), svg_file_location) + + doc = parse(svg_file_location) # parseString also exists + + def dom2dict(element): + """Converts DOM elements to dictionaries of attributes.""" + keys = element.attributes.keys() + values = [val.value for val in element.attributes.values()] + return dict(zip(keys, values)) + + # Use minidom to extract path strings from input SVG + paths = [dom2dict(el) for el in doc.getElementsByTagName('path')] + d_strings = [el['d'] for el in paths] + attribute_dictionary_list = paths + + # Use minidom to extract polyline strings from input SVG, convert to + # path strings, add to list + if convert_polylines_to_paths: + plins = [dom2dict(el) for el in doc.getElementsByTagName('polyline')] + d_strings += [polyline2pathd(pl['points']) for pl in plins] + attribute_dictionary_list += plins + + # Use minidom to extract polygon strings from input SVG, convert to + # path strings, add to list + if convert_polygons_to_paths: + pgons = [dom2dict(el) for el in doc.getElementsByTagName('polygon')] + d_strings += [polyline2pathd(pg['points']) + 'z' for pg in pgons] + attribute_dictionary_list += pgons + + if convert_lines_to_paths: + lines = [dom2dict(el) for el in doc.getElementsByTagName('line')] + d_strings += [('M' + l['x1'] + ' ' + l['y1'] + + 'L' + l['x2'] + ' ' + l['y2']) for l in lines] + attribute_dictionary_list += lines + doc.unlink() + path_list = [parse_path(d) for d in d_strings] + return path_list, attribute_dictionary_list diff --git a/svgpathtools/tests/__init__.py b/svgpathtools/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/svgpathtools/tests/test.svg b/svgpathtools/tests/test.svg new file mode 100644 index 0000000..bded29d --- /dev/null +++ b/svgpathtools/tests/test.svg @@ -0,0 +1,67 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/svgpathtools/tests/test_bezier.py b/svgpathtools/tests/test_bezier.py new file mode 100644 index 0000000..87517f2 --- /dev/null +++ b/svgpathtools/tests/test_bezier.py @@ -0,0 +1,21 @@ +from __future__ import division, absolute_import, print_function +# import unittest +# try: +# from ..bezier import * +# except ValueError: +# from svgpathtools.bezier import * +# +# +# class Test_bezier(unittest.TestCase): +# def test_bernstein(self): +# self.fail() +# +# def test_bezier_point(self): +# self.fail() +# +# def test_split_bezier(self): +# self.fail() +# +# +# if __name__ == '__main__': +# unittest.main() diff --git a/svgpathtools/tests/test_generation.py b/svgpathtools/tests/test_generation.py new file mode 100644 index 0000000..c1047cc --- /dev/null +++ b/svgpathtools/tests/test_generation.py @@ -0,0 +1,56 @@ +# Note: This file was taken mostly as is from the svg.path module (v 2.0) +#------------------------------------------------------------------------------ +from __future__ import division, absolute_import, print_function +import unittest +from svgpathtools import * + + +class TestGeneration(unittest.TestCase): + + def test_svg_examples(self): + """Examples from the SVG spec""" + paths = [ + 'M 100,100 L 300,100 L 200,300 Z', + 'M 0,0 L 50,20 M 100,100 L 300,100 L 200,300 Z', + 'M 100,100 L 200,200', + 'M 100,200 L 200,100 L -100,-200', + 'M 100,200 C 100,100 250,100 250,200 S 400,300 400,200', + 'M 100,200 C 100,100 400,100 400,200', + 'M 100,500 C 25,400 475,400 400,500', + 'M 100,800 C 175,700 325,700 400,800', + 'M 600,200 C 675,100 975,100 900,200', + 'M 600,500 C 600,350 900,650 900,500', + 'M 600,800 C 625,700 725,700 750,800 S 875,900 900,800', + 'M 200,300 Q 400,50 600,300 T 1000,300', + 'M -3.4E+38,3.4E+38 L -3.4E-38,3.4E-38', + 'M 0,0 L 50,20 M 50,20 L 200,100 Z', + 'M 600,350 L 650,325 A 25,25 -30 0,1 700,300 L 750,275', + ] + pathsf = [ + 'M 100.0,100.0 L 300.0,100.0 L 200.0,300.0 L 100.0,100.0', + 'M 0.0,0.0 L 50.0,20.0 M 100.0,100.0 L 300.0,100.0 L 200.0,300.0 L 100.0,100.0', + 'M 100.0,100.0 L 200.0,200.0', + 'M 100.0,200.0 L 200.0,100.0 L -100.0,-200.0', + 'M 100.0,200.0 C 100.0,100.0 250.0,100.0 250.0,200.0 C 250.0,300.0 400.0,300.0 400.0,200.0', + 'M 100.0,200.0 C 100.0,100.0 400.0,100.0 400.0,200.0', + 'M 100.0,500.0 C 25.0,400.0 475.0,400.0 400.0,500.0', + 'M 100.0,800.0 C 175.0,700.0 325.0,700.0 400.0,800.0', + 'M 600.0,200.0 C 675.0,100.0 975.0,100.0 900.0,200.0', + 'M 600.0,500.0 C 600.0,350.0 900.0,650.0 900.0,500.0', + 'M 600.0,800.0 C 625.0,700.0 725.0,700.0 750.0,800.0 C 775.0,900.0 875.0,900.0 900.0,800.0', + 'M 200.0,300.0 Q 400.0,50.0 600.0,300.0 Q 800.0,550.0 1000.0,300.0', + 'M -3.4e+38,3.4e+38 L -3.4e-38,3.4e-38', + 'M 0.0,0.0 L 50.0,20.0 L 200.0,100.0 L 50.0,20.0', + 'M 600.0,350.0 L 650.0,325.0 A 27.9508497187,27.9508497187 -30.0 0,1 700.0,300.0 L 750.0,275.0' + ] + + for k, path in enumerate(paths): + self.assertTrue(parse_path(path).d() in (path, pathsf[k])) + + def test_normalizing(self): + # Relative paths will be made absolute, subpaths merged if they can, + # and syntax will change. + path = 'M0 0L3.4E2-10L100.0,100M100,100l100,-100' + ps = 'M 0,0 L 340,-10 L 100,100 L 200,0' + psf = 'M 0.0,0.0 L 340.0,-10.0 L 100.0,100.0 L 200.0,0.0' + self.assertTrue(parse_path(path).d() in (ps, psf)) diff --git a/svgpathtools/tests/test_parsing.py b/svgpathtools/tests/test_parsing.py new file mode 100644 index 0000000..c052ad8 --- /dev/null +++ b/svgpathtools/tests/test_parsing.py @@ -0,0 +1,139 @@ +# Note: This file was taken mostly as is from the svg.path module (v 2.0) +#------------------------------------------------------------------------------ +from __future__ import division, absolute_import, print_function +import unittest +from svgpathtools import * + + +class TestParser(unittest.TestCase): + + def test_svg_examples(self): + """Examples from the SVG spec""" + path1 = parse_path('M 100 100 L 300 100 L 200 300 z') + self.assertEqual(path1, Path(Line(100 + 100j, 300 + 100j), + Line(300 + 100j, 200 + 300j), + Line(200 + 300j, 100 + 100j))) + self.assertTrue(path1.isclosed()) + + # for Z command behavior when there is multiple subpaths + path1 = parse_path('M 0 0 L 50 20 M 100 100 L 300 100 L 200 300 z') + self.assertEqual(path1, Path( + Line(0 + 0j, 50 + 20j), + Line(100 + 100j, 300 + 100j), + Line(300 + 100j, 200 + 300j), + Line(200 + 300j, 100 + 100j))) + + path1 = parse_path('M 100 100 L 200 200') + path2 = parse_path('M100 100L200 200') + self.assertEqual(path1, path2) + + path1 = parse_path('M 100 200 L 200 100 L -100 -200') + path2 = parse_path('M 100 200 L 200 100 -100 -200') + self.assertEqual(path1, path2) + + path1 = parse_path("""M100,200 C100,100 250,100 250,200 + S400,300 400,200""") + self.assertEqual(path1, + Path(CubicBezier(100 + 200j, 100 + 100j, 250 + 100j, 250 + 200j), + CubicBezier(250 + 200j, 250 + 300j, 400 + 300j, 400 + 200j))) + + path1 = parse_path('M100,200 C100,100 400,100 400,200') + self.assertEqual(path1, + Path(CubicBezier(100 + 200j, 100 + 100j, 400 + 100j, 400 + 200j))) + + path1 = parse_path('M100,500 C25,400 475,400 400,500') + self.assertEqual(path1, + Path(CubicBezier(100 + 500j, 25 + 400j, 475 + 400j, 400 + 500j))) + + path1 = parse_path('M100,800 C175,700 325,700 400,800') + self.assertEqual(path1, + Path(CubicBezier(100 + 800j, 175 + 700j, 325 + 700j, 400 + 800j))) + + path1 = parse_path('M600,200 C675,100 975,100 900,200') + self.assertEqual(path1, + Path(CubicBezier(600 + 200j, 675 + 100j, 975 + 100j, 900 + 200j))) + + path1 = parse_path('M600,500 C600,350 900,650 900,500') + self.assertEqual(path1, + Path(CubicBezier(600 + 500j, 600 + 350j, 900 + 650j, 900 + 500j))) + + path1 = parse_path("""M600,800 C625,700 725,700 750,800 + S875,900 900,800""") + self.assertEqual(path1, + Path(CubicBezier(600 + 800j, 625 + 700j, 725 + 700j, 750 + 800j), + CubicBezier(750 + 800j, 775 + 900j, 875 + 900j, 900 + 800j))) + + path1 = parse_path('M200,300 Q400,50 600,300 T1000,300') + self.assertEqual(path1, + Path(QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j), + QuadraticBezier(600 + 300j, 800 + 550j, 1000 + 300j))) + + path1 = parse_path('M300,200 h-150 a150,150 0 1,0 150,-150 z') + self.assertEqual(path1, + Path(Line(300 + 200j, 150 + 200j), + Arc(150 + 200j, 150 + 150j, 0, 1, 0, 300 + 50j), + Line(300 + 50j, 300 + 200j))) + + path1 = parse_path('M275,175 v-150 a150,150 0 0,0 -150,150 z') + self.assertEqual(path1, + Path(Line(275 + 175j, 275 + 25j), + Arc(275 + 25j, 150 + 150j, 0, 0, 0, 125 + 175j), + Line(125 + 175j, 275 + 175j))) + + path1 = parse_path("""M600,350 l 50,-25 + a25,25 -30 0,1 50,-25 l 50,-25 + a25,50 -30 0,1 50,-25 l 50,-25 + a25,75 -30 0,1 50,-25 l 50,-25 + a25,100 -30 0,1 50,-25 l 50,-25""") + self.assertEqual(path1, + Path(Line(600 + 350j, 650 + 325j), + Arc(650 + 325j, 25 + 25j, -30, 0, 1, 700 + 300j), + Line(700 + 300j, 750 + 275j), + Arc(750 + 275j, 25 + 50j, -30, 0, 1, 800 + 250j), + Line(800 + 250j, 850 + 225j), + Arc(850 + 225j, 25 + 75j, -30, 0, 1, 900 + 200j), + Line(900 + 200j, 950 + 175j), + Arc(950 + 175j, 25 + 100j, -30, 0, 1, 1000 + 150j), + Line(1000 + 150j, 1050 + 125j))) + + def test_others(self): + # Other paths that need testing: + + # Relative moveto: + path1 = parse_path('M 0 0 L 50 20 m 50 80 L 300 100 L 200 300 z') + self.assertEqual(path1, Path( + Line(0 + 0j, 50 + 20j), + Line(100 + 100j, 300 + 100j), + Line(300 + 100j, 200 + 300j), + Line(200 + 300j, 100 + 100j))) + + # Initial smooth and relative CubicBezier + path1 = parse_path("""M100,200 s 150,-100 150,0""") + self.assertEqual(path1, + Path(CubicBezier(100 + 200j, 100 + 200j, 250 + 100j, 250 + 200j))) + + # Initial smooth and relative QuadraticBezier + path1 = parse_path("""M100,200 t 150,0""") + self.assertEqual(path1, + Path(QuadraticBezier(100 + 200j, 100 + 200j, 250 + 200j))) + + # Relative QuadraticBezier + path1 = parse_path("""M100,200 q 0,0 150,0""") + self.assertEqual(path1, + Path(QuadraticBezier(100 + 200j, 100 + 200j, 250 + 200j))) + + def test_negative(self): + """You don't need spaces before a minus-sign""" + path1 = parse_path('M100,200c10-5,20-10,30-20') + path2 = parse_path('M 100 200 c 10 -5 20 -10 30 -20') + self.assertEqual(path1, path2) + + def test_numbers(self): + """Exponents and other number format cases""" + # It can be e or E, the plus is optional, and a minimum of +/-3.4e38 must be supported. + path1 = parse_path('M-3.4e38 3.4E+38L-3.4E-38,3.4e-38') + path2 = Path(Line(-3.4e+38 + 3.4e+38j, -3.4e-38 + 3.4e-38j)) + self.assertEqual(path1, path2) + + def test_errors(self): + self.assertRaises(ValueError, parse_path, 'M 100 100 L 200 200 Z 100 200') diff --git a/svgpathtools/tests/test_path.py b/svgpathtools/tests/test_path.py new file mode 100644 index 0000000..c64aa54 --- /dev/null +++ b/svgpathtools/tests/test_path.py @@ -0,0 +1,906 @@ +# External dependencies +from __future__ import division, absolute_import, print_function +import unittest +from math import sqrt, pi + +# Internal dependencies +from svgpathtools import * + +# A note left from the svg.path tools module: +# ------------------------------------------- +# Most of these test points are not calculated separately, as that would +# take too long and be too error prone. Instead the curves have been verified +# to be correct visually with the disvg() function. + + +class LineTest(unittest.TestCase): + + def test_lines(self): + # These points are calculated, and not just regression tests. + + line1 = Line(0j, 400 + 0j) + self.assertAlmostEqual(line1.point(0), 0j) + self.assertAlmostEqual(line1.point(0.3), (120 + 0j)) + self.assertAlmostEqual(line1.point(0.5), (200 + 0j)) + self.assertAlmostEqual(line1.point(0.9), (360 + 0j)) + self.assertAlmostEqual(line1.point(1), (400 + 0j)) + self.assertAlmostEqual(line1.length(), 400) + + line2 = Line(400 + 0j, 400 + 300j) + self.assertAlmostEqual(line2.point(0), (400 + 0j)) + self.assertAlmostEqual(line2.point(0.3), (400 + 90j)) + self.assertAlmostEqual(line2.point(0.5), (400 + 150j)) + self.assertAlmostEqual(line2.point(0.9), (400 + 270j)) + self.assertAlmostEqual(line2.point(1), (400 + 300j)) + self.assertAlmostEqual(line2.length(), 300) + + line3 = Line(400 + 300j, 0j) + self.assertAlmostEqual(line3.point(0), (400 + 300j)) + self.assertAlmostEqual(line3.point(0.3), (280 + 210j)) + self.assertAlmostEqual(line3.point(0.5), (200 + 150j)) + self.assertAlmostEqual(line3.point(0.9), (40 + 30j)) + self.assertAlmostEqual(line3.point(1), 0j) + self.assertAlmostEqual(line3.length(), 500) + + def test_equality(self): + # This is to test the __eq__ and __ne__ methods, so we can't use + # assertEqual and assertNotEqual + line = Line(0j, 400 + 0j) + self.assertTrue(line == Line(0, 400)) + self.assertTrue(line != Line(100, 400)) + self.assertFalse(line == str(line)) + self.assertTrue(line != str(line)) + self.assertFalse( + CubicBezier(600 + 500j, 600 + 350j, 900 + 650j, 900 + 500j) == line) + + +class CubicBezierTest(unittest.TestCase): + def test_approx_circle(self): + """This is a approximate circle drawn in Inkscape""" + + cub1 = CubicBezier( + complex(0, 0), + complex(0, 109.66797), + complex(-88.90345, 198.57142), + complex(-198.57142, 198.57142) + ) + + self.assertAlmostEqual(cub1.point(0), 0j) + self.assertAlmostEqual(cub1.point(0.1), (-2.59896457 + 32.20931647j)) + self.assertAlmostEqual(cub1.point(0.2), (-10.12330256 + 62.76392816j)) + self.assertAlmostEqual(cub1.point(0.3), (-22.16418039 + 91.25500149j)) + self.assertAlmostEqual(cub1.point(0.4), (-38.31276448 + 117.27370288j)) + self.assertAlmostEqual(cub1.point(0.5), (-58.16022125 + 140.41119875j)) + self.assertAlmostEqual(cub1.point(0.6), (-81.29771712 + 160.25865552j)) + self.assertAlmostEqual(cub1.point(0.7), (-107.31641851 + 176.40723961j)) + self.assertAlmostEqual(cub1.point(0.8), (-135.80749184 + 188.44811744j)) + self.assertAlmostEqual(cub1.point(0.9), (-166.36210353 + 195.97245543j)) + self.assertAlmostEqual(cub1.point(1), (-198.57142 + 198.57142j)) + + cub2 = CubicBezier( + complex(-198.57142, 198.57142), + complex(-109.66797 - 198.57142, 0 + 198.57142), + complex(-198.57143 - 198.57142, -88.90345 + 198.57142), + complex(-198.57143 - 198.57142, 0), + ) + + self.assertAlmostEqual(cub2.point(0), (-198.57142 + 198.57142j)) + self.assertAlmostEqual(cub2.point(0.1), (-230.78073675 + 195.97245543j)) + self.assertAlmostEqual(cub2.point(0.2), (-261.3353492 + 188.44811744j)) + self.assertAlmostEqual(cub2.point(0.3), (-289.82642365 + 176.40723961j)) + self.assertAlmostEqual(cub2.point(0.4), (-315.8451264 + 160.25865552j)) + self.assertAlmostEqual(cub2.point(0.5), (-338.98262375 + 140.41119875j)) + self.assertAlmostEqual(cub2.point(0.6), (-358.830082 + 117.27370288j)) + self.assertAlmostEqual(cub2.point(0.7), (-374.97866745 + 91.25500149j)) + self.assertAlmostEqual(cub2.point(0.8), (-387.0195464 + 62.76392816j)) + self.assertAlmostEqual(cub2.point(0.9), (-394.54388515 + 32.20931647j)) + self.assertAlmostEqual(cub2.point(1), (-397.14285 + 0j)) + + cub3 = CubicBezier( + complex(-198.57143 - 198.57142, 0), + complex(0 - 198.57143 - 198.57142, -109.66797), + complex(88.90346 - 198.57143 - 198.57142, -198.57143), + complex(-198.57142, -198.57143) + ) + + self.assertAlmostEqual(cub3.point(0), (-397.14285 + 0j)) + self.assertAlmostEqual(cub3.point(0.1), (-394.54388515 - 32.20931675j)) + self.assertAlmostEqual(cub3.point(0.2), (-387.0195464 - 62.7639292j)) + self.assertAlmostEqual(cub3.point(0.3), (-374.97866745 - 91.25500365j)) + self.assertAlmostEqual(cub3.point(0.4), (-358.830082 - 117.2737064j)) + self.assertAlmostEqual(cub3.point(0.5), (-338.98262375 - 140.41120375j)) + self.assertAlmostEqual(cub3.point(0.6), (-315.8451264 - 160.258662j)) + self.assertAlmostEqual(cub3.point(0.7), (-289.82642365 - 176.40724745j)) + self.assertAlmostEqual(cub3.point(0.8), (-261.3353492 - 188.4481264j)) + self.assertAlmostEqual(cub3.point(0.9), (-230.78073675 - 195.97246515j)) + self.assertAlmostEqual(cub3.point(1), (-198.57142 - 198.57143j)) + + cub4 = CubicBezier( + complex(-198.57142, -198.57143), + complex(109.66797 - 198.57142, 0 - 198.57143), + complex(0, 88.90346 - 198.57143), + complex(0, 0), + ) + + self.assertAlmostEqual(cub4.point(0), (-198.57142 - 198.57143j)) + self.assertAlmostEqual(cub4.point(0.1), (-166.36210353 - 195.97246515j)) + self.assertAlmostEqual(cub4.point(0.2), (-135.80749184 - 188.4481264j)) + self.assertAlmostEqual(cub4.point(0.3), (-107.31641851 - 176.40724745j)) + self.assertAlmostEqual(cub4.point(0.4), (-81.29771712 - 160.258662j)) + self.assertAlmostEqual(cub4.point(0.5), (-58.16022125 - 140.41120375j)) + self.assertAlmostEqual(cub4.point(0.6), (-38.31276448 - 117.2737064j)) + self.assertAlmostEqual(cub4.point(0.7), (-22.16418039 - 91.25500365j)) + self.assertAlmostEqual(cub4.point(0.8), (-10.12330256 - 62.7639292j)) + self.assertAlmostEqual(cub4.point(0.9), (-2.59896457 - 32.20931675j)) + self.assertAlmostEqual(cub4.point(1), 0j) + + def test_svg_examples(self): + + # M100,200 C100,100 250,100 250,200 + path1 = CubicBezier(100 + 200j, 100 + 100j, 250 + 100j, 250 + 200j) + self.assertAlmostEqual(path1.point(0), (100 + 200j)) + self.assertAlmostEqual(path1.point(0.3), (132.4 + 137j)) + self.assertAlmostEqual(path1.point(0.5), (175 + 125j)) + self.assertAlmostEqual(path1.point(0.9), (245.8 + 173j)) + self.assertAlmostEqual(path1.point(1), (250 + 200j)) + + # S400,300 400,200 + path2 = CubicBezier(250 + 200j, 250 + 300j, 400 + 300j, 400 + 200j) + self.assertAlmostEqual(path2.point(0), (250 + 200j)) + self.assertAlmostEqual(path2.point(0.3), (282.4 + 263j)) + self.assertAlmostEqual(path2.point(0.5), (325 + 275j)) + self.assertAlmostEqual(path2.point(0.9), (395.8 + 227j)) + self.assertAlmostEqual(path2.point(1), (400 + 200j)) + + # M100,200 C100,100 400,100 400,200 + path3 = CubicBezier(100 + 200j, 100 + 100j, 400 + 100j, 400 + 200j) + self.assertAlmostEqual(path3.point(0), (100 + 200j)) + self.assertAlmostEqual(path3.point(0.3), (164.8 + 137j)) + self.assertAlmostEqual(path3.point(0.5), (250 + 125j)) + self.assertAlmostEqual(path3.point(0.9), (391.6 + 173j)) + self.assertAlmostEqual(path3.point(1), (400 + 200j)) + + # M100,500 C25,400 475,400 400,500 + path4 = CubicBezier(100 + 500j, 25 + 400j, 475 + 400j, 400 + 500j) + self.assertAlmostEqual(path4.point(0), (100 + 500j)) + self.assertAlmostEqual(path4.point(0.3), (145.9 + 437j)) + self.assertAlmostEqual(path4.point(0.5), (250 + 425j)) + self.assertAlmostEqual(path4.point(0.9), (407.8 + 473j)) + self.assertAlmostEqual(path4.point(1), (400 + 500j)) + + # M100,800 C175,700 325,700 400,800 + path5 = CubicBezier(100 + 800j, 175 + 700j, 325 + 700j, 400 + 800j) + self.assertAlmostEqual(path5.point(0), (100 + 800j)) + self.assertAlmostEqual(path5.point(0.3), (183.7 + 737j)) + self.assertAlmostEqual(path5.point(0.5), (250 + 725j)) + self.assertAlmostEqual(path5.point(0.9), (375.4 + 773j)) + self.assertAlmostEqual(path5.point(1), (400 + 800j)) + + # M600,200 C675,100 975,100 900,200 + path6 = CubicBezier(600 + 200j, 675 + 100j, 975 + 100j, 900 + 200j) + self.assertAlmostEqual(path6.point(0), (600 + 200j)) + self.assertAlmostEqual(path6.point(0.3), (712.05 + 137j)) + self.assertAlmostEqual(path6.point(0.5), (806.25 + 125j)) + self.assertAlmostEqual(path6.point(0.9), (911.85 + 173j)) + self.assertAlmostEqual(path6.point(1), (900 + 200j)) + + # M600,500 C600,350 900,650 900,500 + path7 = CubicBezier(600 + 500j, 600 + 350j, 900 + 650j, 900 + 500j) + self.assertAlmostEqual(path7.point(0), (600 + 500j)) + self.assertAlmostEqual(path7.point(0.3), (664.8 + 462.2j)) + self.assertAlmostEqual(path7.point(0.5), (750 + 500j)) + self.assertAlmostEqual(path7.point(0.9), (891.6 + 532.4j)) + self.assertAlmostEqual(path7.point(1), (900 + 500j)) + + # M600,800 C625,700 725,700 750,800 + path8 = CubicBezier(600 + 800j, 625 + 700j, 725 + 700j, 750 + 800j) + self.assertAlmostEqual(path8.point(0), (600 + 800j)) + self.assertAlmostEqual(path8.point(0.3), (638.7 + 737j)) + self.assertAlmostEqual(path8.point(0.5), (675 + 725j)) + self.assertAlmostEqual(path8.point(0.9), (740.4 + 773j)) + self.assertAlmostEqual(path8.point(1), (750 + 800j)) + + # S875,900 900,800 + inversion = (750 + 800j) + (750 + 800j) - (725 + 700j) + path9 = CubicBezier(750 + 800j, inversion, 875 + 900j, 900 + 800j) + self.assertAlmostEqual(path9.point(0), (750 + 800j)) + self.assertAlmostEqual(path9.point(0.3), (788.7 + 863j)) + self.assertAlmostEqual(path9.point(0.5), (825 + 875j)) + self.assertAlmostEqual(path9.point(0.9), (890.4 + 827j)) + self.assertAlmostEqual(path9.point(1), (900 + 800j)) + + def test_length(self): + + # A straight line: + cub = CubicBezier( + complex(0, 0), + complex(0, 0), + complex(0, 100), + complex(0, 100) + ) + + self.assertAlmostEqual(cub.length(), 100) + + # A diagonal line: + cub = CubicBezier( + complex(0, 0), + complex(0, 0), + complex(100, 100), + complex(100, 100) + ) + + self.assertAlmostEqual(cub.length(), sqrt(2 * 100 * 100)) + + # A quarter circle large_arc with radius 100: + kappa = 4 * (sqrt(2) - 1) / 3 # http://www.whizkidtech.redprince.net/bezier/circle/ + + cub = CubicBezier( + complex(0, 0), + complex(0, kappa * 100), + complex(100 - kappa * 100, 100), + complex(100, 100) + ) + + # We can't compare with pi*50 here, because this is just an + # approximation of a circle large_arc. pi*50 is 157.079632679 + # So this is just yet another "warn if this changes" test. + # This value is not verified to be correct. + self.assertAlmostEqual(cub.length(), 157.1016698) + + # A recursive solution has also been suggested, but for CubicBezier + # curves it could get a false solution on curves where the midpoint is + # on a straight line between the start and end. For example, the + # following curve would get solved as a straight line and get the + # length 300. + # Make sure this is not the case. + cub = CubicBezier( + complex(600, 500), + complex(600, 350), + complex(900, 650), + complex(900, 500) + ) + self.assertTrue(cub.length() > 300.0) + + def test_equality(self): + # This is to test the __eq__ and __ne__ methods, so we can't use + # assertEqual and assertNotEqual + segment = CubicBezier(complex(600, 500), complex(600, 350), + complex(900, 650), complex(900, 500)) + + self.assertTrue(segment == + CubicBezier(600 + 500j, 600 + 350j, 900 + 650j, 900 + 500j)) + self.assertTrue(segment != + CubicBezier(600 + 501j, 600 + 350j, 900 + 650j, 900 + 500j)) + self.assertTrue(segment != Line(0, 400)) + + +class QuadraticBezierTest(unittest.TestCase): + + def test_svg_examples(self): + """These is the path in the SVG specs""" + # M200,300 Q400,50 600,300 T1000,300 + path1 = QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j) + self.assertAlmostEqual(path1.point(0), (200 + 300j)) + self.assertAlmostEqual(path1.point(0.3), (320 + 195j)) + self.assertAlmostEqual(path1.point(0.5), (400 + 175j)) + self.assertAlmostEqual(path1.point(0.9), (560 + 255j)) + self.assertAlmostEqual(path1.point(1), (600 + 300j)) + + # T1000, 300 + inversion = (600 + 300j) + (600 + 300j) - (400 + 50j) + path2 = QuadraticBezier(600 + 300j, inversion, 1000 + 300j) + self.assertAlmostEqual(path2.point(0), (600 + 300j)) + self.assertAlmostEqual(path2.point(0.3), (720 + 405j)) + self.assertAlmostEqual(path2.point(0.5), (800 + 425j)) + self.assertAlmostEqual(path2.point(0.9), (960 + 345j)) + self.assertAlmostEqual(path2.point(1), (1000 + 300j)) + + def test_length(self): + # expected results calculated with + # svg.path.segment_length(q, 0, 1, q.start, q.end, 1e-14, 20, 0) + q1 = QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j) + q2 = QuadraticBezier(200 + 300j, 400 + 50j, 500 + 200j) + closedq = QuadraticBezier(6+2j, 5-1j, 6+2j) + linq1 = QuadraticBezier(1, 2, 3) + linq2 = QuadraticBezier(1+3j, 2+5j, -9 - 17j) + nodalq = QuadraticBezier(1, 1, 1) + tests = [(q1, 487.77109389525975), + (q2, 379.90458193489155), + (closedq, 3.1622776601683795), + (linq1, 2), + (linq2, 22.73335777124786), + (nodalq, 0)] + for q, exp_res in tests: + self.assertAlmostEqual(q.length(), exp_res) + + # partial length tests + tests = [(q1, 212.34775387566032), + (q2, 166.22170622052397), + (closedq, 0.7905694150420949), + (linq1, 1.0), + (nodalq, 0.0)] + t0 = 0.25 + t1 = 0.75 + for q, exp_res in tests: + self.assertAlmostEqual(q.length(t0=t0, t1=t1), exp_res) + + # linear partial cases + linq2 = QuadraticBezier(1+3j, 2+5j, -9 - 17j) + tests = [(0, 1/24, 0.13975424859373725), + (0, 1/12, 0.1863389981249823), + (0, 0.5, 4.844813951249543), + (0, 1, 22.73335777124786), + (1/24, 1/12, 0.04658474953124506), + (1/24, 0.5, 4.705059702655722), + (1/24, 1, 22.59360352265412), + (1/12, 0.5, 4.658474953124562), + (1/12, 1, 22.54701877312288), + (0.5, 1, 17.88854381999832)] + for t0, t1, exp_s in tests: + self.assertAlmostEqual(linq2.length(t0=t0, t1=t1), exp_s) + + def test_equality(self): + # This is to test the __eq__ and __ne__ methods, so we can't use + # assertEqual and assertNotEqual + segment = QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j) + self.assertTrue(segment == QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j)) + self.assertTrue(segment != QuadraticBezier(200 + 301j, 400 + 50j, 600 + 300j)) + self.assertFalse(segment == Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j)) + self.assertTrue(Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j) != segment) + + +class ArcTest(unittest.TestCase): + + def test_points(self): + arc1 = Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j) + self.assertAlmostEqual(arc1.center, 100 + 0j) + self.assertAlmostEqual(arc1.theta, 180.0) + self.assertAlmostEqual(arc1.delta, -90.0) + + self.assertAlmostEqual(arc1.point(0.0), 0j) + self.assertAlmostEqual(arc1.point(0.1), (1.23116594049 + 7.82172325201j)) + self.assertAlmostEqual(arc1.point(0.2), (4.89434837048 + 15.4508497187j)) + self.assertAlmostEqual(arc1.point(0.3), (10.8993475812 + 22.699524987j)) + self.assertAlmostEqual(arc1.point(0.4), (19.0983005625 + 29.3892626146j)) + self.assertAlmostEqual(arc1.point(0.5), (29.2893218813 + 35.3553390593j)) + self.assertAlmostEqual(arc1.point(0.6), (41.2214747708 + 40.4508497187j)) + self.assertAlmostEqual(arc1.point(0.7), (54.6009500260 + 44.5503262094j)) + self.assertAlmostEqual(arc1.point(0.8), (69.0983005625 + 47.5528258148j)) + self.assertAlmostEqual(arc1.point(0.9), (84.3565534960 + 49.3844170298j)) + self.assertAlmostEqual(arc1.point(1.0), (100 + 50j)) + + arc2 = Arc(0j, 100 + 50j, 0, 1, 0, 100 + 50j) + self.assertAlmostEqual(arc2.center, 50j) + self.assertAlmostEqual(arc2.theta, -90.0) + self.assertAlmostEqual(arc2.delta, -270.0) + + self.assertAlmostEqual(arc2.point(0.0), 0j) + self.assertAlmostEqual(arc2.point(0.1), (-45.399049974 + 5.44967379058j)) + self.assertAlmostEqual(arc2.point(0.2), (-80.9016994375 + 20.6107373854j)) + self.assertAlmostEqual(arc2.point(0.3), (-98.7688340595 + 42.178276748j)) + self.assertAlmostEqual(arc2.point(0.4), (-95.1056516295 + 65.4508497187j)) + self.assertAlmostEqual(arc2.point(0.5), (-70.7106781187 + 85.3553390593j)) + self.assertAlmostEqual(arc2.point(0.6), (-30.9016994375 + 97.5528258148j)) + self.assertAlmostEqual(arc2.point(0.7), (15.643446504 + 99.3844170298j)) + self.assertAlmostEqual(arc2.point(0.8), (58.7785252292 + 90.4508497187j)) + self.assertAlmostEqual(arc2.point(0.9), (89.1006524188 + 72.699524987j)) + self.assertAlmostEqual(arc2.point(1.0), (100 + 50j)) + + arc3 = Arc(0j, 100 + 50j, 0, 0, 1, 100 + 50j) + self.assertAlmostEqual(arc3.center, 50j) + self.assertAlmostEqual(arc3.theta, -90.0) + self.assertAlmostEqual(arc3.delta, 90.0) + + self.assertAlmostEqual(arc3.point(0.0), 0j) + self.assertAlmostEqual(arc3.point(0.1), (15.643446504 + 0.615582970243j)) + self.assertAlmostEqual(arc3.point(0.2), (30.9016994375 + 2.44717418524j)) + self.assertAlmostEqual(arc3.point(0.3), (45.399049974 + 5.44967379058j)) + self.assertAlmostEqual(arc3.point(0.4), (58.7785252292 + 9.54915028125j)) + self.assertAlmostEqual(arc3.point(0.5), (70.7106781187 + 14.6446609407j)) + self.assertAlmostEqual(arc3.point(0.6), (80.9016994375 + 20.6107373854j)) + self.assertAlmostEqual(arc3.point(0.7), (89.1006524188 + 27.300475013j)) + self.assertAlmostEqual(arc3.point(0.8), (95.1056516295 + 34.5491502813j)) + self.assertAlmostEqual(arc3.point(0.9), (98.7688340595 + 42.178276748j)) + self.assertAlmostEqual(arc3.point(1.0), (100 + 50j)) + + arc4 = Arc(0j, 100 + 50j, 0, 1, 1, 100 + 50j) + self.assertAlmostEqual(arc4.center, 100 + 0j) + self.assertAlmostEqual(arc4.theta, 180.0) + self.assertAlmostEqual(arc4.delta, 270.0) + + self.assertAlmostEqual(arc4.point(0.0), 0j) + self.assertAlmostEqual(arc4.point(0.1), (10.8993475812 - 22.699524987j)) + self.assertAlmostEqual(arc4.point(0.2), (41.2214747708 - 40.4508497187j)) + self.assertAlmostEqual(arc4.point(0.3), (84.3565534960 - 49.3844170298j)) + self.assertAlmostEqual(arc4.point(0.4), (130.901699437 - 47.5528258148j)) + self.assertAlmostEqual(arc4.point(0.5), (170.710678119 - 35.3553390593j)) + self.assertAlmostEqual(arc4.point(0.6), (195.105651630 - 15.4508497187j)) + self.assertAlmostEqual(arc4.point(0.7), (198.768834060 + 7.82172325201j)) + self.assertAlmostEqual(arc4.point(0.8), (180.901699437 + 29.3892626146j)) + self.assertAlmostEqual(arc4.point(0.9), (145.399049974 + 44.5503262094j)) + self.assertAlmostEqual(arc4.point(1.0), (100 + 50j)) + + arc5 = Arc((725.307482225571-915.5548199281527j), + (202.79421639137703+148.77294617167183j), + 225.6910319606926, 1, 1, + (-624.6375539637027+896.5483089399895j)) + self.assertAlmostEqual(arc5.point(0.0), (725.307482226-915.554819928j)) + self.assertAlmostEqual(arc5.point(0.0909090909091), (1023.47397369-597.730444283j)) + self.assertAlmostEqual(arc5.point(0.181818181818), (1242.80253007-232.251400124j)) + self.assertAlmostEqual(arc5.point(0.272727272727), (1365.52445614+151.273373978j)) + self.assertAlmostEqual(arc5.point(0.363636363636), (1381.69755131+521.772981736j)) + self.assertAlmostEqual(arc5.point(0.454545454545), (1290.01156757+849.231748376j)) + self.assertAlmostEqual(arc5.point(0.545454545455), (1097.89435807+1107.12091209j)) + self.assertAlmostEqual(arc5.point(0.636363636364), (820.910116547+1274.54782658j)) + self.assertAlmostEqual(arc5.point(0.727272727273), (481.49845896+1337.94855893j)) + self.assertAlmostEqual(arc5.point(0.818181818182), (107.156499251+1292.18675889j)) + self.assertAlmostEqual(arc5.point(0.909090909091), (-271.788803303+1140.96977533j)) + + def test_length(self): + # I'll test the length calculations by making a circle, in two parts. + arc1 = Arc(0j, 100 + 100j, 0, 0, 0, 200 + 0j) + arc2 = Arc(200 + 0j, 100 + 100j, 0, 0, 0, 0j) + self.assertAlmostEqual(arc1.length(), pi * 100) + self.assertAlmostEqual(arc2.length(), pi * 100) + + def test_equality(self): + # This is to test the __eq__ and __ne__ methods, so we can't use + # assertEqual and assertNotEqual + segment = Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j) + self.assertTrue(segment == Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j)) + self.assertTrue(segment != Arc(0j, 100 + 50j, 0, 1, 0, 100 + 50j)) + + +class TestPath(unittest.TestCase): + + def test_circle(self): + arc1 = Arc(0j, 100 + 100j, 0, 0, 0, 200 + 0j) + arc2 = Arc(200 + 0j, 100 + 100j, 0, 0, 0, 0j) + path = Path(arc1, arc2) + self.assertAlmostEqual(path.point(0.0), 0j) + self.assertAlmostEqual(path.point(0.25), (100 + 100j)) + self.assertAlmostEqual(path.point(0.5), (200 + 0j)) + self.assertAlmostEqual(path.point(0.75), (100 - 100j)) + self.assertAlmostEqual(path.point(1.0), 0j) + self.assertAlmostEqual(path.length(), pi * 200) + + def test_svg_specs(self): + """The paths that are in the SVG specs""" + + # Big pie: M300,200 h-150 a150,150 0 1,0 150,-150 z + path = Path(Line(300 + 200j, 150 + 200j), + Arc(150 + 200j, 150 + 150j, 0, 1, 0, 300 + 50j), + Line(300 + 50j, 300 + 200j)) + # The points and length for this path are calculated and not regression tests. + self.assertAlmostEqual(path.point(0.0), (300 + 200j)) + self.assertAlmostEqual(path.point(0.14897825542), (150 + 200j)) + self.assertAlmostEqual(path.point(0.5), (406.066017177 + 306.066017177j)) + self.assertAlmostEqual(path.point(1 - 0.14897825542), (300 + 50j)) + self.assertAlmostEqual(path.point(1.0), (300 + 200j)) + # The errors seem to accumulate. Still 6 decimal places is more than good enough. + self.assertAlmostEqual(path.length(), pi * 225 + 300, places=6) + + # Little pie: M275,175 v-150 a150,150 0 0,0 -150,150 z + path = Path(Line(275 + 175j, 275 + 25j), + Arc(275 + 25j, 150 + 150j, 0, 0, 0, 125 + 175j), + Line(125 + 175j, 275 + 175j)) + # The points and length for this path are calculated and not regression tests. + self.assertAlmostEqual(path.point(0.0), (275 + 175j)) + self.assertAlmostEqual(path.point(0.2800495767557787), (275 + 25j)) + self.assertAlmostEqual(path.point(0.5), (168.93398282201787 + 68.93398282201787j)) + self.assertAlmostEqual(path.point(1 - 0.2800495767557787), (125 + 175j)) + self.assertAlmostEqual(path.point(1.0), (275 + 175j)) + # The errors seem to accumulate. Still 6 decimal places is more than good enough. + self.assertAlmostEqual(path.length(), pi * 75 + 300, places=6) + + # Bumpy path: M600,350 l 50,-25 + # a25,25 -30 0,1 50,-25 l 50,-25 + # a25,50 -30 0,1 50,-25 l 50,-25 + # a25,75 -30 0,1 50,-25 l 50,-25 + # a25,100 -30 0,1 50,-25 l 50,-25 + + # Commented out because by Andy cause I was skeptical of path.point + # ground truth values + # path = Path(Line(600 + 350j, 650 + 325j), + # Arc(650 + 325j, 25 + 25j, -30, 0, 1, 700 + 300j), + # Line(700 + 300j, 750 + 275j), + # Arc(750 + 275j, 25 + 50j, -30, 0, 1, 800 + 250j), + # Line(800 + 250j, 850 + 225j), + # Arc(850 + 225j, 25 + 75j, -30, 0, 1, 900 + 200j), + # Line(900 + 200j, 950 + 175j), + # Arc(950 + 175j, 25 + 100j, -30, 0, 1, 1000 + 150j), + # Line(1000 + 150j, 1050 + 125j), + # ) + # # These are *not* calculated, but just regression tests. Be skeptical. + # self.assertAlmostEqual(path.point(0), (600+350j)) + # self.assertAlmostEqual(path.point(0.3), (755.239799276+212.182020958j)) + # self.assertAlmostEqual(path.point(0.5), (827.730749264+147.824157418j)) + # self.assertAlmostEqual(path.point(0.9), (971.284357806+106.302352605j)) + # self.assertAlmostEqual(path.point(1), (1050+125j)) + # # The errors seem to accumulate. Still 6 decimal places is more than good enough. + # self.assertAlmostEqual(path.length(), 928.3886394081095) + + def test_repr(self): + path = Path( + Line(start=600 + 350j, end=650 + 325j), + Arc(start=650 + 325j, radius=25 + 25j, rotation=-30, large_arc=0, sweep=1, end=700 + 300j), + CubicBezier(start=700 + 300j, control1=800 + 400j, control2=750 + 200j, end=600 + 100j), + QuadraticBezier(start=600 + 100j, control=600, end=600 + 300j)) + self.assertEqual(eval(repr(path)), path) + + def test_equality(self): + # This is to test the __eq__ and __ne__ methods, so we can't use + # assertEqual and assertNotEqual + path1 = Path( + Line(start=600 + 350j, end=650 + 325j), + Arc(start=650 + 325j, radius=25 + 25j, rotation=-30, large_arc=0, sweep=1, end=700 + 300j), + CubicBezier(start=700 + 300j, control1=800 + 400j, control2=750 + 200j, end=600 + 100j), + QuadraticBezier(start=600 + 100j, control=600, end=600 + 300j)) + path2 = Path( + Line(start=600 + 350j, end=650 + 325j), + Arc(start=650 + 325j, radius=25 + 25j, rotation=-30, large_arc=0, sweep=1, end=700 + 300j), + CubicBezier(start=700 + 300j, control1=800 + 400j, control2=750 + 200j, end=600 + 100j), + QuadraticBezier(start=600 + 100j, control=600, end=600 + 300j)) + + self.assertTrue(path1 == path2) + # Modify path2: + path2[0].start = 601 + 350j + self.assertTrue(path1 != path2) + + # Modify back: + path2[0].start = 600 + 350j + self.assertFalse(path1 != path2) + + # Get rid of the last segment: + del path2[-1] + self.assertFalse(path1 == path2) + + # It's not equal to a list of it's segments + self.assertTrue(path1 != path1[:]) + self.assertFalse(path1 == path1[:]) + + def test_continuous_subpaths(self): + """Test the Path.continuous_subpaths() method.""" + + # Continuous and open example + q = Path(Line(1, 2)) + a = [Path(Line(1, 2))] + subpaths = q.continuous_subpaths() + chk1 = all(subpath.iscontinuous() for subpath in subpaths) + chk2 = (q == Path(*[seg for subpath in subpaths for seg in subpath])) + self.assertTrue(subpaths == a) + self.assertTrue(chk1) + self.assertTrue(chk2) + + # # Continuous and closed example + q = Path(Line(1, 2), Line(2, 1)) + a = [Path(Line(1, 2), Line(2, 1))] + subpaths = q.continuous_subpaths() + chk1 = all(subpath.iscontinuous() for subpath in subpaths) + chk2 = q == Path(*[seg for subpath in subpaths for seg in subpath]) + self.assertTrue(subpaths == a) + self.assertTrue(chk1) + self.assertTrue(chk2) + + # Continuous and open example + q = Path(Line(1, 2), Line(2, 3), Line(3, 4)) + a = [Path(Line(1, 2), Line(2, 3), Line(3, 4))] + subpaths = q.continuous_subpaths() + chk1 = all(subpath.iscontinuous() for subpath in subpaths) + chk2 = (q == Path(*[seg for subpath in subpaths for seg in subpath])) + self.assertTrue(subpaths == a) + self.assertTrue(chk1) + self.assertTrue(chk2) + + # Continuous and closed example + q = Path(Line(1, 2), Line(2, 3), Line(3, 4), Line(4, 1)) + a = [Path(Line(1, 2), Line(2, 3), Line(3, 4), Line(4, 1))] + subpaths = q.continuous_subpaths() + chk1 = all(subpath.iscontinuous() for subpath in subpaths) + chk2 = (q == Path(*[seg for subpath in subpaths for seg in subpath])) + self.assertTrue(subpaths == a) + self.assertTrue(chk1) + self.assertTrue(chk2) + + # Discontinuous example + q = Path(Line(1, 2), Line(2, 3), Line(3, 4), + Line(10, 11)) + a = [Path(Line(1, 2), Line(2, 3), Line(3, 4)), + Path(Line(10, 11))] + subpaths = q.continuous_subpaths() + chk1 = all(subpath.iscontinuous() for subpath in subpaths) + chk2 = (q == Path(*[seg for subpath in subpaths for seg in subpath])) + self.assertTrue(subpaths == a) + self.assertTrue(chk1) + self.assertTrue(chk2) + + # Discontinuous closed example + q = Path(Line(1, 2), Line(2, 3), Line(3, 4), Line(4, 1), + Line(10, 11), Line(11, 12)) + a = [Path(Line(1, 2), Line(2, 3), Line(3, 4), Line(4, 1)), + Path(Line(10, 11), Line(11, 12))] + subpaths = q.continuous_subpaths() + chk1 = all(subpath.iscontinuous() for subpath in subpaths) + chk2 = (q == Path(*[seg for subpath in subpaths for seg in subpath])) + self.assertTrue(subpaths == a) + self.assertTrue(chk1) + self.assertTrue(chk2) + + # Discontinuous example + q = Path(Line(1, 2), + Line(1, 2), Line(2, 3), + Line(10, 11), Line(11, 12), Line(12, 13), + Line(10, 11), Line(11, 12), Line(12, 13), Line(13, 14)) + a = [Path(Line(1, 2)), + Path(Line(1, 2), Line(2, 3)), + Path(Line(10, 11), Line(11, 12), Line(12, 13)), + Path(Line(10, 11), Line(11, 12), Line(12, 13), Line(13, 14))] + subpaths = q.continuous_subpaths() + chk1 = all(subpath.iscontinuous() for subpath in subpaths) + chk2 = (q == Path(*[seg for subpath in subpaths for seg in subpath])) + self.assertTrue(subpaths == a) + self.assertTrue(chk1) + self.assertTrue(chk2) + + # Discontinuous example with overlapping end + q = Path(Line(1, 2), + Line(5, 6), Line(6, 7), + Line(10, 11), Line(11, 12), Line(12, 13), + Line(10, 11), Line(11, 12), Line(12, 13), Line(13, 1)) + a = [Path(Line(1, 2)), + Path(Line(5, 6), Line(6, 7)), + Path(Line(10, 11), Line(11, 12), Line(12, 13)), + Path(Line(10, 11), Line(11, 12), Line(12, 13), Line(13, 1))] + subpaths = q.continuous_subpaths() + chk1 = all(subpath.iscontinuous() for subpath in subpaths) + chk2 = (q == Path(*[seg for subpath in subpaths for seg in subpath])) + self.assertTrue(subpaths == a) + self.assertTrue(chk1) + self.assertTrue(chk2) + + +class Test_ilength(unittest.TestCase): + def test_ilength(self): + # See svgpathtools.notes.inv_arclength.py for information on how these + # test values were generated (using the .length() method). + ############################################################## + # Lines + l = Line(1, 3-1j) + nodall = Line(1+1j, 1+1j) + + tests = [(l, 0.01, 0.022360679774997897), + (l, 0.1, 0.223606797749979), + (l, 0.5, 1.118033988749895), + (l, 0.9, 2.012461179749811), + (l, 0.99, 2.213707297724792)] + + for (l, t, s) in tests: + self.assertAlmostEqual(l.ilength(s), t) + + ############################################################### + # Quadratics + q1 = QuadraticBezier(200 + 300j, 400 + 50j, 600 + 300j) + q2 = QuadraticBezier(200 + 300j, 400 + 50j, 500 + 200j) + closedq = QuadraticBezier(6 + 2j, 5 - 1j, 6 + 2j) + linq = QuadraticBezier(1+3j, 2+5j, -9 - 17j) + nodalq = QuadraticBezier(1, 1, 1) + + tests = [(q1, 0.01, 6.364183310105577), + (q1, 0.1, 60.23857499635088), + (q1, 0.5, 243.8855469477619), + (q1, 0.9, 427.53251889917294), + (q1, 0.99, 481.40691058541813), + (q2, 0.01, 6.365673533661836), + (q2, 0.1, 60.31675895732397), + (q2, 0.5, 233.24592830045907), + (q2, 0.9, 346.42891253298706), + (q2, 0.99, 376.32659156736844), + (closedq, 0.01, 0.06261309767133393), + (closedq, 0.1, 0.5692099788303084), + (closedq, 0.5, 1.5811388300841898), + (closedq, 0.9, 2.5930676813380713), + (closedq, 0.99, 3.0996645624970456), + (linq, 0.01, 0.04203807797699605), + (linq, 0.1, 0.19379255804998186), + (linq, 0.5, 4.844813951249544), + (linq, 0.9, 18.0823363780483), + (linq, 0.99, 22.24410609777091)] + + for q, t, s in tests: + try: + self.assertAlmostEqual(q.ilength(s), t) + except: + print(q) + print(s) + print(t) + raise + + ############################################################### + # Cubics + c1 = CubicBezier(200 + 300j, 400 + 50j, 600+100j, -200) + symc = CubicBezier(1-2j, 10-1j, 10+1j, 1+2j) + closedc = CubicBezier(1-2j, 10-1j, 10+1j, 1-2j) + + tests = [(c1, 0.01, 9.53434737943073), + (c1, 0.1, 88.89941848775852), + (c1, 0.5, 278.5750942713189), + (c1, 0.9, 651.4957786584646), + (c1, 0.99, 840.2010603832538), + (symc, 0.01, 0.2690118556702902), + (symc, 0.1, 2.45230693868727), + (symc, 0.5, 7.256147083644424), + (symc, 0.9, 12.059987228602886), + (symc, 0.99, 14.243282311619401), + (closedc, 0.01, 0.26901140075538765), + (closedc, 0.1, 2.451722765460998), + (closedc, 0.5, 6.974058969750422), + (closedc, 0.9, 11.41781741489913), + (closedc, 0.99, 13.681324783697782)] + + for (c, t, s) in tests: + self.assertAlmostEqual(c.ilength(s), t) + + ############################################################### + # Arcs + arc1 = Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j) + arc2 = Arc(0j, 100 + 50j, 0, 1, 0, 100 + 50j) + arc3 = Arc(0j, 100 + 50j, 0, 0, 1, 100 + 50j) + arc4 = Arc(0j, 100 + 50j, 0, 1, 1, 100 + 50j) + arc5 = Arc(0j, 100 + 100j, 0, 0, 0, 200 + 0j) + arc6 = Arc(200 + 0j, 100 + 100j, 0, 0, 0, 0j) + arc7 = Arc(0j, 100 + 50j, 0, 0, 0, 100 + 50j) + + tests = [(arc1, 0.01, 0.785495042476231), + (arc1, 0.1, 7.949362877455911), + (arc1, 0.5, 48.28318721111137), + (arc1, 0.9, 105.44598206942156), + (arc1, 0.99, 119.53485487631241), + (arc2, 0.01, 4.71108115728524), + (arc2, 0.1, 45.84152747676626), + (arc2, 0.5, 169.38878996795734), + (arc2, 0.9, 337.44707303579696), + (arc2, 0.99, 360.95800139278765), + (arc3, 0.01, 1.5707478805335624), + (arc3, 0.1, 15.659620687424416), + (arc3, 0.5, 72.82241554573457), + (arc3, 0.9, 113.15623987939003), + (arc3, 0.99, 120.3201077143697), + (arc4, 0.01, 2.3588068777503897), + (arc4, 0.1, 25.869735234740887), + (arc4, 0.5, 193.9280183025816), + (arc4, 0.9, 317.4752807937718), + (arc4, 0.99, 358.6057271132536), + (arc5, 0.01, 3.141592653589793), + (arc5, 0.1, 31.415926535897935), + (arc5, 0.5, 157.07963267948966), + (arc5, 0.9, 282.7433388230814), + (arc5, 0.99, 311.01767270538954), + (arc6, 0.01, 3.141592653589793), + (arc6, 0.1, 31.415926535897928), + (arc6, 0.5, 157.07963267948966), + (arc6, 0.9, 282.7433388230814), + (arc6, 0.99, 311.01767270538954), + (arc7, 0.01, 0.785495042476231), + (arc7, 0.1, 7.949362877455911), + (arc7, 0.5, 48.28318721111137), + (arc7, 0.9, 105.44598206942156), + (arc7, 0.99, 119.53485487631241)] + + for (c, t, s) in tests: + self.assertAlmostEqual(c.ilength(s), t) + + ############################################################### + # Paths + line1 = Line(600 + 350j, 650 + 325j) + arc1 = Arc(650 + 325j, 25 + 25j, -30, 0, 1, 700 + 300j) + cub1 = CubicBezier(650 + 325j, 25 + 25j, -30, 700 + 300j) + cub2 = CubicBezier(700 + 300j, 800 + 400j, 750 + 200j, 600 + 100j) + quad3 = QuadraticBezier(600 + 100j, 600, 600 + 300j) + linez = Line(600 + 300j, 600 + 350j) + + bezpath = Path(line1, cub1, cub2, quad3) + bezpathz = Path(line1, cub1, cub2, quad3, linez) + path = Path(line1, arc1, cub2, quad3) + pathz = Path(line1, arc1, cub2, quad3, linez) + lpath = Path(linez) + qpath = Path(quad3) + cpath = Path(cub1) + apath = Path(arc1) + + tests = [(bezpath, 0.0, 0.0), + (bezpath, 0.1111111111111111, 286.2533595149515), + (bezpath, 0.2222222222222222, 503.8620222915423), + (bezpath, 0.3333333333333333, 592.6337135346268), + (bezpath, 0.4444444444444444, 644.3880677233315), + (bezpath, 0.5555555555555556, 835.0384185011363), + (bezpath, 0.6666666666666666, 1172.8729938994575), + (bezpath, 0.7777777777777778, 1308.6205983178952), + (bezpath, 0.8888888888888888, 1532.8473168900994), + (bezpath, 1.0, 1758.2427369258733), + (bezpathz, 0.0, 0.0), + (bezpathz, 0.1111111111111111, 294.15942308605435), + (bezpathz, 0.2222222222222222, 512.4295461513882), + (bezpathz, 0.3333333333333333, 594.0779370040138), + (bezpathz, 0.4444444444444444, 658.7361976564598), + (bezpathz, 0.5555555555555556, 874.1674336581542), + (bezpathz, 0.6666666666666666, 1204.2371344392693), + (bezpathz, 0.7777777777777778, 1356.773042865213), + (bezpathz, 0.8888888888888888, 1541.808492602876), + (bezpathz, 1.0, 1808.2427369258733), + (path, 0.0, 0.0), + (path, 0.1111111111111111, 81.44016397108298), + (path, 0.2222222222222222, 164.72556816469307), + (path, 0.3333333333333333, 206.71343564679154), + (path, 0.4444444444444444, 265.4898349999353), + (path, 0.5555555555555556, 367.5420981413199), + (path, 0.6666666666666666, 487.29863861165995), + (path, 0.7777777777777778, 511.84069655405284), + (path, 0.8888888888888888, 579.9530841780238), + (path, 1.0, 732.9614757397469), + (pathz, 0.0, 0.0), + (pathz, 0.1111111111111111, 86.99571952663854), + (pathz, 0.2222222222222222, 174.33662608180325), + (pathz, 0.3333333333333333, 214.42194393858466), + (pathz, 0.4444444444444444, 289.94661033436205), + (pathz, 0.5555555555555556, 408.38391100702125), + (pathz, 0.6666666666666666, 504.4309373835351), + (pathz, 0.7777777777777778, 533.774834546298), + (pathz, 0.8888888888888888, 652.931321760894), + (pathz, 1.0, 782.9614757397469), + (lpath, 0.0, 0.0), + (lpath, 0.1111111111111111, 5.555555555555555), + (lpath, 0.2222222222222222, 11.11111111111111), + (lpath, 0.3333333333333333, 16.666666666666664), + (lpath, 0.4444444444444444, 22.22222222222222), + (lpath, 0.5555555555555556, 27.77777777777778), + (lpath, 0.6666666666666666, 33.33333333333333), + (lpath, 0.7777777777777778, 38.88888888888889), + (lpath, 0.8888888888888888, 44.44444444444444), + (lpath, 1.0, 50.0), + (qpath, 0.0, 0.0), + (qpath, 0.1111111111111111, 17.28395061728395), + (qpath, 0.2222222222222222, 24.69135802469136), + (qpath, 0.3333333333333333, 27.777777777777786), + (qpath, 0.4444444444444444, 40.12345679012344), + (qpath, 0.5555555555555556, 62.3456790123457), + (qpath, 0.6666666666666666, 94.44444444444446), + (qpath, 0.7777777777777778, 136.41975308641975), + (qpath, 0.8888888888888888, 188.27160493827154), + (qpath, 1.0, 250.0), + (cpath, 0.0, 0.0), + (cpath, 0.1111111111111111, 207.35525375551356), + (cpath, 0.2222222222222222, 366.0583590267552), + (cpath, 0.3333333333333333, 474.34064293812787), + (cpath, 0.4444444444444444, 530.467036317684), + (cpath, 0.5555555555555556, 545.0444351253911), + (cpath, 0.6666666666666666, 598.9767847757622), + (cpath, 0.7777777777777778, 710.4080903390646), + (cpath, 0.8888888888888888, 881.1796899225557), + (cpath, 1.0, 1113.0914444911352), + (apath, 0.0, 0.0), + (apath, 0.1111111111111111, 9.756687033889872), + (apath, 0.2222222222222222, 19.51337406777974), + (apath, 0.3333333333333333, 29.27006110166961), + (apath, 0.4444444444444444, 39.02674813555948), + (apath, 0.5555555555555556, 48.783435169449355), + (apath, 0.6666666666666666, 58.54012220333922), + (apath, 0.7777777777777778, 68.2968092372291), + (apath, 0.8888888888888888, 78.05349627111896), + (apath, 1.0, 87.81018330500885)] + + for (c, t, s) in tests: + self.assertAlmostEqual(c.ilength(s), t) + + ############################################################### + # Exception Cases + nodalq = QuadraticBezier(1, 1, 1) + with self.assertRaises(AssertionError): + nodalq.ilength(1) + + lin = Line(0, 0.5j) + with self.assertRaises(ValueError): + lin.ilength(1) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/svgpathtools/tests/test_pathtools.py b/svgpathtools/tests/test_pathtools.py new file mode 100644 index 0000000..d688e0f --- /dev/null +++ b/svgpathtools/tests/test_pathtools.py @@ -0,0 +1,244 @@ +# External dependencies +from __future__ import division, absolute_import, print_function +import unittest +from numpy import poly1d + +# Internal dependencies +from svgpathtools import * + + +class TestPathTools(unittest.TestCase): + + def setUp(self): + self.arc1 = Arc(650+325j, 25+25j, -30.0, False, True, 700+300j) + self.line1 = Line(0, 100+100j) + self.quadratic1 = QuadraticBezier(100+100j, 150+150j, 300+200j) + self.cubic1 = CubicBezier(300+200j, 350+400j, 400+425j, 650+325j) + self.path_of_all_seg_types = Path(self.line1, self.quadratic1, + self.cubic1, self.arc1) + self.path_of_bezier_seg_types = Path(self.line1, self.quadratic1, + self.cubic1) + + def test_is_bezier_segment(self): + + # False + self.assertFalse(is_bezier_segment(self.arc1)) + self.assertFalse(is_bezier_segment(self.path_of_bezier_seg_types)) + + # True + self.assertTrue(is_bezier_segment(self.line1)) + self.assertTrue(is_bezier_segment(self.quadratic1)) + self.assertTrue(is_bezier_segment(self.cubic1)) + + def test_is_bezier_path(self): + + # False + self.assertFalse(is_bezier_path(self.path_of_all_seg_types)) + self.assertFalse(is_bezier_path(self.line1)) + self.assertFalse(is_bezier_path(self.quadratic1)) + self.assertFalse(is_bezier_path(self.cubic1)) + self.assertFalse(is_bezier_path(self.arc1)) + + # True + self.assertTrue(is_bezier_path(self.path_of_bezier_seg_types)) + self.assertTrue(is_bezier_path(Path())) + + def test_polynomial2bezier(self): + + def distfcn(tup1, tup2): + assert len(tup1) == len(tup2) + return sum((tup1[i]-tup2[i])**2 for i in range(len(tup1)))**0.5 + + # Case: Line + pcoeffs = [(-1.7-2j), (6+2j)] + p = poly1d(pcoeffs) + correct_bpoints = [(6+2j), (4.3+0j)] + + # Input poly1d object + bez = poly2bez(p) + bpoints = bez.bpoints() + self.assertAlmostEquals(distfcn(bpoints, correct_bpoints), 0) + + # Input list of coefficients + bpoints = poly2bez(pcoeffs, return_bpoints=True) + self.assertAlmostEquals(distfcn(bpoints, correct_bpoints), 0) + + # Case: Quadratic + pcoeffs = [(29.5+15.5j), (-31-19j), (7.5+5.5j)] + p = poly1d(pcoeffs) + correct_bpoints = [(7.5+5.5j), (-8-4j), (6+2j)] + + # Input poly1d object + bez = poly2bez(p) + bpoints = bez.bpoints() + self.assertAlmostEquals(distfcn(bpoints, correct_bpoints), 0) + + # Input list of coefficients + bpoints = poly2bez(pcoeffs, return_bpoints=True) + self.assertAlmostEquals(distfcn(bpoints, correct_bpoints), 0) + + # Case: Cubic + pcoeffs = [(-18.5-12.5j), (34.5+16.5j), (-18-6j), (6+2j)] + p = poly1d(pcoeffs) + correct_bpoints = [(6+2j), 0j, (5.5+3.5j), (4+0j)] + + # Input poly1d object + bez = poly2bez(p) + bpoints = bez.bpoints() + self.assertAlmostEquals(distfcn(bpoints, correct_bpoints), 0) + + # Input list of coefficients object + bpoints = poly2bez(pcoeffs, return_bpoints=True) + self.assertAlmostEquals(distfcn(bpoints, correct_bpoints), 0) + + def test_bpoints2bezier(self): + cubic_bpoints = [(6+2j), 0, (5.5+3.5j), (4+0j)] + quadratic_bpoints = [(6+2j), 0, (5.5+3.5j)] + line_bpoints = [(6+2j), 0] + self.assertTrue(isinstance(bpoints2bezier(cubic_bpoints), CubicBezier)) + self.assertTrue(isinstance(bpoints2bezier(quadratic_bpoints), + QuadraticBezier)) + self.assertTrue(isinstance(bpoints2bezier(line_bpoints), Line)) + self.assertSequenceEqual(bpoints2bezier(cubic_bpoints).bpoints(), + cubic_bpoints) + self.assertSequenceEqual(bpoints2bezier(quadratic_bpoints).bpoints(), + quadratic_bpoints) + self.assertSequenceEqual(bpoints2bezier(line_bpoints).bpoints(), + line_bpoints) + + # def test_line2pathd(self): + # bpoints = (0+1.5j, 100+10j) + # line = Line(*bpoints) + # + # # from Line object + # pathd = line2pathd(line) + # path = parse_path(pathd) + # self.assertTrue(path[0] == line) + # + # # from list of bpoints + # pathd = line2pathd(bpoints) + # path = parse_path(pathd) + # self.assertTrue(path[0] == line) + # + # def test_cubic2pathd(self): + # bpoints = (0+1.5j, 100+10j, 150-155.3j, 0) + # cubic = CubicBezier(*bpoints) + # + # # from Line object + # pathd = cubic2pathd(cubic) + # path = parse_path(pathd) + # self.assertTrue(path[0] == cubic) + # + # # from list of bpoints + # pathd = cubic2pathd(bpoints) + # path = parse_path(pathd) + # self.assertTrue(path[0] == cubic) + + def test_closest_point_in_path(self): + def distfcn(tup1, tup2): + assert len(tup1) == len(tup2) + return sum((tup1[i]-tup2[i])**2 for i in range(len(tup1)))**0.5 + + # Note: currently the radiialrange method is not implemented for Arc + # objects + # test_path = self.path_of_all_seg_types + # origin = -123 - 123j + # expected_result = ??? + # self.assertAlmostEqual(min_radius(origin, test_path), + # expected_result) + + # generic case (where is_bezier_path(test_path) == True) + test_path = self.path_of_bezier_seg_types + pt = 300+300j + expected_result = (29.382522853493143, 0.17477067969145446, 2) + result = closest_point_in_path(pt, test_path) + err = distfcn(expected_result, result) + self.assertAlmostEqual(err, 0) + + # cubic test with multiple valid solutions + test_path = Path(CubicBezier(1-2j, 10-1j, 10+1j, 1+2j)) + pt = 3 + expected_results = [(1.7191878932122302, 0.90731678233211366, 0), + (1.7191878932122304, 0.092683217667886342, 0)] + result = closest_point_in_path(pt, test_path) + err = min(distfcn(e_res, result) for e_res in expected_results) + self.assertAlmostEqual(err, 0) + + def test_farthest_point_in_path(self): + def distfcn(tup1, tup2): + assert len(tup1) == len(tup2) + return sum((tup1[i]-tup2[i])**2 for i in range(len(tup1)))**0.5 + + # Note: currently the radiialrange method is not implemented for Arc + # objects + # test_path = self.path_of_all_seg_types + # origin = -123 - 123j + # expected_result = ??? + # self.assertAlmostEqual(min_radius(origin, test_path), + # expected_result) + + # boundary test + test_path = self.path_of_bezier_seg_types + pt = 300+300j + expected_result = (424.26406871192853, 0, 0) + result = farthest_point_in_path(pt, test_path) + err = distfcn(expected_result, result) + self.assertAlmostEqual(err, 0) + + # non-boundary test + test_path = Path(CubicBezier(1-2j, 10-1j, 10+1j, 1+2j)) + pt = 3 + expected_result = (4.75, 0.5, 0) + result = farthest_point_in_path(pt, test_path) + err = distfcn(expected_result, result) + self.assertAlmostEqual(err, 0) + + def test_path_encloses_pt(self): + + line1 = Line(0, 100+100j) + quadratic1 = QuadraticBezier(100+100j, 150+150j, 300+200j) + cubic1 = CubicBezier(300+200j, 350+400j, 400+425j, 650+325j) + line2 = Line(650+325j, 650+10j) + line3 = Line(650+10j, 0) + open_bez_path = Path(line1, quadratic1, cubic1) + closed_bez_path = Path(line1, quadratic1, cubic1, line2, line3) + + inside_pt = 200+20j + outside_pt1 = 1000+1000j + outside_pt2 = 800+800j + boundary_pt = 50+50j + + # Note: currently the intersect() method is not implemented for Arc + # objects + # arc1 = Arc(650+325j, 25+25j, -30.0, False, True, 700+300j) + # closed_path_with_arc = Path(line1, quadratic1, cubic1, arc1) + # self.assertTrue( + # path_encloses_pt(inside_pt, outside_pt2, closed_path_with_arc)) + + # True cases + self.assertTrue( + path_encloses_pt(inside_pt, outside_pt2, closed_bez_path)) + self.assertTrue( + path_encloses_pt(boundary_pt, outside_pt2, closed_bez_path)) + + # False cases + self.assertFalse( + path_encloses_pt(outside_pt1, outside_pt2, closed_bez_path)) + + # Exception Cases + with self.assertRaises(AssertionError): + path_encloses_pt(inside_pt, outside_pt2, open_bez_path) + + # Display test paths and points + # ns2d = [inside_pt, outside_pt1, outside_pt2, boundary_pt] + # ncolors = ['green', 'red', 'orange', 'purple'] + # disvg(closed_path_with_arc, nodes=ns2d, node_colors=ncolors, + # openinbrowser=True) + # disvg(open_bez_path, nodes=ns2d, node_colors=ncolors, + # openinbrowser=True) + # disvg(closed_bez_path, nodes=ns2d, node_colors=ncolors, + # openinbrowser=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/svgpathtools/tests/test_polytools.py b/svgpathtools/tests/test_polytools.py new file mode 100644 index 0000000..45f1685 --- /dev/null +++ b/svgpathtools/tests/test_polytools.py @@ -0,0 +1,30 @@ +# External dependencies +from __future__ import division, absolute_import, print_function +import unittest +import numpy as np + +# Internal dependencies +from svgpathtools import * + + +class Test_polytools(unittest.TestCase): + # def test_poly_roots(self): + # self.fail() + + def test_rational_limit(self): + + # (3x^3 + x)/(4x^2 - 2x) -> -1/2 as x->0 + f = np.poly1d([3, 0, 1, 0]) + g = np.poly1d([4, -2, 0]) + lim = rational_limit(f, g, 0) + self.assertAlmostEqual(lim, -0.5) + + # (3x^2)/(4x^2 - 2x) -> 0 as x->0 + f = np.poly1d([3, 0, 0]) + g = np.poly1d([4, -2, 0]) + lim = rational_limit(f, g, 0) + self.assertAlmostEqual(lim, 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/test.svg b/test.svg new file mode 100644 index 0000000..3d1b43f --- /dev/null +++ b/test.svg @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/vectorframes.svg b/vectorframes.svg new file mode 100644 index 0000000..950b654 --- /dev/null +++ b/vectorframes.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + b's tangent + + + br's tangent + +