From 9b3d6fe5e3513e03235822e4bbf5352abef46dd2 Mon Sep 17 00:00:00 2001 From: Andy Date: Tue, 5 Jul 2016 21:51:11 -0700 Subject: [PATCH] initial commit --- .gitignore | 4 + LICENSE.txt | 21 + LICENSE2.txt | 21 + MANIFEST | 12 + README.ipynb | 753 +++ README.rst | 634 ++ decorated_ellipse.svg | 7 + dist/svgpathtools-1.0.tar.gz | Bin 0 -> 39797 bytes disvg_output.svg | 8 + offsetcurves.svg | 9005 +++++++++++++++++++++++++ output1.svg | 8 + output2.svg | 21 + output_intersections.svg | 12 + path.svg | 5 + setup.py | 25 + svgpathtools/__init__.py | 19 + svgpathtools/bezier.py | 374 + svgpathtools/misctools.py | 64 + svgpathtools/parser.py | 195 + svgpathtools/path.py | 2130 ++++++ svgpathtools/paths2svg.py | 379 ++ svgpathtools/pathtools.py | 14 + svgpathtools/polytools.py | 80 + svgpathtools/smoothing.py | 201 + svgpathtools/svg2paths.py | 87 + svgpathtools/tests/__init__.py | 0 svgpathtools/tests/test.svg | 67 + svgpathtools/tests/test_bezier.py | 21 + svgpathtools/tests/test_generation.py | 56 + svgpathtools/tests/test_parsing.py | 139 + svgpathtools/tests/test_path.py | 906 +++ svgpathtools/tests/test_pathtools.py | 244 + svgpathtools/tests/test_polytools.py | 30 + test.svg | 25 + vectorframes.svg | 21 + 35 files changed, 15588 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 LICENSE2.txt create mode 100644 MANIFEST create mode 100644 README.ipynb create mode 100644 README.rst create mode 100644 decorated_ellipse.svg create mode 100644 dist/svgpathtools-1.0.tar.gz create mode 100644 disvg_output.svg create mode 100644 offsetcurves.svg create mode 100644 output1.svg create mode 100644 output2.svg create mode 100644 output_intersections.svg create mode 100644 path.svg create mode 100644 setup.py create mode 100644 svgpathtools/__init__.py create mode 100644 svgpathtools/bezier.py create mode 100644 svgpathtools/misctools.py create mode 100644 svgpathtools/parser.py create mode 100644 svgpathtools/path.py create mode 100644 svgpathtools/paths2svg.py create mode 100644 svgpathtools/pathtools.py create mode 100644 svgpathtools/polytools.py create mode 100644 svgpathtools/smoothing.py create mode 100644 svgpathtools/svg2paths.py create mode 100644 svgpathtools/tests/__init__.py create mode 100644 svgpathtools/tests/test.svg create mode 100644 svgpathtools/tests/test_bezier.py create mode 100644 svgpathtools/tests/test_generation.py create mode 100644 svgpathtools/tests/test_parsing.py create mode 100644 svgpathtools/tests/test_path.py create mode 100644 svgpathtools/tests/test_pathtools.py create mode 100644 svgpathtools/tests/test_polytools.py create mode 100644 test.svg create mode 100644 vectorframes.svg 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 0000000000000000000000000000000000000000..f6567afe53c23c51be894007438141f9b9fb2f91 GIT binary patch literal 39797 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 literal 0 HcmV?d00001 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 + +