Click here to Skip to main content
15,867,453 members
Articles / Programming Languages / Java
Article

Spiro2SVG: Convert Spirographs to SVG Using Bezier Curves

Rate me:
Please Sign up or sign in to vote.
5.00/5 (5 votes)
12 Dec 2016CPOL17 min read 11K   355   6   2
A file converter to convert spirograph data files into SVG graphics using Bezier curves.

Based on the Open Source project Spiro2SVG. This article will discuss a portion of that project, focusing just on standard spirograph shapes.

Introduction

There are dozens of free programs available for generating spirograph shapes. They can be found at Code Project or GitHub or SourceForge or Android Apps or Launchpad. Two things that most of these programs have in common is that they will typically allow you to save the image only in the form of a bitmap, and that they typically do the rendering of the image using a sequence of many, perhaps hundreds, of short straight lines sometimes referred to as a polyline. This is a leftover from the old days of CNC machining where the hardware could only render straight lines, and where splines could only be produced with a French curve. The Spiro2SVG project is an attempt to move beyond this, into an inherently vector-graphics representation that uses only splines. The program will convert spirograph data files, which are typically saved in XML format, into a vector-graphics format (SVG) using cubic Bezier curves. This leads to smaller file size and an image that is always smooth, even if you zoom in; and a file format that is well-suited to websites. It also avoids a very difficult question, namely, how many small lines are necessary to produce a good quality image?

Using the Program

The current version will convert *.spiro files produced by the program SpiroGraph_1.0.2.1. This program comes complete with a library of sample files as well as an online gallery of images. These images obey the equations:

x = (a + b) cos(t) + c cos((1 + a/b)t)(1)
y = (a + b) sin(t) + c sin((1 + a/b)t)(2)


These shapes are known as epitrochoids (b > 0) or hypotrochoids (b < 0). Subsequent articles in this series will describe the conversion of other types of shapes.

Image 1

The program can be started by double-clicking on the Spiro2SVG.jar file. It will ask you to browse to locate a .spiro file, some of which are included in the .\Samples folder. This will bring up a display of the configuration of the images. For each object the following fields are shown:

Java
public static final String[][] spiroNames = new String[][] 
            {{"StatorRadius"}, {"RotorRadius"}, {"NumRotations"}, {"AnglesPerCycle"},
             {"RotorSlide"}, {"OriginX"}, {"OriginY"}, {"InitialAngle"},
             {"PenDistance"}, {"Lock"}, {"CurvePenWidth"}, {"Zoom"},
             {"Argb"}, {"FillArgb"}, {"FillMode"}, {"Edit Drawing Style"}};

For each object, the bottom field, "Edit Drawing Style", can be individually edited to be either Points, Lines, or Bezier. Using Lines should produce an image identical to that of the original SpiroGraph_1.0.2.1 program. Using Bezier will produce a smooth interpolation based on a reduced number of Bezier fit points. There are cases where it may actually be advantageous to use the Lines rendering method, if the original author of the image has deliberately used a small number of lines, AnglesPerCycle, in order to produce a somewhat jagged appearance. Otherwise, the default shown in the display can be used. The default is based on the assumption that any object that has less than 20 lines per lobe was intended to be drawn jagged with Lines; otherwise it will be drawn smooth using a Bezier curve. (A lobe is the smallest unique portion of the spirograph, typically there will be a/b lobes in each revolution of the spirograph.) Once the rendering method has been chosen, click OK to bring up a browse dialog to save the SVG file. The program can also be run silently from the CLI using the command 'java -jar Spiro2SVG.jar -?' to see the available options. This might be useful if you wanted to convert an entire directory using a batch file, or if you wish to capture any error messages that may be generated. In this case one can force the rendering method to be the same for all objects using the options (-p, -l, -b) or one can use the default method described above.

Program Calculation Sequence

  1. main.main(String[] args) - this will start the program using either CLI or a JFileChooser to specify the input data file. Browse to the Samples folder to find suitable files.
  2. SpiroParse.parse_spiro_file(String fname, String m_style) - this will read the data file and populate the arrays main.rowNames and main.rowData which contain the field names and field values for each object.
  3. SpiroConfig.showDialog(JDialog parent, String fname) - if the program is not being run silently, this dialog will allow the choice of Drawing Style. This dialog has been written in a fairly generic way to allow for the use of different sets of field names coming from different types of data files.
  4. SpiroWrite.write_svg_file(String fname, boolean m_isspiro) - this initiates the write sequence by writing an <svg> header and some individual <path> properties such as fill, stroke, and stroke-width. These will constitute the SVG style property. The detailed path information will be calculated below.
  5. SpiroWrite.convertspiroParms(int index, FileWriter out) - for each spirograph object, convert the incoming data fields into a format that is consistent with Eq.(1). This is to allow for the possibility of different data sources in the future.
  6. SpiroWrite.getPath(FileWriter out, PathIterator pit) - this is a generic converter from a PathIterator (which operates on a Java Shape object) to a text format which will specify the "d" parameter in an SVG <path> element. In Java, the flow of path information is as follows:
    - at the bottom-most level we have either Rectangle2D, Line2D, or CubicCurve2D objects, depending on which Drawing Style was chosen. These objects are created in the routine SpiroWrite.getspiroShape(int index, double a, double b, double c)
    - these are concatenated into a Path2D object to form complex shapes.
    - then we operate on the final path using an AffineTransform.createTransformedShape(Shape pSrc) which will perform overall positioning, scaling, and possibly rotation, of each object.
    - this produces a Shape object, which we then interrogate using Shape.getPathIterator() to produce detailed information on each segment of the path.
    - the PathIterator information is then fed into the "d" parameter in the SVG <path>.
  7. SpiroWrite.getspiroShape(int index, double a, double b, double c) - this is the source of the Java path information. It will return a sequence of either Line2D, Rectangle2D, or CubicCurve2D segments. In the first two cases, the processing is complete at this point. However, for the Bezier drawing style, the interesting part has just begun. The Bezier fitting process is a two-stage affair. First we choose a set of interesting t values to be used in Eq.(1) to specify start points and end points of each segment, using SpiroCalc.get_t_values(t_values, a, b, c). Then we fit a Bezier curve to each segment, using SpiroCalc.getBezier(t_old, t_new). This will be described below.

Finding t values on a Hypotrochoid (b < 0)

First some assumptions: we will assume a > 0 (stator radius) and b < 0 for a hypotrochoid, so the rotor is rolling on the inside of the stator. We also assume c > 0, although in fact this is not important. Changing the sign of c is equivalent to rotating the entire object by πb/a, which does not change the shape in any way. To fit the hypotrochoid all that is necessary is to analyze the first lobe, which is in the range t ∈ (0, 2πb/a). Subsequent lobes will just be rotational clones of the first lobe. In addition, each lobe has a reflection symmetry, so that if t corresponds to a specific feature of the curve then 2πb/a-t will also correpond to the same feature in the reflected version of the lobe. We wish to choose t values to correspond to easily identifiable features like maximum radius, minimum radius, inflection points where the curvature is zero, and cusps where the motion is temporarily stationary. In addition we need to impose one non-trivial constraint, which is that the maximum arc angle cannot be allowed to be greater than 90 degrees. By arc angle we mean the difference in angle between the tangent vector at the start and the tangent vector at the end of the segment. If this angle is greater than 90 degrees it is not possible to get a reliable Bezier fit.

Next some definitions: we define slope as dy/dx = ẏ/ẋ where ẋ means dx/dt. However, if the point is stationary, meaning x and y are quadratic functions of t, not linear, then we define dy/dx as ÿ/ẍ. The curvature is defined by:

κ = (ẋÿ - ẏẍ) / (ẋ2 + ẏ2)3/2(3)

Before trying to calculate the fit points described above, we need to first classify the hypotrochoid shapes to determine whether these features will exist. The classification depends on the size of c. There are three special values of c:

  • c = 0, which yields a circle.
  • c = b/(1 + a/b), which is the smallest c value that will produce an inflection point.
  • c = -b, this yields a hypocycloid, which has a cusp (stationary point).

For c > b/(1 + a/b), we will have two inflection points per lobe.
For c > -b, the inflection points disappear and are replaced by an extra loop (convex) which will necessitate the creation of two new fit points which are located 90 degrees away from the point of maximum radius, so that they occur at the point of maximum width of the new loop. For the hypotrochoid (first lobe) this is a point of zero slope.

With this classification we can calculate the fit points as follows:

  1. maximum radius will always occur at t = 0
  2. minimum radius will always occur at t = πb/a
  3. cusp, if it exists, will occur at t = 0
  4. inflection point, if it exists, will occur when:
    cos(at/b) = (1 + c2(1 + a/b)/b2) b / c / (2 + a/b)
  5. point of maximum width of the extra loop, if it exists, will be a solution of the equation:
    cos(t) = -(c/b) cos((1+a/b)*t)
    This equation is solved in calc_cos_t(double fAmp, double fFreq) using the Newton-Raphson method.

The graph below shows five typical hypotrochoids with the parameter values a = 210, b = -70, c = {10, 30, 50, 70, 90}. The five shapes illustrate the cases of: no inflection points, two inflection points, a cusp, and an extra loop. In each case the fit points for the first lobe are shown as red rectangles.
Image 2

Finding t values on an Epitrochoid (b > 0)

The process of finding suitable t values on an epitrochoid is slightly more complex than it was for a hypotrochoid, as follows. For a hypotrochoid, if we consider the case where a/b = N, an integer, and if we count the number of full revolutions that the tangent to the curve makes as we traverse the entire curve back to the beginning, we find the tangent vector executes N-1 revolutions. The number of unique lobes is N. Therefore if we implement a procedure that produces four fit points per lobe, then we are guaranteed that the arc angle per segment will never be more than 90 degrees, which is mandatory. This is assuming the points are chosen carefully, which is easy enough to do. For an epitrochoid, however, the number of revolutions of the tangent vector is N+1, so four fit points per lobe will not be sufficient, especially at small N.

In addition to the fit points previously defined for the hypotrochoid, we now consider some new candidates:

  1. If c > b there will be two loops in each unique lobe, a large one (convex) which goes through the maximum radius, and a small one (concave) which goes through the minimum radius. It is tempting to try to perform a fit at the maximum width point of both of these loops, which would give us one more fit point than previously. However, for the large loop, there is a problem in that the point of maximum width may sometimes disappear. It is hard to predict a priori when this will happen. All we know is that, as c decreases, the point of maximum width of the large loop will merge with an inflection point and disappear, leaving us with an equation that is unsolvable. Since we cannot anticipate when this will happen, we need an alternative approach. The alternative is to first find the point of maximum width of the small (concave) loop, and then choose another fit point that is 90 degrees away from this point, so it has a tangent that is parallel to the tangent at the minimum radius. This is guaranteed to exist (in fact, it exists even if the small loop does not exist) and it satisfies the requirement of having 90 degrees, or less, arc per segment. This solution will be found using calc_sin_t(double fAmp, double fFreq).
  2. It has been found experimentally that, if one endpoint is a cusp point or even near to a cusp point (c ≈ b), and if the total arc angle is close to 90 degrees, then the solution may behave badly. This typically shows up as a negative Bezier arm length (see below). To prevent this we may insert an interpolated t value between the two points that are nearest to the cusp. One additional fit point can greatly improve the accuracy in this case.
  3. In the alternative scenario, where no cusp exists, but where we are at an inflection point or even near an inflection point (c ≈ b/(1 + a/b)), it has been found that the solution may similarly behave badly, and so we also insert an additional interpolated t value in this case.
  4. In the case where a < 2b (number of lobes is less than 2) it is necessary to insert a new t value at the point of maximum width of the large (convex) loop in order to avoid having an arc angle greater than 90 degrees.

The graph below shows four typical epitrochoids with the parameter values a = 150, b = 50, c = {10, 30, 50, 70}. The four shapes illustrate the cases of: no inflection points, two inflection points, a cusp, and an extra loop. In each case the fit points for the first lobe are shown as red rectangles.
Image 3

Fitting a cubic Bezier to a spirograph

The cubic Bezier is defined parametrically in the range t ∈ (0,1) by the equations:

x = x0 (1-t)3 + 3 x1 t(1-t)2 + 3 x2 t2(1-t) + x3 t3(4)
y = y0 (1-t)3 + 3 y1 t(1-t)2 + 3 y2 t2(1-t) + y3 t3(5)

This curve has eight unknowns: four of these will be specified using the start- and end-point positions. Two more will be specified by matching the slopes of the Bezier control arms which go from (x0, y0) to (x1, y1) and from (x3, y3) to (x2, y2). Finally, the start- and end-point curvatures from Eq.(3) will be used to specify the length of the Bezier control arms. The actual solution of the fitting equation will be performed in main.calcBezier(Point2D.Double[][] ptSpiro, double t1, double t2, double max_v). However, before doing that, it is necessary to do some pre-processing in SpiroCalc.getBezier in order to determine the curvature and the orientation of the tangent at the two endpoints. (Both of these calculations depend on whether the endpoint is stationary or not, so there are two sets of formulas for doing this.) The tangent angles are important for the following practical reason: if we solve the equations using the original curve orientation we will have to deal with a large number of special cases depending on whether the tangent angle is vertical at the start or the end, and whether the start or end has zero curvature, or is a cusp, etc. To try to reduce the number of special cases we will re-orient all curve segments in such a way that the start and end tangents will never be vertical, by using an orientation that is in between the two directions. This is made possible by the fact that the curvature is invariant with respect to orientation. After the solution is complete, we will then transform the solution back into the original orientation before using it. Next we define the array

Java
Point2D.Double[][] ptSpiro = new Point2D.Double[3][2];

This contains all the necessary information about each individual spirograph segment. The first index (0-2) specifies whether this is the spirograph position or first derivative or second derivative with respect to t, and the second index (0-1) specifies either a start point or an end point. This is the information we will try to match by adjusting the Bezier curve. The ptSpiro data is sent to main.calcBezier. This routine will transform the data into the new rotated frame, using the routines getrotX() and getrotY(), before solving the equations.

When fitting the Bezier curve, the only non-trivial part is the process of fitting the curvature. Before doing this, the original definition of κ in Eq.(3) will be simplified a bit. The denominator in this expression can be written in the alternate form

(ẋ2 + ẏ2)3/2 = ẋ3 (1 + α2)3/2

where α is the slope dy/dx. However, we will be constraining the Bezier slope to equal the spirograph slope, so the term involving α is common to both objects and can be factored out. This means we will be matching a reduced curvature expression in the form

Cu = (ÿ - α ẍ) / ẋ2

where Cu is a constant which will be evaluated using spirograph parameters and the right-hand side is a function which will be based on Bezier parameters. Writing this out explicitly we get the following two constraints:

Cu0 = (2/3) ((y0 - 2y1 + y2) - α0 (x0 - 2x1 + x2)) / (x1 - x0)2
Cu1 = (2/3) ((y1 - 2y2 + y3) - α1 (x1 - 2x2 + x3)) / (x3 - x2)2

where Cui refers to the reduced spirograph curvature at either the start- or end-point, and αi is the corresponding slope:

α0 = (y1 - y0) / (x1 - x0)
α1 = (y3 - y2) / (x3 - x2)

Now we introduce the unknown Bezier arm length using the notation:

δ0 = x1 - x0
δ1 = x3 - x2

which yields the final set of equations:

(3/2) Cu0 δ02 =   (y3 - y0) - α0 (x3 - x0) - δ11 - α0)(6)
(3/2) Cu1 δ12 = -(y3 - y0) + α1 (x3 - x0) - δ01 - α0)(7)

In these equations, only the arm lengths δ0 and δ1 are unknown. We can now identify a few special cases:

  • α0 = α1 : endpoint slopes are parallel. In this case the equations are decoupled and can be solved independently of each other.
  • Cui = 0 : if an endpoint curvature is zero, this equation becomes linear in δ, and the solution is straightforward.
  • general case : In this case we solve Eq.(6) for δ1 as a function of δ0, and plug this into Eq.(7) to obtain a quartic equation for δ0. This is solved analytically in solve_quartic(). Of the four solutions, we choose the one that has the right sign, since we expect the two Bezier arms to be pointing inwards, towards each other.

Before accepting the results of the curve fit, there is one final issue that needs to be dealt with. If the curvature is zero at an endpoint, or if the endpoint is stationary, and if the arc angle is nearly 90 degrees, it can happen that the Bezier arm length may be slightly negative, pointing the wrong way. If this happens, the arm length will be arbitrarily set to zero, meaning that the curvature at the other endpoint will not be exactly correct. However, this is fairly rare, and is better than the obvious defect that would show up due to a reversal of direction of a Bezier arm. Finally, as a diagnostic tool, if one wishes to qualitatively confirm the solution, it is quite easy to visually display the Bezier control arms in Inkscape, using the node tool.

Conclusion

Putting it all together, we get results like this, which is an SVG file based on the sample file 00_Animation_Simple.spiro from SpiroGraph_1.0.2.1.

Image 4

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer Bosch Rexroth Canada
Canada Canada
I am a (retired) C programmer working in the area of process control of mobile hydraulics.
Play with vector graphics for fun; especially Inkscape, which I think is the coolest thing since sliced bread.

Comments and Discussions

 
QuestionREALLY nice, but... Pin
ezytim15-Sep-23 3:13
ezytim15-Sep-23 3:13 
GeneralMy vote of 5 Pin
James Hunter Ross13-Dec-16 7:49
James Hunter Ross13-Dec-16 7:49 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.