This article builds off my previous article, Intro to Cairo Graphics in Rust. In that, I briefly described how to install Rust and the Cairo graphics library and set up a Rust project using the cairo-rs
bindings. With that article, and a bit of dedicated research on your own, you should have been able to start coding some drawings using the Rust programming language. In this article, I’m going to build on what I described there, and introduce my own library, bitlib-rs, that adds a bunch of new functionality on top of Cairo.
I used to do a lot of work in Flash. ActionScript. RIP. I had, at various times, built up libraries of additional drawing routines that made it easier to draw complex shapes, manipulate colors, etc. But for some reason I never got very organized with it back then.
Eventually, I switched over to JavaScript and started using Canvas to draw stuff. By that time, github was a thing, and I started creating libraries that actually stuck around. This culminated in my own bitlib JavaScript library that I personally used all the time. It’s got all kinds of advanced drawing routines, animation stuff, a bunch of math and geometry functions, a pseudo random number generator, and hard core color manipulation tools.
When I started working with Rust and Cairo, I wanted all that functionality to come over with me. So I got to work porting bitlib
to bitlib-rs
. This was a great way for me to learn Rust itself. 800-something lines of code I knew inside out, that had to be recreated in a totally different paradigm. With the added verbosity of the new language, pulling some other stuff in from some of my earlier JS libraries, and riffing on existing code with new functionality, I’m up to over 1,400 lines of Rust. It’s still very much a work in progress, and as of this writing, should be considered to be in a pre-alpha state. Functions might be broken. APIs might change. More stuff needs to be tested and documented. But there’s some good stuff in there.
Let’s start by adding bitlib-rs
to a new project. Use cargo new to create a project. In the dependencies, add:
[dependencies]
bitlib = { git = "https://github.com/bit101/bitlib-rs.git" }
Normally, dependencies are added with a version number when they are registered with the crates dependency system. Since bitlib-rs
is not there yet, you can just add it with the git url. Alternately, you could check out the project and add it via a path:
[dependencies]
bitlib = { path = "path/to/bitlib-rs" }
I use this method while I’m working on the library and want to see the latest changes in a test project.
Now, in your main.rs
, add the following line at the top of the file:
extern crate bitlib;
Now you’re ready to use the various structs and methods in your code. But first, let’s get an idea of how bitlib-rs
can make using Cairo easier. Let’s make a bare-bones application that draws a bunch of randomly sized, positioned and colored circles and saves it as a png.
extern crate cairo;
extern crate rand;
use cairo::{ Context, ImageSurface, Format };
use std::fs::File;
use std::f64::consts::PI;
use rand::random;
fn main() {
let surface = ImageSurface::create(Format::ARgb32, 600, 600)
.expect("couldn't create a surface, yo");
let context = Context::new(&surface);
for _i in 0..1000 {
let x = random::<f64>() * 600.0;
let y = random::<f64>() * 600.0;
let r = rand::random::<f64>() * 45.0 + 5.0;
context.set_source_rgb(random::<f64>(),
random::<f64>(),
random::<f64>());
context.arc(x, y, r, 0.0, PI * 2.0);
context.fill();
}
let mut file = File::create("output.png").unwrap();
surface.write_to_png(&mut file)
.expect("Couldn't write to png");
}
It’s not too bad, but there are some points there that seem a bit painful to me to have to do over and over.
To contrast, here is the exact same program written using bitlib-rs
:
extern crate bitlib;
use bitlib::canvas::{ Canvas, BitContext };
use bitlib::color::Color;
use bitlib::random::Random;
use bitlib::file::open;
fn main() {
let canvas = Canvas::create(600.0, 600.0);
let context = canvas.get_context();
let mut rand = Random::new();
for _i in 0..1000 {
let x = rand.float(0.0, 600.0);
let y = rand.float(0.0, 600.0);
let r = rand.float(5.0, 50.0);
context.set_source_color(&Color::random_rgb());
context.fill_circle(x, y, r);
}
canvas.write("output.png");
open("output.png");
}
Let’s go through it step by step. First, the creation of the surface and context. I always want to create both of these, in the exact same way. They are a pair. So I created a Canvas struct that encapsulates them. This now goes from:
let surface = ImageSurface::create(Format::ARgb32, 600, 600)
.expect("couldn't create a surface, yo");
let context = Context::new(&surface);
to this:
let canvas = Canvas::create(600.0, 600.0);
let context = canvas.get_context();
You can count characters typed if you want. But for me, it’s just a lot less boilerplate to keep in my head. Canvas::create
and get_context
. Simple.
Next, the random syntax. The rand
crate is very powerful, but can be a bit complex to use, especially if you want a seeded pseudo random number generator (PRNG). Using the simplest (non-seeded) version, you get this:
let x = random::<f64>() * 600.0;
let y = random::<f64>() * 600.0;
let r = random::<f64>() * 45.0 + 5.0;
I don’t really love having to specify the data type in angle brackets and doing the math to generate a range. So I encapsulated rand’s more complex PRNG functionality into bitlib::random::Random
. You have to create the PRNG, and then you have various methods to get int ranges, float ranges, bools, etc.
let mut rand = Random::new();
and then…
let x = rand.float(0.0, 600.0);
let y = rand.float(0.0, 600.0);
let r = rand.float(5.0, 50.0);
Doesn’t save a whole lot of typing, but again, it’s much easier on the brain. At least on my brain.
Then there’s color management. Cairo-rs has two methods for setting color:
context.set_source_rgb(r, g, b);
context.set_source_rgba(r, g, b, a);
In these, each value is a normalized value (0.0 to 1.0). For those of us who are used to 0-255 integers and hex values for color, this can be hard to think with. So a large portion of bitlib-rs
is devoted to powerful color methods. You create an instance of the Color struct with various methods such as:
Color::rgb(r, g, b); // values from 0.0 to 1.0
Color::rgba(r, g, b, a); // values from 0.0 to 1.0
Color::rgb_int(r, g, b); // values from 0 to 255
Color::rgba_int(r, g, b, a); // values from 0 to 255
Color::from_int(0xffcc9966); // single integer argb.
Color::from_string("#ffcc99"); // rgb with 100% opacity
Color::from_string("#80ffcc99"); // argb
Color::from_string("palevioletred"); // standard css color names
Color::grey(shade); // 0.0 to 1.0 shade
Color::grey_int(shade); // 0 to 255 shade
Color::hsv(h, s, v); // h = 0.0 to 360.0. s, v = 0.0 to 1.0
Then the random color methods:
Color::random_rgb(); // what it sounds like
Color::random_grey(); // also what it sounds like
Color::random_grey_range(min, max); // min and max both 0.0 to 1.0
Color::random_hsv(hmin, hmax, smin, smax, vmin, vmax); // you get the idea
Finally, some hard-coded colors for black, white, red, green, blue, yellow, cyan, magenta:
Color::black();
Color::red(); // etc.
So, our randomly colored circles go from:
context.set_source_rgb(random::<f64>(),
random::<f64>(),
random::<f64>());
to:
context.set_source_color(&Color::random_rgb());
Two things to note here. One is that there’s a new method on context, set_source_color
. I’ll get into how that happened in a moment. Also, this method takes a reference. So it’s &Color
instead of just Color
. This lets the context borrow colors rather than move them, so you can reuse a single color multiple times. It’s a Rust thing you need to understand early on.
Now, about that context. It’s got some drawing methods, sure. But they are pretty minimal. That’s fine. I’m a big fan of APIs being minimal but extendable. There are a lot of things I want to draw that are not simply lines, arcs, rectangles or bezier curves. But they can all be composed from those things.
One of the brilliant pieces of Rust is its trait system. You can think of a trait as an interface… kind of. It defines methods that should be on a struct that implements that trait. Just like an interface defines methods that should be on a class that implements that interface. But Rust allows you to implement the trait methods on other structs. Even structs you don’t own, like cairo::Context
. With that, I was able to extend Context with a whole boatload of new methods that are indistinguishable from native methods. All you have to do is tell Rust that you want to use that trait. My trait is called BitContext
, so you just have to say:
use bitlib::canvas::BitContext;
at the top of the file and you’re all set. This is why we now magically have a new method called context.set_source_color
, when Context itself only has set_source_rgb
and knows nothing at all about my custom Color module. I’ve also added a ton of custom shapes, so instead of using arc and fill:
context.arc(x, y, r, 0.0, PI * 2.0);
context.fill();
I can now just draw a filled circle:
context.fill_circle(x, y, r);
The BitContext
trait has all kinds of other drawing methods on it. Methods to draw ellipses, rounded rectangles, various types of curves and paths, polygons, stars, heart shapes, grids, etc. They each have a simple version that creates a path, and fill as well as stroke variants where appropriate.
Finally, we get to the point of saving the drawing as a png. Normally, you need to create a file and set up error handling, and use that file in the surface.write_to_png
method, again with more error handling.
let mut file = File::create("output.png").unwrap();
surface.write_to_png(&mut file)
.expect("Couldn't write to png");
But with the Canvas struct, you can just call write, with the path.
canvas.write("output.png");
open("output.png");
Also notice the final line, which is a call to bitlib::file::open
. This will open the file in whatever application is registered to open that kind of file. As of this writing, this is set up to work on Linux and MacOS. It’s a nice added touch that lets you create and display the image just by running the program.
In addition to all that, there are geometry and math modules that have a bunch of other useful functions in them. One thing to note is that almost all the methods in those modules work exclusively with f64 values – 64-bit floating point numbers. Many of them could be made generic, but because cairo-rs
‘s drawing methods all take f64s, I decided to keep these methods using the same data type. That may change in the future.
You may have noticed that I kind of skimped on the error handling. In various places where errors need to be handled, I did the bare minimum of expect
or unwrap
, which will work fine when things work fine, but will crash the program (with a hopefully useful error message) if you, say, try to use a file you don’t have access to. For my purposes – writing programs that generate images – this is totally fine. To make it more robust, I’d probably want to pass those possible error conditions to the caller of the functions that are currently squashing those errors. That would make the whole system more complex to use and defeat the purposes of a lot of the shortcut methods I’ve created. If you plan on doing something more sensitive – writing an app that others are going to depend on, you’d want to not use these shortcuts, and actually handle the errors appropriately.
To recap, this whole library is in its very early stages. But I know that a lot of the people who follow me may be interested in learning something new like Rust and Cairo and if this code were to give someone a bit of a boost, and some sample code to get started, that would be great.
Comments? Best way to shout at me is on Mastodon