![]() |
||
---|---|---|
.. | ||
README.md | ||
micropython.cmake | ||
picovector.c | ||
picovector.cpp | ||
picovector.h | ||
polygon-bullseye.png | ||
transform-only-rotate.png | ||
transform-operations-corrected.png | ||
transform-operations-reversed.png | ||
transform-translate-end.png | ||
transform-translate-start.png |
README.md
PicoVector
PicoVector is an anti-aliased vector graphics implementation built on top of PicoGraphics.
PicoVector implements bindings for Pretty Poly and Alright Fonts. Providing a MicroPython interface for building polygons and shapes, handling transformations and rendering text.
While PicoGraphics supports basic primitives like rectangles and circles, PicoVector goes much further: allowing for arbitrary shapes with cut-outs for representing anything from a rounded rectangle to a complex icon. Bouncing DVD logo, anyone?
Quick Start
PicoVector wraps PicoGraphics and uses its existing pixel pushing powers to provide pretty polygons. As such, it's super simple to set up:
from picographics import PicoGraphics, YOUR_DISPLAY_TYPE
from picovector import PicoVector
graphics = PicoGraphics(YOUR_DISPLAY_TYPE)
vector = PicoVector(graphics)
Drawing Something
The essential drawing primitive for PicoVector is the Polygon.
Getting your head round Polygons might be a little more complex. PicoVector uses "Polygon" to mean a collection of one or more polygonal shapes, with subsequent shapes either punching out or filling in areas in an "exclusive or" fashion.
For example, three concentric circles added into a "Polygon" will create an outer donut shape from the first two circles, with the third circle appearing inside.
eg:
from picovector import Polygon
bullseye = Polygon()
bullseye.circle(120, 120, 100)
bullseye.circle(120, 120, 70)
bullseye.circle(120, 120, 30)
vector.draw(bullseye)
Transforms
Since PicoVector deals in Polygons, which are collections of immutable shapes, we need some other way move them around, scale them or rotate them.
Enter Transforms:
from picovector import Polygon, Transform
bullseye = Polygon()
bullseye.circle(120, 120, 100)
bullseye.circle(120, 120, 70)
bullseye.circle(120, 120, 30)
vector.set_transform(Transform().translate(100, 0))
vector.draw(bullseye)
A Transform is actually a 2d transformation matrix with some convenience functions for producing common, practical transformations.
Transformation matrices are weird, and you'll find you need to reverse your steps to get the expected results.
For example, say we want to draw a diamond in the middle of our 240x240 pixel screen.
Since we want to move a square to the middle of the screen, and then rotate it we might be tempted to do:
vector.set_transform(Transform().translate(95, 95).rotate(45, (120, 120)))
vector.draw(Polygon().rectangle(0, 0, 50, 50))
Move, and then rotate. That shoul... woah why is the diamond over there!??
To understand why this doesn't work, removing the translate()
step:
vector.set_transform(Transform().rotate(45, (120, 120)))
vector.draw(Polygon().rectangle(0, 0, 50, 50))
puts our diamond at the top of the screen, since we have rotated our 50x50
pixel square 45 degrees around the center of the screen. It's easy to see why
translating that by 95, 95
then puts it off to the right.
The right way is to add the translate()
last so that it happens first...
this is counter-intuitive but you'll get the hang of it after a few mishaps!
vector.set_transform(Transform().rotate(45, (120, 120)).translate(95, 95))
vector.draw(Polygon().rectangle(0, 0, 50, 50))
Perfect!
It's worth reiterating that the same rectangle is used in all the above examples,
a plain ol' 50x50 pixel square in the top left corner of the screen. We translate
by 95, 95
because this is the screen dimension (240x240) divided by two, minus
the square size (50x50) divided by two.
Another equally valid but slightly tricky approach would be to use a square like this:
Polygon().rectangle(-25, -25, 50, 50)
It's the same square, sort of, except it is centered on 0, 0
.
A Note About Rotation
To make rotation a little less confusing, the rotate()
transform method requires
an origin, that's the tuple given as the second argument. It's an X, Y coordinate
that describes the point around which the rotation should happen.
Rotation then performs three transformations- a translation to shift to your supplied origin, a rotation and a final translation back to its original location.
You can supply None
if you do not want this behaviour and like to live dangerously.
In Depth
Anti-aliasing Options
Behind the scenes all of PicoVector's drawing is done by PicoGraphics- by setting pixels. Unlike just directly drawing shapes with pixels PicoVector includes anti-aliasing, a smoothing technique that turns diagonal lines into the crisp, blended edges we'd expect from computers today.
Available options are:
ANTIALIAS_NONE
- turns off anti-aliasing for best performanceANTIALIAS_FAST
- 4x anti-aliasing, a good balance between speed & qualityANTIALIAS_BEST
- 16x anti-aliasing, best quality at the expense of speed
Text, Fonts and Text Metrics
Under the hood PicoVector uses Alright Fonts a font-format for embedded and low resource platforms.
Alright Fonts supports converting TTF and OTF fonts into .af format which can then be displayed using PicoVector. Most of your favourite fonts should work - including silly fonts like Jokerman - but there are some limitations to their complexity.
Converting
Converting from an OTF or TTF font is done with the afinate
utility. It's a
Python script that handles decomposing the font into a simple list of points.
Right now you'll need the port-to-c17
branch:
git clone https://github.com/lowfatcode/alright-fonts --branch port-to-c17
And you'll need to set up/activate a virtual env and install some dependencies:
cd alright-fonts
python3 -m venv .venv
source .venv/bin/activate
pip install freetype.py simplification
And, finally, convert a font with afinate
:
./afinate --font jokerman.ttf --quality medium jokerman.af
This will output two things- a wall of text detailing which characters have been converted and how many points/contours they consist of, and the font file itself. You'll then need to upload the font to your board, this could be via the file explorer in Thonny or your preferred method.
Loading & Configuring
vector.set_font("jokerman.af", 24)
Spacing & Alignment
set_font_size()
set_font_word_spacing()
set_font_letter_spacing()
set_font_line_height()
set_font_align()
Measuring Text
x, y, w, h = measure_text(text, x=0, y=0, angle=None)
Returns a four tuple with the x, y position, width and height in pixels.
Drawing Text
text(text, x, y, angle=None, max_width=0, max_height=0)
When you draw text the x, y position and angle are used to create a new
Transform to control the position and rotation of your text. The transform
set with .set_transform()
is also applied.
Polygons
The basis of all drawing operations in PicoVector is the Polygon
object.
A Polygon
is a collection of one or more paths to be drawn together. This
allows for shapes with holes - letters, for example - or more complicated
designs - logos or icons - to be scaled, rotated and drawn at once.
If paths overlap then the top-most path will "punch" out the one underneath.
To use any of the primitives or path drawing methods you must first create
a Polygon
, for example here's a simple triangle:
from picovector import Polygon
my_shape = Polygon()
my_shape.path((10, 0), (0, 10), (20, 10))
Rectangle
Polygon().rectangle(x, y, w, h, [corners=(tl, tr, bl, br)])
A rectangle is a plain old rectangular shape with optional rounded corners.
If stroke
is greater than zero then the rectangle outline will be produced.
If any of the corner radii are greater than zero then that corner will be created.
Regular
Polygon().regular(x, y, radius, sides, [stroke])
Creates a regular polygon with the given radius and number of sides. Needs at least 3 sides (an equilateral triangle) and converges on a circle.
If stroke
is greater than zero then the regular polygon outline will be created.
Circle
Polygon().regular(x, y, radius, [stroke])
Effectively a regular polygon, approximates a circle by automatically picking a number of sides that will look smooth for a given radius.
If stroke
is greater than zero then the circle outline will be created.
Arc
Polygon().arc(x, y, radius, from, to, [stroke])
Create an arc at x, y with radius, from and to angle (degrees).
Great for radial graphs.
If stroke
is greater than zero then the arc outline will be created.
Star
Polygon().star(x, y, points, inner_radius, outer_radius, [stroke])
Create a star at x, y with given number of points.
The inner and outer radius (in pixels) define where the points start and end.
If stroke
is greater than zero then the arc outline will be created.
Line
Polygon().line(x, y, x2, y2, thickness)
Path
Polygon().path((x, y), (x2, y2), (x3, y3), ...)
A path is simply an arbitrary list of points that produce a complete closed shape. It's ideal for drawing complex shapes such as logos or icons.
If you have a list of points you can use Python's spread operator to pass it
into path
, eg:
my_points = [(10, 0), (0, 10), (20, 10)]
my_shape = Polygon()
my_shape.path(*my_points)
Transformations
To create a new empty Transform()
you can just:
my_transform = Transform()
And tell PicoVector it's the one you want to use with:
vector.set_transform(my_transform)
T'is A-fine Day For Transformin'
Since PicoVector supports only 2D transformation matrices you can only perform translations which are "affine." The simplest way to think about the limitations of an affine transformation is to understand that parallel lines will remain parallel.
You can, for example, rotate a square into a diamond since its two pairs of parallel sides will remain parallel.
You cannot skew a square into a trapezoid, since this would require one pair of sides to not be parallel.
Translate
Totally unrelated to language, a translation describes a displacement along the X and or Y axes - or simply put: how far right and down you want a thing to move.
For example our friend Mx. Square from above lives in the top left-hand corner:
Say we translate them by 50, 120
:
vector.set_transform(Transform().translate(50, 120))
Away they go... hey come back we're not done yet!
Rotate
Transform().rotate(angle_degrees, (origin_x, origin_y))
Scale
Transform().scale(scale_x, scale_y)
Custom
If you want to get fancy, you can supply a custom 2d transformation matrix as a list of nine floating point numbers. Eg:
my_custom_transform = Transform().matrix([
1.0, 0.0, 0.0,
0.0, 1.0, 0.0,
0.0, 0.0, 1.0
])
Okay, this one isn't so fancy- it's a standard "identity" matrix which has no effect whatsoever.
What about calculating our own scale matrix?
scale_x = 10
scale_y = 10
my_custom_transform = Transform().matrix([
scale_x, 0.0, 0.0,
0.0, scale_y, 0.0,
0.0, 0.0, 1.0
])
Okay that was surprisingly easy. What about rotation, that must be hard, right?
angle = math.pi / 4 # 45 degrees in radians
my_custom_transform = Transform().matrix([
math.cos(angle), math.sin(angle), 0.0,
-math.sin(angle), math.cos(angle), 0.0,
0.0, 0.0, 1.0
])
Nope, it's pretty much just scale with bells on.