Implementing a drawing program provides a very good example on the usefulness of object-oriented programming. In the following we shall develop the simpler parts of a relatively small and compact drawing program for making sketches of the type shown in Figure ref{sketcher:fig:inclinedplane}. This is a typical principal sketch of a physics problem, here involving a rolling wheel on an inclined plane. This sketch is made up many individual elements: a rectangle filled with a pattern (the inclined plane), a hollow circle with color (the wheel), arrows with label (the $N$ and $Mg$ forces, and the $x$ axis), an angle with symbol $\theta$, and a dashed line indicating the starting location of the wheel. Drawing software and plotting programs can produce such figures quite easily in principle, but the amount of details the user needs to control with the mouse can be substantial. Software more tailored to producing sketches of this type would work with more convenient abstractions, such as circle, wall, angle, force arrow, axis, and so forth. FIGURE: [figs-sketcher/wheel_on_inclined_plane.png, width=500] Sketch of a physics problem. label{sketcher:fig:inclinedplane} Classes are very suitable for implementing the various components that build up a sketch and their functionality. In particular, we shall demonstrate that as soon some classes are established, more are easily added. Enhanced functionality for all the classes is also easy to implement in common, generic code that can immediately be shared by all present and future classes. ===== Using the Object Collection ===== We start by demonstrating a convenient user interface for making sketches of the type in Figure ref{sketcher:fig1}. However, it is more appropriate to start with a significantly simpler example as depicted in Figure ref{sketcher:fig:vehicle0}. This toy sketch consists of several elements: two circles, two rectangles, and a "ground" element. FIGURE: [figs-sketcher/vehicle0_dim.png, width=400] Sketch of a simple figure. label{sketcher:fig:vehicle0} === Basic Drawing === A typical program creating these five elements is shown next. After importing the `pysketcher` package, the first task is always to define a coordinate system. Some graphics operations are done with a helper object called `drawing_tool` (imported from `pysketcher`). With the drawing area in place we can make the first `Circle` object in an intuitive fashion: !bc pycod from pysketcher import * R = 1 # radius of wheel L = 4 # distance between wheels H = 2 # height of vehicle body w_1 = 5 # position of front wheel drawing_tool.set_coordinate_system(xmin=0, xmax=w_1 + 2*L + 3*R, ymin=-1, ymax=2*R + 3*H) wheel1 = Circle(center=(w_1, R), radius=R) !ec By using symbols for characteristic lengths in the drawing, rather than absolute lengths, it is easier to change dimensions later. To translate the geometric information about the `wheel1` object to instructions for the plotting engine (in this case Matplotlib), one calls the `wheel1.draw()`. To display all drawn objects, one issues `drawing_tool.display()`. The typical steps are hence: !bc pycod wheel1 = Circle(center=(w_1, R), radius=R) wheel1.draw() # Define other objects and call their draw() methods drawing_tool.display() drawing_tool.savefig('tmp.png') # store picture !ec The next wheel can be made by taking a copy of `wheel1` and translating the object a distance (to the right) described by the vector $(4,0)$: !bc pycod wheel2 = wheel1.copy() wheel2.translate((L,0)) !ec The two rectangles are made in an intuitive way: !bc pycod under = Rectangle(lower_left_corner=(w_1-2*R, 2*R), width=2*R + L + 2*R, height=H) over = Rectangle(lower_left_corner=(w_1, 2*R + H), width=2.5*R, height=1.25*H) !ec === Groups of Objects === Instead of calling the `draw` method of every object, we can group objects and call `draw`, or perform other operations, for the whole group. For example, we may collect the two wheels in a `wheels` group and the `over` and `under` rectangles in a `body` group. The whole vehicle is a composition of its `wheels` and `body` groups. The codes goes like !bc pycod wheels = Composition({'wheel1': wheel1, 'wheel2': wheel2}) body = Composition({'under': under, 'over': over}) vehicle = Composition({'wheels': wheels, 'body': body}) !ec The ground is illustrated by an object of type `Wall`, mostly used to indicate walls in sketches of mechanical systems. A `Wall` takes the `x` and `y` coordinates of some curve, and a `thickness` parameter, and creates a "thick" curve filled with a simple pattern. In this case the curve is just a flat line so the construction is made of two points on the ground line ($(w_1-L,0)$ and $(w_1+3L,0)$): !bc pycod ground = Wall(x=[w_1 - L, w_1 + 3*L], y=[0, 0], thickness=-0.3*R) !ec The negative thickness makes the pattern-filled rectangle appear below the defined line, otherwise it appears above. We may now collect all the objects in a "top" object that contains the whole figure: !bc pycod fig = Composition({'vehicle': vehicle, 'ground': ground}) fig.draw() # send all figures to plotting backend drawing_tool.display() drawing_tool.savefig('tmp.png') !ec The `fig.draw()` call will visit all subgroups, their subgroups, and so in the herarchical tree structure that we have collected, and call `draw` for every object. === Changing Line Styles and Colors === Controlling the line style, line color, and line width is fundamental when designing figures. The `pysketcher` package allows the user to control such properties in single objects, but also set global properties that are used if the object has no particular specification of the properties. Setting the global properties are done like !bc pycod drawing_tool.set_linestyle('dashed') drawing_tool.set_linecolor('black') drawing_tool.set_linewidth(4) !ec At the object level the properties are specified in a similar way: !bc pycod wheel1.set_linestyle('solid') wheel1.set_linecolor('red') !ec and so on. Geometric figures can be specified as *filled*, either with a color or with a special visual pattern: !bc # Set filling of all curves drawing_tool.set_filled_curves(color='blue', pattern='/') # Turn off filling of all curves drawing_tool.set_filled_curves(False) # Fill the wheel with red color wheel1.set_filled_curves('red') !ec # http://packages.python.org/ete2/ for visualizing tree structures! === The Figure Composition as an Object Hierarchy === The composition of objects is hierarchical, as in a family, where each object has a parent and a number of children. Do a `print fig` to display the relations: !bc dat ground wall vehicle body over rectangle under rectangle wheels wheel1 arc wheel2 arc !ec The indentation reflects how deep down in the hierarchy (family) we are. This output is to be interpreted as follows: * `fig` contains two objects, `ground` and `vehicle` * `ground` contains an object `wall` * `vehicle` contains two objects, `body` and `wheels` * `body` contains two objects, `over` and `under` * `wheels` contains two objects, `wheel1` and `wheel2` More detailed information can be printed by !bc pycod print fig.show_hierarchy('std') !ec yielding the output !bc dat ground (Wall): wall (Curve): 4 coords fillcolor='white' fillpattern='/' vehicle (Composition): body (Composition): over (Rectangle): rectangle (Curve): 5 coords under (Rectangle): rectangle (Curve): 5 coords wheels (Composition): wheel1 (Circle): arc (Curve): 181 coords wheel2 (Circle): arc (Curve): 181 coords !ec Here we can see the class type for each figure object, how many coordinates that are involved in basic figures (`Curve` objects), and special settings of the basic figure (fillcolor, line types, etc.). For example, `wheel2` is a `Circle` object consisting of an `arc`, which is a `Curve` object consisting of 181 coordinates (the points needed to draw a smooth circle). The `Curve` objects are the only objects that really holds specific coordinates to be drawn. The other object types are just compositions used to group parts of the complete figure. One can also get a graphical overview of the hiearchy of figure objects that build up a particular figure `fig`. Just call `fig.graphviz_dot('fig')` to produce a file `fig.dot` in the *dot format*. This file contains relations between parent and child objects in the figure and can be turned into an image, as in Figure ref{sketcher:fig:vehicle0:hier1}, by running the `dot` program: !bc sys Terminal> dot -Tpng -o fig.png fig.dot !ec FIGURE: [figs-sketcher/vehicle0_hier1.png, width=400] Hierarchical relation between figure objects. label{sketcher:fig:vehicle0:hier1} The call `fig.graphviz_dot('fig', classname=True)` makes a `fig.dot` file where the class type of each object is also visible, see Figure ref{sketcher:fig:vehicle0:hier2}. The ability to write out the object hierarchy or view it graphically can be of great help when working with complex figures that involve layers of subfigures. FIGURE: [figs-sketcher/vehicle0_hier1.png, width=400] Hierarchical relation between figure objects, including their class names. label{sketcher:fig:vehicle0:hier2} Any of the objects can in the program be reached through their names, e.g., !bc pycodc fig['vehicle'] fig['vehicle']['wheels'] fig['vehicle']['wheels']['wheel2'] fig['vehicle']['wheels']['wheel2']['arc'] fig['vehicle']['wheels']['wheel2']['arc'].x # x coords fig['vehicle']['wheels']['wheel2']['arc'].y # y coords fig['vehicle']['wheels']['wheel2']['arc'].linestyle fig['vehicle']['wheels']['wheel2']['arc'].linetype !ec Grabbing a part of the figure this way is very handy for changing properties of that part, for example, colors, line styles (see Figure ref{sketcher:fig:vehicle0:v2}): !bc pycod fig['vehicle']['wheels'].set_filled_curves('blue') fig['vehicle']['wheels'].set_linewidth(6) fig['vehicle']['wheels'].set_linecolor('black') fig['vehicle']['body']['under'].set_filled_curves('red') fig['vehicle']['body']['over'].set_filled_curves(pattern='/') fig['vehicle']['body']['over'].set_linewidth(14) fig['vehicle']['body']['over']['rectangle'].linewidth = 4 !ec The last line accesses the `Curve` object directly, while the line above, accesses the `Rectangle` object which will then set the linewidth of its `Curve` object, and other objects if it had any. The result of the actions above is shown in Figure ref{sketcher:fig:vehicle0:v2}. FIGURE: [figs-sketcher/vehicle0.png, width=700] Left: Basic line-based drawing. Right: Thicker lines and filled parts. label{sketcher:fig:vehicle0:v2} We can also change position of parts of the figure and thereby make animations, as shown next. === Animation: Translating the Vehicle === Can we make our little vehicle roll? A first attempt will be to fake rolling by just displacing all parts of the vehicle. The relevant parts constitute the `fig['vehicle']` object. This part of the figure can be translated, rotated, and scaled. A translation along the ground means a translation in $x$ direction, say a length $L$ to the right: !bc pycod fig['vehicle'].translate((L,0)) !ec You need to erase, draw, and display to see the movement: !bc pycod drawing_tool.erase() fig.draw() drawing_tool.display() !ec Without erasing the old position of the vehicle will remain in the figure so you get two vehicles. Without `fig.draw()` the new coordinates of the vehicle will not be communicated to the drawing tool, and without calling dislay the updated drawing will not be visible. Let us make a velocity function and move the object according to that velocity in small steps of time: !bc pydoc def v(t): return -8*R*t*(1 - t/(2*R)) animate(fig, tp, action) !ec For small time steps `dt` the corresponding displacement is well approximated by `dt*v(t)` (we could integrate the velocity to obtain the exact position, but we would anyway need to calculate the displacement from time step to time step). The `animate` function takes as arguments some figure `fig`, a set of time points `tp`, and a user function `action`, and then a new figure is drawn for each time point and the user can through the provided `action` function modify desired parts of the figure. Here the `action` function will move the vehicle: !bc pycod def move_vehicle(t, fig): x_displacement = dt*v(t) fig['vehicle'].translate((x_displacement, 0)) !ec Defining a set of time points for the frames in the animation and performing the animation is done by !bc pycod import numpy tp = numpy.linspace(0, 2*R, 25) dt = tp[1] - tp[0] # time step animate(fig, tp, move_vehicle, pause_per_frame=0.2) !ec The `pause_per_frame` adds a pause, here 0.2 seconds, between each frame. We can also make a movie file of the animation: !bc pycod files = animate(fig, tp, move_vehicle, moviefiles=True, pause_per_frame=0.2) !ec The `files` variable holds a string with the family of files constituting the frames in the movie, here `'tmp_frame*.png'`. Making a movie out of the individual frames can be done in many ways. A simple approach is to make an animated GIF file with help of `convert`, a program in the ImageMagick software suite: !bc sys Terminal> convert -delay 20 tmp_frame*.png anim.gif Terminal> animate anim.gif # play movie !ec The delay between frames governs the speed of the movie. The `anim.gif` file can be embedded in a web page and shown as a movie the page is loaded into a web browser (just insert `` in the HTML code to play the GIF animation). The tool `ffmpeg` can alternatively be used, e.g., !bc sys Terminal> ffmpeg -i "tmp_frame_%04d.png" -b 800k -r 25 \ -vcodec mpeg4 -y -qmin 2 -qmax 31 anim.mpeg !ec An easy-to-use interface to movie-making tools is provided by the SciTools package: !bc pycod from scitools.std import movie # HTML page showing individual frames movie(files, encoder='html', output_file='anim.html') # Standard GIF file movie(files, encoder='convert', output_file='anim.gif') # AVI format movie('tmp_*.png', encoder='ffmpeg', fps=4, output_file='anim.avi') # requires ffmpeg package # MPEG format movie('tmp_*.png', encoder='ffmpeg', fps=4, output_file='anim2.mpeg', vodec='mpeg2video') # or movie(files, encoder='ppmtompeg', fps=24, output_file='anim.mpeg') # requires the netpbm package !ec When difficulties with encoders and players arise, the simple web page for showing a movie, here `anim.html` (generated by the first `movie` command above), is a safe method that you always can rely on. You can try loading `anim.html` into a web browser, after first having run the present example in the file # #ifdef PRIMER_BOOK `vehicle0.py`. # #else "`vehicle0.py`": "http://hplgit.github.com/pysketcher/doc/src/sketcher/src-sketcher/vehicle0.py". Alternatively, you can view a ready-made "movie": "http://hplgit.github.com/pysketcher/doc/src/sketcher/movies-sketcher/anim.html_vehicle0/anim.html". # #endif === Animation: Rolling the Wheels === It is time to show rolling wheels. To this end, we make somewhat more complicated wheels with spokes as on a bicyle, formed by two crossing lines, see Figure ref{sketcher:fig:vehicle1}. The construction of the wheels will now involve a circle and two lines: !bc pycod wheel1 = Composition({ 'wheel': Circle(center=(w_1, R), radius=R), 'cross': Composition({'cross1': Line((w_1,0), (w_1,2*R)), 'cross2': Line((w_1-R,R), (w_1+R,R))})}) wheel2 = wheel1.copy() wheel2.translate((L,0)) !ec Observe that `wheel1.copy()` copies all the objects that make up the first wheel, and `wheel2.translate` translates all the copied objects. FIGURE: [figs-sketcher/vehicle1.png, width=400] Wheels with spokes to show rotation. label{sketcher:fig:vehicle1} The `move_vehicle` function now needs to displace all the objects in the entire vehicle and also rotate the `cross1` and `cross2` objects in both wheels. The rotation angle follows from the fact that the arc length of a rolling wheel equals the displacement of the center of the wheel, leading to a rotation angle !bc pycod angle = - x_displacement/R !ec With `w_1` tracking the $x$ coordinate of the center of the front wheel, we can rotate that wheel by !bc pycod w1 = fig['vehicle']['wheels']['wheel1'] from math import degrees w1.rotate(degrees(angle), center=(w_1, R)) !ec The `rotate` function takes two parameters: the rotation angle (in degrees) and the center point of the rotation, which is the center of the wheel in this case. The other wheel is rotated by !bc pycod w2 = fig['vehicle']['wheels']['wheel2'] w2.rotate(degrees(angle), center=(w_1 + L, R)) !ec That is, the angle is the same, but the rotation point is different. The update of the center point is done by `w_1 += displacement[0]`. The complete `move_vehicle` function then becomes !bc pycod w_1 = w_1 + L # start position def move_vehicle(t, fig): x_displacement = dt*v(t) fig['vehicle'].translate((x_displacement, 0)) # Rotate wheels global w_1 w_1 += x_displacement # R*angle = -x_displacement angle = - x_displacement/R w1 = fig['vehicle']['wheels']['wheel1'] w1.rotate(degrees(angle), center=(w_1, R)) w2 = fig['vehicle']['wheels']['wheel2'] w2.rotate(degrees(angle), center=(w_1 + L, R)) !ec The complete example is found in the file # #ifdef PRIMER_BOOK `vehicle1.py`. # #else "`vehicle1.py`": "http://hplgit.github.com/pysketcher/doc/src/sketcher/src-sketcher/vehicle1.py". You may run this file or watch a "ready-made movie": "http://hplgit.github.com/pysketcher/doc/src/sketcher/movies-sketcher/anim.html_vehicle1/anim.html". # #endif The advantages with making figures this way through programming, rather than using interactive drawing programs, are numerous. For example, the objects are parameterized by variables so that various dimensions can easily be changed. Subparts of the figure, possible involving a lot of figure objects, can change color, linetype, filling or other properties through a *single* function call. Subparts of the figure can be rotated, translated, or scaled. Subparts of the figure can also be copied and moved to other parts of the drawing area. However, the single most important feature is probably the ability to make animations governed by mathematical formulas or data coming from physics simulations of the problem sketched in the drawing, as very simplistically shown in the example above. ===== Example of Classes for Geometric Objects ===== We shall now explain how we can, quite easily, realize software with the capabilities demonstrated above. Each object in the figure is represented as a class in a class hierarchy. Using inheritance, classes can inherit properties from parent classes and add new geometric features. We introduce class `Shape` as superclass for all specialized objects in a figure. This class does not store any data, but provides a series of functions that add functionality to all the subclasses. This will be shown later. === Simple Geometric Objects === One simple subclass is `Rectangle`: !bc pycod class Rectangle(Shape): def __init__(self, lower_left_corner, width, height): p = lower_left_corner # short form x = [p[0], p[0] + width, p[0] + width, p[0], p[0]] y = [p[1], p[1], p[1] + height, p[1] + height, p[1]] self.shapes = {'rectangle': Curve(x,y)} !ec Any subclass of `Shape` will have a constructor which takes geometric information about the shape of the object and creates a dictionary `self.shapes` with the shape built of simpler shapes. The most fundamental shape is `Curve`, which is just a collection of $(x,y)$ coordinates in two arrays `x` and `y`. Drawing the `Curve` object is a matter of plotting `y` versus `x`. The `Rectangle` class illustrates how the constructor takes information about the lower left corner, the width and the height, and creates coordinate arrays `x` and `y` consisting of the four corners, plus the first one repeated such that plotting `x` and `y` will form a closed four-sided rectangle. This construction procedure demands that the rectangle will always be aligned with the $x$ and $y$ axis. However, we may easily rotate the rectangle about any point once the object is constructed. Class `Line` constitutes a similar example: !bc pycod class Line(Shape): def __init__(self, start, end): x = [start[0], end[0]] y = [start[1], end[1]] self.shapes = {'line': Curve(x, y)} !ec Here we only need two points, the start and end point on the line. However, we may add some useful functionality, e.g., the ability to give an $x$ coordinate and have the class calculate the corresponding $y$ coordinate: !bc pycod def __call__(self, x): """Given x, return y on the line.""" x, y = self.shapes['line'].x, self.shapes['line'].y self.a = (y[1] - y[0])/(x[1] - x[0]) self.b = y[0] - self.a*x[0] return self.a*x + self.b !ec Unfortunately, this is too simplistic because vertical lines cannot be handled (infinte `self.a`). The source code of `Line` therefore provides a more general solution at the cost of significantly longer code with more tests. A circle gives us somewhat increased complexity. Again we represent the geometic object by a `Curve` object, but this time the `Curve` object needs to store a large number of points on the curve such that a plotting program produces a visually smooth curve. The points on the circle must be calculated manually in the constructor of class `Circle`. The formulas for points $(x,y)$ on a curve with radius $R$ and center at $(x_0, y_0)$ are given by !bt \begin{align*} x &= x_0 + R\cos (t),\\ y &= y_0 + R\sin (t), \end{align*} !et where $t\in [0, 2\pi]$. A discrete set of $t$ values in this interval gives the corresponding set of $(x,y)$ coordinates on the circle. The user must specify the resolution, i.e., the number of $t$ values, or equivalently, points on the circle. The circle's radius and center must of course also be specified. We can write the `Circle` class as !bc pycod class Circle(Shape): def __init__(self, center, radius, resolution=180): self.center, self.radius = center, radius self.resolution = resolution t = linspace(0, 2*pi, resolution+1) x0 = center[0]; y0 = center[1] R = radius x = x0 + R*cos(t) y = y0 + R*sin(t) self.shapes = {'circle': Curve(x, y)} !ec As in class `Line` we can offer the possibility to give an angle $\theta$ (equivalent to $t$ in the formulas above) and then get the corresponding $x$ and $y$ coordinates: !bc pycod def __call__(self, theta): """Return (x, y) point corresponding to angle theta.""" return self.center[0] + self.radius*cos(theta), \ self.center[1] + self.radius*sin(theta) !ec There is one flaw with this method: it yields illegal values after a translation, scaling, or rotation of the circle. A part of a circle, an arc, is a frequent geometric object when drawing mechanical systems. The arc is constructed much like a circle, but $t$ runs in $[\theta_0, \theta_1]$. Giving $\theta_1$ and $\theta_2$ the slightly more descriptive names `start_angle` and `arc_angle`, the code looks like this: !bc pycod class Arc(Shape): def __init__(self, center, radius, start_angle, arc_angle, resolution=180): self.center = center self.radius = radius self.start_angle = start_angle*pi/180 # radians self.arc_angle = arc_angle*pi/180 self.resolution = resolution t = linspace(self.start_angle, self.start_angle + self.arc_angle, resolution+1) x0 = center[0]; y0 = center[1] R = radius x = x0 + R*cos(t) y = y0 + R*sin(t) self.shapes = {'arc': Curve(x, y)} !ec Having the `Arc` class, a `Circle` can alternatively befined as a subclass specializing the arc to a circle: !bc pycod class Circle(Arc): def __init__(self, center, radius, resolution=180): Arc.__init__(self, center, radius, 0, 360, resolution) !ec A wall is about drawing a curve, displacing the curve vertically by some thickness, and then filling the space between the curves by some pattern. The input is the `x` and `y` coordinate arrays of the curve and a thickness parameter. The computed coordinates will be a polygon: going along the originally curve and then back again along the vertically displaced curve. The relevant code becomes !bc pycod class CurveWall(Shape): def __init__(self, x, y, thickness): # User's curve x1 = asarray(x, float) y1 = asarray(y, float) # Displaced curve (according to thickness) x2 = x1 y2 = y1 + thickness # Combine x1,y1 with x2,y2 reversed from numpy import concatenate x = concatenate((x1, x2[-1::-1])) y = concatenate((y1, y2[-1::-1])) wall = Curve(x, y) wall.set_filled_curves(color='white', pattern='/') self.shapes = {'wall': wall} !ec === Class Curve === Class `Curve` sits on the coordinates to be drawn, but how is that done? The constructor just stores the coordinates, while a method `draw` sends the coordinates to the plotting program to make a graph. Or more precisely, to avoid a lot of (e.g.) Matplotlib-specific plotting commands we have created a small layer with a simple programming interface to plotting programs. This makes it straightforward to change from Matplotlib to another plotting program. The programming interface is represented by the `drawing_tool` object and has a few functions: * `plot_curve` for sending a curve in terms of $x$ and $y$ coordinates to the plotting program, * `set_coordinate_system` for specifying the graphics area, * `erase` for deleting all elements of the graph, * `set_grid` for turning on a grid (convenient while constructing the plot), * `set_instruction_file` for creating a separate file with all plotting commands (Matplotlib commands in our case), * a series of `set_X` functions where `X` is some property like `linecolor`, `linestyle`, `linewidth`, `filled_curves`. This is basically all we need to communicate to a plotting program. Any class in the `Shape` hierarchy inherits `set_X` functions for setting properties of curves. This information is propagated to all other shape objects that make up the figure. Class `Curve` stores the line properties together with the coordinates of its curve and propagates this information to the plotting program. When saying `vehicle.set_linewidth(10)`, all objects that make up the `vehicle` object will get a `set_linewidth(10)` call, but only the `Curve` object at the end of the chain will actually store the information and send it to the plotting program. A rough sketch of class `Curve` reads !bc pycod class Curve(Shape): """General curve as a sequence of (x,y) coordintes.""" def __init__(self, x, y): self.x = asarray(x, dtype=float) self.y = asarray(y, dtype=float) self.linestyle = None self.linewidth = None self.linecolor = None self.fillcolor = None self.fillpattern = None self.arrow = None def draw(self): drawing_tool.plot_curve( self.x, self.y, self.linestyle, self.linewidth, self.linecolor, self.arrow, self.fillcolor, self.fillpattern) def set_linewidth(self, width): self.linewidth = width det set_linestyle(self, style): self.linestyle = style ... !ec === Compound Geometric Objects === The simple classes `Line`, `Arc`, and `Circle` could define the geometric shape through just one `Curve` object. More complicated figure elements are built from instances of various subclasses of `Shape`. Classes used for professional drawings soon get quite complex in composition and have a lot of geometric details, so here we prefer to make a very simple composition: the already drawy vehicle from Figure refref{sketcher:fig:vehicle0}. That is, instead of composing the drawing in a Python code we make a class `Vehicle0` for doing the same thing, and derive it from `Shape`. The `Shape` hierarchy is found in the `pysketcher` package, so to use these classes or derive a new one, we need to import `pysketcher`. The constructor of clas `Vehicle0` performs approximately the same statements as in the example program we developed for making the drawing in Figure refref{sketcher:fig:vehicle0}. !bc pycod class Vehicle0(Shape): def __init__(self, w_1, R, L, H): wheel1 = Circle(center=(w_1, R), radius=R) wheel2 = wheel1.copy() wheel2.translate((L,0)) under = Rectangle(lower_left_corner=(w_1-2*R, 2*R), width=2*R + L + 2*R, height=H) over = Rectangle(lower_left_corner=(w_1, 2*R + H), width=2.5*R, height=1.25*H) wheels = Composition( {'wheel1': wheel1, 'wheel2': wheel2}) body = Composition( {'under': under, 'over': over}) vehicle = Composition({'wheels': wheels, 'body': body}) xmax = w_1 + 2*L + 3*R ground = Wall(x=[R, xmax], y=[0, 0], thickness=-0.3*R) self.shapes = {'vehicle': vehicle, 'ground': ground} !ec Any subclass of `Shape` *must* define the `shapes` attribute, otherwise the inherited `draw` method (and a lot of other methods too) will not work. The painting of the vehicle could be offered by a method: !bc pycod def colorful(self): wheels = self.shapes['vehicle']['wheels'] wheels.set_filled_curves('blue') wheels.set_linewidth(6) wheels.set_linecolor('black') under = self.shapes['vehicle']['body']['under'] under.set_filled_curves('red') over = self.shapes['vehicle']['body']['over'] over.set_filled_curves(pattern='/') over.set_linewidth(14) !ec The usage of the class is simple: after having set up an appropriate coordinate system a s previously shown, we can do !bc pycod vehicle = Vehicle0(w_1, R, L, H) vehicle.draw() drawing_tool.display() !ec The color from Figure ref{sketcher:fig:vehicle0:v2} is realized by !bc pycod drawing_tool.erase() vehicle.colorful() vehicle.draw() drawing_tool.display() !ec A complete code defining and using class `Vehicle0` is found in the file # #ifdef PRIMER_BOOK `vehicle2.py`. # #else "`vehicle2.py`": "http://hplgit.github.com/pysketcher/doc/src/sketcher/src-sketcher/vehicle2.py". # #endif The `pysketcher` package contains a wide range of classes for various geometrical objects, particularly those that are frequently used in drawings of mechanical systems. ======= Adding Functionality via Recursion ======= The really powerful feature of our class hierarchy is that we can add much functionality to the superclass `Shape` and to the "bottom" classe `Curve`, and all other classes for all types of geometrical shapes immediately get the new functionality. To explain the idea we first have to look at the `draw` method, which all classes in the `Shape` hierarchy must have. The inner workings of the `draw` method explain the secrets of how a series of other useful operations on figures can be implemented. [[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[ !bc cod class Rectangle(Shape): def __init__(self, lower_left_corner, width, height): self.lower_left_corner = lower_left_corner # 2-tuple self.width, self.height = width, height Shape.__init__(self) def subshapes(self): ll = self.lower_left_corner # short form x = [ll[0], ll[0]+self.width, ll[0]+self.width, ll[0], ll[0]] y = [ll[1], ll[1], ll[1]+self.height, ll[1]+self.height, ll[1]] return Curve(x,y) !ec Class `Circle` needs many coordinates in its `Curve` object in order to display a smooth circle. We can provide the number of straight line segments along the circle as a parameter `resolution`. Using a default value of 180 means that each straight line segment approximates an arc of 2 degrees. This resolution should be sufficient for visual purposes. The set of coordinates along a circle with radius $R$ and center $(x_0,y_0)$ is defined by !bt \begin{align} x &= x_0 + R\cos(t), label{sec:oo:circle:eq1}\\ y &= y_0 + R\sin(t), label{sec:oo:circle:eq2} \end{align} !et for `resolution+1` $t$ values between $0$ and $2\pi$. The vectorized code for computing the coordinates becomes !bc cod t = linspace(0, 2*pi, self.resolution+1) x = x0 + R*cos(t) y = y0 + R*sin(t) !ec The complete `Circle` class is shown below: !bc cod class Circle(Shape): def __init__(self, center, radius, resolution=180): self.center, self.radius = center, radius self.resolution = resolution Shape.__init__(self) def subshapes(self): t = linspace(0, 2*pi, self.resolution+1) x0 = self.center[0]; y0 = self.center[1] R = self.radius x = x0 + R*cos(t) y = y0 + R*sin(t) return Curve(x,y) !ec We can also introduce class `Arc` for drawing the arc of a circle. Class `Arc` could be a subclass of `Circle`, extending the latter with two additional parameters: the opening of the arc (in degrees) and the starting $t$ value in (ref{sec:oo:circle:eq1})--(ref{sec:oo:circle:eq2}). The implementation of class `Arc` will then be almost a copy of the implementation of class `Circle`. The `subshapes` method will just define a different `t` array. Another view is to let class `Arc` be a subclass of `Shape`, and `Circle` a subclass of `Arc`, since a circle is an arc of 360 degrees. Let us employ this idea: !bc cod class Arc(Shape): def __init__(self, center, radius, start_degrees, opening_degrees, resolution=180): self.center = center self.radius = radius self.start_degrees = start_degrees*pi/180 self.opening_degrees = opening_degrees*pi/180 self.resolution = resolution Shape.__init__(self) def subshapes(self): t = linspace(self.start_degrees, self.start_degrees + self.opening_degrees, self.resolution+1) x0 = self.center[0]; y0 = self.center[1] R = self.radius x = x0 + R*cos(t) y = y0 + R*sin(t) return Curve(x,y) class Circle(Arc): def __init__(self, center, radius, resolution=180): Arc.__init__(self, center, radius, 0, 360, resolution) !ec In this latter implementation, we save a lot of code in class `Circle` since all of class `Arc` can be reused. Class `Wheel` may conceptually be a subclass of `Circle`. One circle, say the outer, is inherited and the subclass must have the inner circle as an attribute. Because of this "asymmetric" representation of the two circles in a wheel, we find it more natural to derive `Wheel` directly from `Shape`, and have the two circles as two attributes of type `Circle`: !bc cod class Wheel(Shape): def __init__(self, center, radius, inner_radius=None, nlines=10): self.center = center self.radius = radius if inner_radius is None: self.inner_radius = radius/5.0 else: self.inner_radius = inner_radius self.nlines = nlines Shape.__init__(self) !ec If the radius of the inner circle is not defined (`None`) we take it as 1/5 of the radius of the outer circle. The wheel is naturally composed of two `Circle` instances and `nlines` `Line` instances: !bc cod def subshapes(self): outer = Circle(self.center, self.radius) inner = Circle(self.center, self.inner_radius) lines = [] t = linspace(0, 2*pi, self.nlines) Ri = self.inner_radius; Ro = self.radius x0 = self.center[0]; y0 = self.center[1] xinner = x0 + Ri*cos(t) yinner = y0 + Ri*sin(t) xouter = x0 + Ro*cos(t) youter = y0 + Ro*sin(t) lines = [Line((xi,yi),(xo,yo)) for xi, yi, xo, yo in \ zip(xinner, yinner, xouter, youter)] return [outer, inner] + lines !ec For the fun of it, we can implement other shapes, say a sine wave !bt \begin{equation*}y = m + A\sin kx,\quad k=2\pi /\lambda,\end{equation*} !et where $\lambda$ is the wavelength of the sine waves, $A$ is the wave amplitude, and $m$ is the mean value of the wave. The class looks like !bc cod class Wave(Shape): def __init__(self, xstart, xstop, wavelength, amplitude, mean_level): self.xstart = xstart self.xstop = xstop self.wavelength = wavelength self.amplitude = amplitude self.mean_level = mean_level Shape.__init__(self) def subshapes(self): npoints = (self.xstop - self.xstart)/(self.wavelength/61.0) x = linspace(self.xstart, self.xstop, npoints) k = 2*pi/self.wavelength # frequency y = self.mean_level + self.amplitude*sin(k*x) return Curve(x,y) !ec With this and the previous example, you should be in a position to write your own subclasses. Exercises~ref{sec:oo:ex11}--ref{sec:oo:ex13b} suggest some smaller projects. # ===== A Class for Drawing Springs ===== # # Give: bottom point $B$, number of spring tags $n$, length $L$. Assume that # $L/3$ is the bottom and top vertical line and that the tags # are in the middle $L/3$. The width of the tags, $w$, can be either fixed to # a number or relative to $L$ (say $L/10$ -- need two variables, one # fixed true/false and one value). # # # \notready __Functions for Controlling Lines, Colors, etc.__ The `shapes` module containing class `Shape` and all subclasses mentioned above, also offers some additional functions that do not depend on any particular shape: * `display()` for displaying the defined figures so far (all figures whose `draw` method is called). * `erase()` for ereasing the current figure. * `hardcopy(name)` for saving the current figure to a PNG file `name.png`. * `set_linecolor(color)` for setting the color of lines, where `color` is a string like `'red'` (default), `'blue'`, `'green'`, `'aqua'`, `'purple'`, `'yellow'`, and `'black'`. * `set_linewidth(width)` for setting the width of a line, measured as an integer (default is 2). * `filled_curves(on)` for turrning on (`on=True`) or off (`on=False`) whether the area inside a shape should be filled with the current line color. Actually, the functions above are static methods in class `Shape` (cf.~Chapter ref{sec:class:static}), and they are just mirrored as global functions\footnote{You can look into `shapes.py` to see how we automate the duplication of static methods as global functions.} in the `shapes` module. Users without knowledge of static methods do not need to use the `Shape` prefix for reaching this functionality. ===== Scaling, Translating, and Rotating a Figure ===== label{sec:oo:scaling} The real power of object-oriented programming will be obvious in a minute when we, with a few lines of code, suddenly can equip *all* shape objects with additional functionality for scaling, translating, and rotating the figure. __Scaling.__ Let us first treat the simplest of the three cases: scaling. For a `Curve` instance containing a set of $n$ coordinates $(x_i,y_i)$ that make up a curve, scaling by a factor $a$ means that we multiply all the $x$ and $y$ coordinates by $a$: !bt \begin{equation*} x_i \leftarrow ax_i,\quad y_i\leftarrow ay_i,\quad i=0,\ldots,n-1\thinspace . \end{equation*} !et Here we apply the arrow as an assignment operator. The corresponding Python implementation in class `Curve` reads !bc cod class Curve: ... def scale(self, factor): self.x = factor*self.x self.y = factor*self.y !ec Note here that `self.x` and `self.y` are Numerical Python arrays, so that multiplication by a scalar number `factor` is a vectorized operation. In an instance of a subclass of `Shape`, the meaning of a method `scale` is to run through all objects in the list `self.shapes` and ask each object to scale itself. This is the same delegation of actions to subclass instances as we do in the `draw` method, and all objects, except `Curve` instances, can share the same implementation of the `scale` method. Therefore, we place the `scale` method in the superclass `Shape` such that all subclasses can inherit this method. Since `scale` and `draw` are so similar, we can easily implement the `scale` method in class `Shape` by copying and editing the `draw` method: !bc cod class Shape: ... def scale(self, factor): for shape in self.shapes: shape.scale(factor) !ec This is all we have to do in order to equip all subclasses of `Shape` with scaling functionality! But why is it so easy? All subclasses inherit `scale` from class `Shape`. Say we have a subclass instance `s` and that we call `s.scale(factor)`. This leads to calling the inherited `scale` method shown above, and in the `for` loop we call the `scale` method for each `shape` object in the `self.shapes` list. If `shape` is not a `Curve` object, this procedure repeats, until we hit a `shape` that is a `Curve`, and then the scaling on that set of coordinates is performed. __Translation.__ A set of coordinates $(x_i, y_i)$ can be translated $x$ units in the $x$ direction and $y$ units in the $y$ direction using the formulas !bt \begin{equation*} x_i\leftarrow x+x_i,\quad y_i\leftarrow y + y_i,\quad i=0,\ldots,n-1\thinspace . \end{equation*} !et The corresponding Python implementation in class `Curve` becomes !bc cod class Curve: ... def translate(self, x, y): self.x = x + self.x self.y = y + self.y !ec The translation operation for a shape object is very similar to the scaling and drawing operations. This means that we can implement a common method `translate` in the superclass `Shape`. The code is parallel to the `scale` method: !bc cod class Shape: .... def translate(self, x, y): for shape in self.shapes: shape.translate(x, y) !ec __Rotation.__ Rotating a figure is more complicated than scaling and translating. A counter clockwise rotation of $\theta$ degrees for a set of coordinates $(x_i,y_i)$ is given by !bt \begin{align*} \bar x_i &\leftarrow& x_i\cos\theta - y_i\sin\theta,\\ \bar y_i &\leftarrow& x_i\sin\theta + y_i\cos\theta\thinspace . \end{align*} !et This rotation is performed around the origin. If we want the figure to be rotated with respect to a general point $(x,y)$, we need to extend the formulas above: !bt \begin{align*} \bar x_i &\leftarrow& x + (x_i -x)\cos\theta - (y_i -y)\sin\theta,\\ \bar y_i &\leftarrow& y + (x_i -x)\sin\theta + (y_i -y)\cos\theta\thinspace . \end{align*} !et The Python implementation in class `Curve`, assuming that $\theta$ is given in degrees and not in radians, becomes !bc cod def rotate(self, angle, x=0, y=0): angle = angle*pi/180 c = cos(angle); s = sin(angle) xnew = x + (self.x - x)*c - (self.y - y)*s ynew = y + (self.x - x)*s + (self.y - y)*c self.x = xnew self.y = ynew !ec The `rotate` method in class `Shape` is identical to the `draw`, `scale`, and `translate` methods except that we have other arguments: !bc cod class Shape: .... def rotate(self, angle, x=0, y=0): for shape in self.shapes: shape.rotate(angle, x, y) !ec __Application: Rolling Wheel.__ To demonstrate the effect of translation and rotation we can roll a wheel on the screen. First we draw the wheel and rotate it a bit to demonstrate the basic operations: !bc cod center = (6,2) # the wheel's center point w1 = Wheel(center=center, radius=2, inner_radius=0.5, nlines=7) # rorate the wheel 2 degrees around its center point: w1.rotate(angle=2, center[0], center[1]) w1.draw() display() !ec Now we want to roll the wheel by making many such small rotations. At the same time we need to translate the wheel since rolling an arc length $L=R\theta$, where $\theta$ is the rotation angle (in radians) and $R$ is the outer radius of the wheel, implies that the center point moves a distance $L$ to the left ($\theta >0$ means counter clockwise rotation). In code we must therefore combine rotation with translation: !bc cod L = radius*angle*pi/180 # translation = arc length w1.rotate(angle, center[0], center[1]) w1.translate(-L, 0) center = (center[0] - L, center[1]) !ec We are now in a position to put the rotation and translation operations in a `for` loop and make a complete function: !bc cod def rolling_wheel(total_rotation_angle): """Animation of a rotating wheel.""" set_coordinate_system(xmin=0, xmax=10, ymin=0, ymax=10) center = (6,2) radius = 2.0 angle = 2.0 w1 = Wheel(center=center, radius=radius, inner_radius=0.5, nlines=7) for i in range(int(total_rotation_angle/angle)): w1.draw() display() L = radius*angle*pi/180 # translation = arc length w1.rotate(angle, center[0], center[1]) w1.translate(-L, 0) center = (center[0] - L, center[1]) erase() !ec To control the visual "velocity" of the wheel, we can insert a pause between each frame in the `for` loop. A call to `time.sleep(s)`, where `s` is the length of the pause in seconds, can do this for us. Another convenient feature is to save each frame drawn in the `for` loop as a hardcopy in PNG format and then, after the loop, make an animated GIF file based on the individual PNG frames. The latter operation is performed either by the `movie` function from `scitools.std` or by the `convert` program from the ImageMagick suite. With the latter you write the following command in a terminal window: !bc ccq convert -delay 50 -loop 1000 xxx tmp_movie.gif !ec Here, `xxx` is a space-separated list of all the PNG files, and `tmp_movie.gif` is the name of the resulting animated GIF file. We can easily make `xxx` by collecting the names of the PNG files from the loop in a list object, and then join the names. The `convert` command can be run as an `os.system` call. The complete `rolling_wheel` function, incorporating the mentioned movie making, will then be !bc cod def rolling_wheel(total_rotation_angle): """Animation of a rotating wheel.""" set_coordinate_system(xmin=0, xmax=10, ymin=0, ymax=10) import time center = (6,2) radius = 2.0 angle = 2.0 pngfiles = [] w1 = Wheel(center=center, radius=radius, inner_radius=0.5, nlines=7) for i in range(int(total_rotation_angle/angle)): w1.draw() display() filename = 'tmp_ #03d' i pngfiles.append(filename + '.png') hardcopy(filename) time.sleep(0.3) # pause 0.3 sec L = radius*angle*pi/180 # translation = arc length w1.rotate(angle, center[0], center[1]) w1.translate(-L, 0) center = (center[0] - L, center[1]) erase() # erase the screen before new figure cmd = 'convert -delay 50 -loop 1000 #s tmp_movie.gif' \ # (' '.join(pngfiles)) import commands failure, output = commands.getstatusoutput(cmd) if failure: print 'Could not run', cmd !ec The last two lines run a command, from Python, as we would run the command in a terminal window. The resulting animated GIF file can be viewed with `animate tmp_movie.gif` as a command in a terminal window.