Pull to refresh

Various things in MetaPost

Reading time8 min
Original author: Sergey Slyusarev
What is the best tool to use for drawing vector pictures? For me and probably for many others, the answer is pretty obvious: Illustrator, or, maybe, Inkscape. At least that's what I thought when I was asked to draw about eight hundred diagrams for a physics textbook. Nothing exceptional, just a bunch of black and white illustrations with spheres, springs, pulleys, lenses and so on. By that time it was already known that the book was going to be made in LaTeX and I was given a number of MS Word documents with embedded images. Some of them were scanned pictures from other books, some were pencil drawings. Picturing days and nights of inkscaping this stuff made me feel dizzy, so soon I found myself fantasizing about a more automated solution. For some reason MetaPost became the focus of these fantasies.

The main advantage of using MetaPost (or similar) solution is that every picture can be a sort of function of several variables. Such a picture can be quickly adjusted for any unforeseen circumstances of the layout, without disrupting important internal relationships of the illustration (something I was really concerned about), which is not easily achieved with more traditional tools. Also, recurring elements, all these spheres and springs, can be made more visually interesting than conventional tools would allow with the same time constraints.

I wanted to make pictures with some kind of hatching, not unlike what you encounter in old books.

First, I needed to be able to produce some curves of varying thickness. The main complication here is to construct a curve that follows the original curve at a varying distance. I used probably the most primitive working method, which boils down to simply shifting the line segments connecting Bezier curve control points by a given distance, except this distance varies along the curve.

In most cases, it worked OK.

Example code
From here on it is assumed that the library is downloaded and input fiziko.mp; is present in the MetaPost code. The fastest method is to use ConTeXt (then you don't need beginfig and endfig):

input fiziko.mp;
% the code goes here

or LuaLaTeX:

input fiziko.mp;
% the code goes here

path p, q; % MetaPost's syntax is reasonably readable, so I'll comment mostly on my stuff
p := (0,-1/4cm){dir(30)}..(5cm, 0)..{dir(30)}(10cm, 1/4cm);
q := offsetPath(p)(1cm*sin(offsetPathLength*pi)); % first argument is the path itself, second is a function of the position along this path (offsetPathLength changes from 0 to 1), which determines how far the outline is from the original line
draw p;
draw q dashed evenly;

Two outlines can be combined to make a contour line for a variable-thickness stroke.

Example code
path p, q[];
p := (0,-1/4cm){dir(30)}..(5cm, 0)..{dir(30)}(10cm, 1/4cm);
q1 := offsetPath(p)(1/2pt*(sin(offsetPathLength*pi)**2)); % outline on one side
q2 := offsetPath(p)(-1/2pt*(sin(offsetPathLength*pi)**2)); % and on the other
fill q1--reverse(q2)--cycle;

The line thickness should have some lower bound, otherwise, some lines are going to be too thin to be properly printed and this doesn't look great. One of the options (the one I chose) is to make the lines which are too thin dashed, so that the total amount of ink per unit length remains approximately the same as in the intended thinner line. In other words, instead of reducing the amount of ink on the sides of the line, the algorithm takes some ink from the line itself.

Example code
path p;
p := (0,-1/4cm){dir(30)}..(5cm, 0)..{dir(30)}(10cm, 1/4cm);
draw brush(p)(1pt*(sin(offsetPathLength*pi)**2)); % the arguments are the same as for the outline

Once you have working variable-thickness lines, you can draw spheres. A sphere can be depicted as a series of concentric circles with the line thicknesses varying according to the output of a function that calculates the lightness of a particular point on the sphere.

Example code
draw sphere.c(1.2cm);
draw sphere.c(2.4cm) shifted (2cm, 0);

Another convenient construction block is a “tube.” Roughly speaking it is a cylinder which you can bend. So long as the diameter is constant, it's pretty straightforward.

Example code
path p;
p := subpath (1,8) of fullcircle scaled 3cm;
draw tube.l(p)(1/2cm); % arguments are the path itself and the tube radius

If the diameter isn't constant, things become more complicated: the number of strokes should change according to the tube thickness in order to keep the amount of ink per unit area constant before taking the lights into account.

Example code
path p;
p := pathSubdivide(fullcircle, 2) scaled 3cm; % this thing splits every segment between the points of a path (here—fullcircle) into several parts (here—2)
draw tube.l(p)(1/2cm + 1/6cm*sin(offsetPathLength*10pi));

There are also tubes with transverse hatching. The problem of keeping the amount of ink constant turned out to be even trickier in this case, so ofttimes such tubes look a tad shaggy.

Example code
path p;
p := pathSubdivide(fullcircle, 2) scaled 3cm;
draw tube.t(p)(1/2cm + 1/6cm*sin(offsetPathLength*10pi));

Tubes can be used to construct a wide range of objects: from cones and cylinders to balusters.

Example code
draw tube.l ((0, 0) -- (0, 3cm))((1-offsetPathLength)*1cm) shifted (-3cm, 0); % a very simple cone
path p;
p := (-1/2cm, 0) {dir(175)} .. {dir(5)} (-1/2cm, 1/8cm) {dir(120)} .. (-2/5cm, 1/3cm) .. (-1/2cm, 3/4cm) {dir(90)} .. {dir(90)}(-1/4cm, 9/4cm){dir(175)} .. {dir(5)}(-1/4cm, 9/4cm + 1/5cm){dir(90)} .. (-2/5cm, 3cm); % baluster's envelope
p := pathSubdivide(p, 6);
draw p -- reverse(p xscaled -1) -- cycle;
tubeGenerateAlt(p, p xscaled -1, p rotated -90); % a more low-level stuff than tube.t, first two arguments are tube's sides and the third is the envelope. The envelope is basically a flattened out version of the outline, with line length along the x axis and the distance to line at the y. In the case of this baluster, it's simply its side rotated 90 degrees.

Some constructions which can be made from these primitives are included in the library. For example, the globe is basically a sphere.

Example code
draw globe(1cm, -15, 0) shifted (-6/2cm, 0); % radius, west longitude, north latitude, both decimal
draw globe(3/2cm, -30.280577, 59.939461);
draw globe(4/3cm, -140, -30) shifted (10/3cm, 0);

However, the hatching here is latitudinal and controlling line density is much more difficult than on the regular spheres with “concentric” hatching, so it's a different kind of sphere.

Example code
draw sphere.l(2cm, -60); % diameter and latitude
draw sphere.l(3cm, 45) shifted (3cm, 0);

A weight is a simple construction made from tubes of two types.

Example code
draw weight.s(1cm); % weight height
draw weight.s(2cm) shifted (2cm, 0);

There's also a tool to knot the tubes.

Example code. For brevity's sake only one knot.
path p;
p := (dir(90)*4/3cm) {dir(0)} .. tension 3/2 ..(dir(90 + 120)*4/3cm){dir(90 + 30)} .. tension 3/2 ..(dir(90 - 120)*4/3cm){dir(-90 - 30)} .. tension 3/2 .. cycle;
p := p scaled 6/5;
addStrandToKnot (primeOne) (p, 1/4cm, "l", "1, -1, 1"); % first, we add a strand of width 1/4cm going along the path p to the knot named primeOne. its intersections along the path go to layers "1, -1, 1" and the type of tube is going to be "l".
draw knotFromStrands (primeOne); % then the knot is drawn. you can add more than one strand.

Tubes in knots drop shadows on each other as they should. In theory, this feature can be used in other contexts, but since I had no plans to go deep into the third dimension, the user interface is somewhat lacking and shadows work properly only for some objects.

Example code
path shadowPath[];
boolean shadowsEnabled;
numeric numberOfShadows;
shadowsEnabled := true; % shadows need to be turned on
numberOfShadows := 1; % number of shadows should be specified
shadowPath0 := (-1cm, -2cm) -- (-1cm, 2cm) -- (-1cm +1/6cm, 2cm) -- (-1cm + 1/8cm, -2cm) -- cycle; % shadow-dropping object should be a closed path
shadowDepth0 := 4/3cm; % it's just this high above the object on which the shadow falls
shadowPath1 := shadowPath0 rotated -60;
shadowDepth1 := 4/3cm;
draw sphere.c(2.4cm); % shadows work ok only with sphere.c and tube.l with constant diameter
fill shadowPath0 withcolor white;
draw shadowPath0;
fill shadowPath1 withcolor white;
draw shadowPath1;

Certainly, you will need a wood texture (update: since the Russian version of this article was published, the first case of this library being used in a real project which I'm aware of has occurred, and it was the wood texture which came in handy, so this ended up not being a joke after all). How twigs and their growth affect the pattern of year rings is a topic for some serious study. The simplest working model I could come up with is as follows: year rings are parallel flat surfaces, distorted by growing twigs; thus the surface is modified by a series of not overly complex “twig functions” in different places and the surface's isolines are taken as the year ring pattern.

Example code
numeric w, b;
pair A, B, C, D, A', B', C', D';
w := 4cm;
b := 1/2cm;
A := (0, 0);
A' := (b, b);
B := (0, w);
B' := (b, w-b);
C := (w, w);
C' := (w-b, w-b);
D := (w, 0);
D' := (w-b, b);
draw woodenThing(A--A'--B'--B--cycle, 0); % a piece of wood inside the A--A'--B'--B--cycle path, with wood grain at 0 degrees
draw woodenThing(B--B'--C'--C--cycle, 90);
draw woodenThing(C--C'--D'--D--cycle, 0);
draw woodenThing(A--A'--D'--D--cycle, 90);
eyescale := 2/3cm; % scale for the eye
draw eye(150) shifted 1/2[A,C]; % the eye looks in 150 degree direction

The eye from the picture above opens wide or squints a bit and its pupil changes its size too. It may not make any practical sense, but mechanically similar eyes just look boring.

Example code
eyescale := 2/3cm; % 1/2cm by default
draw eye(0) shifted (0cm, 0);
draw eye(0) shifted (1cm, 0);
draw eye(0) shifted (2cm, 0);
draw eye(0) shifted (3cm, 0);
draw eye(0) shifted (4cm, 0);

Most of the time the illustrations weren't all that complex, but a more rigorous approach would require solving many of the problems in the textbook to illustrate them correctly. Say, L'Hôpital's pulley problem (it wasn't in that textbook, but anyway): on the rope with the length $l$, suspended at the point $A$ a pulley is hanging; it's hooked to another rope, suspended at the point $B$ with the weight $C$ on its end. The question is: where does the weight go if both the pulley and the ropes weigh nothing? Surprisingly, the solution and the construction for this problem are not that simple. But by playing with several variables you can make the picture look just right for the page while maintaining accuracy.

Example code
vardef lHopitalPulley (expr AB, l, m) = % distance AB between the suspension points of the ropes and their lengths l and m. “Why no units of length?”, you may ask. It's because some calculations inside can cause arithmetic overflow in MetaPost.
save A, B, C, D, E, o, a, x, y, d, w, h, support;
pair A, B, C, D, E, o[];
path support;
numeric a, x[], y[], d[], w, h;
x1 := (l**2 + abs(l)*((sqrt(8)*AB)++l))/4AB; % the solution
y1 := l+-+x1; % second coordinate is trivial
y2 := m - ((AB-x1)++y1); % as well as the weight's position
A := (0, 0);
B := (AB*cm, 0);
D := (x1*cm, -y1*cm);
C := D shifted (0, -y2*cm);
d1 := 2/3cm; d2 := 1cm; d3 := 5/6d1; % diameters of the pulley, weight and the pulley wheel
w := 2/3cm; h := 1/3cm; % parameters of the wood block
o1 := (unitvector(C-D) rotated 90 scaled 1/2d3);
o2 := (unitvector(D-B) rotated 90 scaled 1/2d3);
E := whatever [D shifted o1, C shifted o1]
= whatever [D shifted o2, B shifted o2]; % pulley's center
a := angle(A-D);
support := A shifted (-w, 0) -- B shifted (w, 0) -- B shifted (w, h) -- A shifted (-w, h) -- cycle;
draw woodenThing(support, 0); % wood block everything is suspended from
draw pulley (d1, a - 90) shifted E; % the pulley
draw image(
draw A -- D -- B withpen thickpen;
draw D -- C withpen thickpen;
) maskedWith (pulleyOutline shifted E); % ropes should be covered with the pulley
draw sphere.c(d2) shifted C shifted (0, -1/2d2); % sphere as a weight
dotlabel.llft(btex $A$ etex, A);
dotlabel.lrt(btex $B$ etex, B);
dotlabel.ulft(btex $C$ etex, C);
label.llft(btex $l$ etex, 1/2[A, D]);
draw lHopitalPulley (6, 2, 11/2); % now you can choose the right parameters
draw lHopitalPulley (3, 5/2, 3) shifted (8cm, 0);

And what about the textbook? Alas, when almost all the illustrations and the layout were ready, something happened and the textbook was canceled. Maybe because of that, I decided to rewrite most of the functions of the original library from scratch (I chose not to use any of the original code, which, although indirectly, I was paid for) and put it on GitHub. Some things, present in the original library, such as functions for drawing cars and tractors, I didn't include there, some new features, e.g. knots, were added.

It doesn't run quickly: it takes about a minute to produce all the pictures for this article with LuaLaTeX on my laptop with i5-4200U 1.6 GHz. A pseudorandom number generator is used here and there, so no two similar pictures are absolutely identical (that's a feature) and every run produces slightly different pictures. To avoid surprises you can simply set randomseed := some number and enjoy the same results every run.

Many thanks to dr ord and Mikael Sundqvist for their help with the English version of this text.
If this publication inspired you and you want to support the author, do not hesitate to click on the button


Change theme settings