Imitation of freehand drawing on the example of RoughJS

RoughJS is a small (< 9KB ) JavaScript graphics library that allows you to draw in sketchy, handwritten style. It allows you to draw on <canvas>and with SVG. In this post I want to answer the most popular question about RoughJS: how does it work?


A bit of history


Fascinated by the images of hand-drawn graphs, diagrams and sketches, I, like a true nerd, asked myself: can I create such drawings with the help of a code, can I imitate the drawing as accurately as possible by hand, while still preserving the possibility of software implementation? I decided to focus on the primitives — lines, polygons, ellipses, and curves — to create an entire library of 2D graphics. Based on it, you can create libraries and graphs for drawing graphs and diagrams.

After briefly examining the issue, I found an article by Joe Wood and his colleagues called Sketchy rendering for information visualization . The techniques described in it became the basis of the library, especially in drawing lines and ellipses.

In 2017, I wrote the first version of the library, which only worked on Canvas. Having solved the problem, I lost interest in it. A year later, I worked a lot with SVG, and decided to adapt RoughJS to work with SVG. I also changed the structure of the API to make it simpler, and focused on simple vector graphics primitives. I talked about version 2.0 on Hacker News and suddenly it gained immense popularity. In 2018, it was ShowHN's second most popular post .

Since then, other people have created more amazing things based on RoughJS, for example, Excalidraw , Why do Cats & Dogs ... , the roughViz graphics library .

Now let's talk about algorithms ...

Unevenness


The fundamental basis for imitation of handwritten figures is chance. When we draw by hand, any two shapes will be somewhat different. No one draws perfectly precisely, so every spatial point in RoughJS is adjusted for random displacement. The magnitude of randomness is given by a numerical parameter roughness.


Imagine a point Aand a circle around it. Now replace with a Arandom point within this circle. The area of ​​this circle of randomness is controlled by value roughness.

Lines


Handwritten lines are never straight and often show curvature in the bend (described here ). We randomize the two endpoints of the line based on roughness. Then we select two more random points approximately at a distance of 50% and 75% from the end of the segment. By connecting these points of the curve, we get the effect of bending .


When drawing by hand, people sometimes move the pencil forward and backward along the line. This is necessary either to make the line brighter, or simply to correct the straightness of the line. It looks something like this:


To add a sketchy effect, RoughJS draws a line twice. In the future I plan to make this aspect more customizable.

Look at this canvas surface. The roughness parameter changes the appearance of the lines:


In the original article on canvas, you can draw yourself.

When drawing by hand, long lines usually become less straight and more curved. That is, the randomness of offsets to create an effect is a function of line length and value randomness. However, scaling this function is not suitable for very long lines. For example, in the image below, concentric squares are drawn with the same random seed, i.e. in fact, they are one random figure, but with a different scale.


You may notice that the edges of the outer squares look a little more uneven than the inner ones. Therefore, I also added a damping factor depending on the line length. The attenuation coefficient is used as a step function at various lengths.


Ellipses (and circles)


Take a sheet of paper and draw a few circles as quickly as possible in one continuous motion. Here's what I got:


Note that the start and end points of the loop do not always match. RoughJS tries to imitate this, while making the appearance more complete (the technique is adapted from the giCenter article ).

The algorithm finds the nellipse points where it nis determined by the size of the ellipse. Then each point is randomized by its value roughness. Then a curve is drawn through these points. To get the effect of the disconnected ends, the points from the second to the last do not coincide with the first point. Instead, the curve connects the second and third points.


A second ellipse is also drawn so that the loop is more closed and has an additional sketch effect.

In the original article, you can draw ellipses on an interactive canvas surface. Vary roughness and watch how the form changes:


In the case of line drawing, some of these artifacts become more accentuated if some shape is scaled to different sizes. In an ellipse, this effect is more noticeable because the ratio is quadratic. In the image below, all circles have the same shape, but the outer ones look more uneven.


The algorithm automatically adjusts based on the size of the shape, increasing the number of points in the circle ( n). Below is the same set of circles generated using automatic tuning.


Filling out


Dotted lines are usually used to fill in hand-drawn shapes . In the case of freehand sketches, the lines do not always remain within the outline of the shapes. They are also randomized. Density, angle, line width can be adjusted.


The squares shown above are easy to fill, but in the case of other shapes, all kinds of problems can occur. For example, concave polygons (in which angles can exceed 180 °) often cause such problems:


In fact, the image above is taken from an error report in one of the previous versions of RoughJS. Since then, I have updated the stroke filling algorithm by adapting the version of the string scanning method .

The string scanning algorithm can be used to fill any polygon. Its principle is to scan a polygon using horizontal lines (raster lines). Raster lines go from the top of the polygon down. For each raster line, we determine at what points the line intersects with the polygon. We build these intersection points from left to right.


Moving from point to point, we switch from filling mode to non-filling mode; switching between states occurs when each intersection point on the raster line meets. Here much more needs to be taken into account, in particular, borderline cases and scanning optimization methods; You can read more about this here: Rasterizing polygons , or deploy a spoiler with pseudo-code.

Details of the implementation of the string scanning algorithm
() .

— (Edge Table, ET), , Ymin. Ymin, Xmin.

— (Active Edge Table, AET), , .

:

interface EdgeTableEntry {
  ymin: number;
  ymax: number;
  x: number; // Initialized to Xmin
  iSlope: number; // Inverse of the slope of the line: 1/m
}

interface ActiveEdgeTableEntry {
  scanlineY: number; // The y value of the scanline
  edge: EdgeTableEntry;
}

, :

1. y y ET. .

2. AET .

3. , AET, ET :

(a) ET y AET , ymin ≤ y.

(b) AET , y = ymax, AET x.

() y, x AET.

(d) y , , .. .

(e) , AET, x y (edge.x = edge.x + edge.iSlope)

In the image below (in the original article interactive), each square denotes a pixel. You can move the vertices to change the polygon and observe which pixels will be filled traditionally.


When filling in strokes, the increment of raster lines is performed in increments depending on the given density of lines of strokes, and each line is drawn using the algorithm described above.

However, this algorithm is for horizontal raster lines. To implement different angles of strokes, the algorithm first rotates the shape itself by the desired angle of strokes. Then the raster lines for the rotated figure are calculated. Further, the calculated lines rotate back to the angle of the strokes in the opposite direction.


Not just filling out strokes


RoughJS also supports other fill styles, but they are all derived from the same hatching algorithm. Cross hatching consists in drawing dashed lines at an angle angle, and then another lines at an angle angle + 90°. Zigzag seeks to connect one dashed line with the previous one. To obtain a dot pattern, draw small circles along the dashed lines.


The curves


Everything in RoughJS is normalized to curves - lines, polygons, ellipses, etc. Therefore, the natural development of this idea is to create a sketch curve. In RoughJS, we pass a set of points to a curve, after which we use the curve approximation to convert them to cubic Bezier curves .

Each Bezier curve has two end points and two control points. By randomizing them on the basis roughness, you can similarly create “handwritten” curves.


Curve Filling


However, the inverse process is required to fill the curves. Instead of normalizing everything to a curve, the curve normalizes to a polygon. After obtaining the polygon, you can use the line scanning algorithm to fill the curved shape.

You can sample points on the curve with the desired frequency using the equation of the cubic Bezier curve .


If we use the sampling frequency, which depends on the density of the strokes, then we get enough points to fill the figure. But this is not particularly effective. If part of the curve is sharp, then we need more points. If part of the curve is almost straight, then fewer points are needed. One solution may be to determine the curvature / smoothness of the curve. If it is very curved, then we divide the curve into two smaller curves. If it is smooth, then we will consider it simply as a straight line.

The smoothness of the curve is calculated using the method described in this post . The smoothness value is compared with the tolerance value, after which a decision is made whether to split the curve or not.

Here is the same curve with a tolerance level of 0.7:


Based on the tolerance alone, the algorithm provides enough points to represent the curve. However, it does not allow you to effectively get rid of optional points. This will help the second parameter called distance . To reduce the number of points in this method, the Ramer-Douglas-Pecker algorithm is used .

The following shows the points generated with values of distance, equal to 0.15, 0.75, 1.5and 3.0.


Based on the roughness of the shape, you can set the appropriate distance value . Having received all the vertices of the polygon, we can beautifully fill the curved shapes:


SVG circuits


SVG contours are a very powerful tool that can be used to create all kinds of stunning images, but because of this, it is quite difficult to work with them.

RoughJS parses the path and normalizes it in just three operations: Move , Line and Cubic Curve . ( path-data-parser ). After normalization, the figure can be drawn using the above methods of drawing lines and curves.

The points-on-path package combines the normalization of paths and the sampling of curve points to calculate the corresponding path points.

The following is an example point calculation for a path M240,100c50,0,0,125,50,100s0,-125,50,-150s175,50,50,100s-175,50,-300,0s0,-125,50,-100s0,125,50,150s0,-100,50,-100:


Another SVG example that I love to show is the outline map of the United States:


Try RoughJS


Check out the website or repository on Github or the API documentation . Follow Twitter @RoughLib for project information .

All Articles