Copyright 2020-2022 Moddable Tech, Inc.
Revised: February 13, 2022
The Moddable SDK now supports 2D vector graphics: Poco and Piu can render anti-aliased shapes in color with blending.
There are three steps to rendering an 2D vector graphics. This document explains each.
- Build a path for the shape. Several APIs are provided to define the path.
- Create an outline from the path. The outline is created by either stroking or filling the path.
- Draw the outline. Both the Piu user interface framework and Poco renderer can draw outlines.
Familiar programming interfaces are supported to define the shapes:
- Canvas paths to build shapes programatically.
- SVG paths to use shapes built with vector graphics tools.
The rendering implementation is based on FreeType Outline Processing API.
There are three new modules:
-
commodetto/outline
– defines the Outline
class, with static methods to build paths and to create outlines by filling or stroking a path
-
commodetto/PocoOutline
– extends the Poco renderer to render outlines
-
piu/shape
– extends Piu with a shape object that displays outlines
Outlines are host objects that encapsulate FreeType outline data. Outlines are created by filling or stroking a path. Outlines can be cloned, rotated, scaled, and translated.
Paths are arrays of subpaths. Subpaths are sequences of curves and lines, stored into array buffers as commands and points. Subpaths can be open or closed.
Projects using Outlines with Piu should include the manifest_outline_piu.json
manifest; projects using Poco, the manifest_outline_poco.json
manifest.
All animated figures in this document are generated by mods. A link to the source code of the mod appears next to the figure it generates. Each mod exports a Piu behavior that is assigned to a Piu shape by the app (aka host) that runs the mod.
The Outline
class has several different ways to build paths.
import Outline from "commodetto/outline";
A constructor that returns a new instance of Outline.CanvasPath.prototype
. Use the instance to build a path with the CanvasPath
programming interface.
The Outline.CanvasPath.prototype
supports the following methods:
arc(x, y, radius, startAngle, endAngle, counterclockwise)
arcTo(x1, y1, x2, y2, r)
bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y)
closePath()
ellipse(x, y, radiusX, radiusY, rotation, startAngle, endAngle, counterclockwise)
lineTo(x, y)
moveTo(x, y)
quadraticCurveTo(cpx, cpy, x, y)
rect(x, y, w, h)
These methods are documented in the HTML specification under Building Paths.
source
A constructor that returns a new instance of Outline.FreeTypePath.prototype
. Use the instance to build a path with a programming interface inspired by FreeType's Glyph Stroker API.
The Outline.FreeTypePath.prototype
has the following methods:
-
beginSubpath(x, y, open)
:
-
conicTo(cx, cy, x, y)
:
-
cubicTo(c1x, c1y, c2x, c2y, x, y
;
-
c1x
, c1y
: the first Bezier control point
-
c2x
, c2y
: the second Bezier control point
-
x
, y
: the destination point
- see
FT_Stroker_CubicTo
-
endSubpath()
:
-
lineTo(x, y)
:
source
Outline.PolygonPath(x0, y0, x1, y1 /* etc */)
A static method that creates a polygon from a list of coordinates.
Argument |
Type |
Description |
x0, y0, x1, y1 /* etc */ |
number |
the coordinates of the polygon vertices |
Returns a new path.
source
Outline.RoundRectPath(x, y, width, height, radius)
A static method that creates a rectangle with rounded corners.
Argument |
Type |
Description |
x, y, width, height |
number |
the coordinates of the rectangle |
radius |
number |
the radius of the rounded corners |
Returns a new path.
source
A static method that creates a path from an SVG path string.
Returns a new path.
The SVG path data parser supports the following commands:
- close path (Z, z)
- cubic Bézier (C, c, S, s)
- elliptical arc (A, a)
- line to (L, l, H, h, V, v)
- move to (M, m)
- quadratic Bézier (Q, q, T, t)
Since a path is an array, if there are multiple paths in the SVG element, use Outline.SVGPath
for each path data, then use Array.prototype.concat
to combine the paths.
source
The Outline
class has static methods to create outlines by filling or stroking a path.
Argument |
Type |
Description |
path |
object |
a path returned by one of the building paths static methods |
rule |
number |
Outline.NON_ZERO_RULE for the non zero rule, Outline.EVEN_ODD_RULE for the even-odd rule. Defaults to Outline.NON_ZERO_RULE . |
Returns a new outline, an instance of Outline.prototype
.
source
Outline.stroke(path, weight, linecap, linejoin, miterLimit)
Argument |
Type |
Description |
path |
object |
a path returned by one of the building paths static methods |
weight |
number |
the weight of the lines, defaults to 1 pixel |
linecap |
number |
Outline.LINECAP_BUTT for a full stop on the last point itself, Outline.LINECAP_ROUND for a half-circle around the last point, Outline.LINECAP_SQUARE for square around the last point. Defaults to Outline.LINECAP_ROUND . |
linejoin |
number |
Outline.LINEJOIN_ROUND for rounded line joins, Outline.LINEJOIN_BEVEL for beveled line joins, Outline.LINEJOIN_MITER for mitered line joins. Defaults to Outline.LINEJOIN_ROUND . |
miterLimit |
number |
the miter limit. Defaults to weight . |
Returns a new outline, an instance of Outline.prototype
.
source
source
The Outline
prototype has one getter to get the bounds of the outline, one method to clone outlines and three methods to transform outlines.
All transformations modify the outline's data and therefore can be irreversible because of the precision of coordinates.
Outline.prototype.get bounds()
Returns a the bounds of the outline, an object with x
, y
, width
and height
properties.
Outline.prototype.clone()
Returns a copy of the outline.
Outline.prototype.rotate(angle, cx, cy)
Argument |
Type |
Description |
angle |
number |
the rotation angle in radians |
cx , cy
|
number |
the rotation center. Defaults to 0, 0 . |
Returns the outline.
source
Outline.prototype.scale(x, y)
Argument |
Type |
Description |
x |
number |
the horizontal scaling factor |
y |
number |
the vertical scaling factor. Defaults to x
|
Returns the outline.
source
Outline.prototype.translate(x, y)
Argument |
Type |
Description |
x |
number |
the horizontal distance in pixels |
y |
number |
the vertical distance in pixels |
Returns the outline.
source
The Poco renderer has one method to render outlines and one method to render polygons.
Poco.prototype.blendOutline(color, blend, outline, x, y)
Argument |
Type |
Description |
color |
number |
the rendering color, a value returned by Poco.prototype.makeColor
|
blend |
number |
the level of blending, from a value of 0 for transparent to a value of 255 for opaque |
outline |
number |
the outline to render, an instance of Outline.prototype
|
x , y
|
number |
where to render the outline |
Poco.prototype.blendPolygon(color, blend, x0, y0, x1, y1 /* etc */)
Argument |
Type |
Description |
color |
number |
the rendering color, a value returned by Poco.prototype.makeColor
|
blend |
number |
the level of blending, from a value of 0 for transparent to a value of 255 for opaque |
x0, y0, x1, y1 /* etc */ |
number |
the coordinates of the polygon vertices |
Let us use outlines to build a simple oscilloscope.
First, we need to import modules, to initialize Poco itself and to make background and foreground colors.
import Poco from "commodetto/Poco";
import {Outline} from "commodetto/outline";
import Timer from "timer";
let poco = new Poco(screen, {rotation:90});
let background = poco.makeColor(0, 0, 0);
let foreground = poco.makeColor(0, 255, 0);
For the first channel, we define a function that draw samples by filling a polygon path.
function drawSamplesPolygon(samples, length, y, h) {
const delta = poco.width / (length - 1);
const points = new Array(2 * length);
for (let i = 0; i < length; i++) {
let j = i << 1;
points[j] = delta * i;
points[j + 1] = y + ((samples[i] / 255) * h);
}
const path = Outline.PolygonPath(0, y, ...points, poco.width, y);
const outline = Outline.fill(path);
poco.begin(0, 0, poco.width, 120);
poco.fillRectangle(background, 0, 0, poco.width, poco.height);
poco.blendOutline(foreground, 255, outline);
poco.end();
}
For the first channel, we define a function that draw samples by stroking a FreeType path. The path is slightly smoothed with conicTo
.
function drawSamplesOutline(samples, length, y, h) {
const dx = poco.width / (length - 1);
const cx = dx / 3;
let px = 0;
let py = y + ((samples[0] / 255) * h);
const path = new Outline.FreeTypePath();
path.beginSubpath(px, py, true);
for (let i = 1; i < length; i++) {
let qx = px + dx;
let qy = y + ((samples[i] / 255) * h);
path.conicTo(px + cx, py, qx - cx, qy, qx, qy);
px = qx;
py = qy;
}
path.endSubpath();
const outline = Outline.stroke(path, 2);
poco.begin(0, 120, poco.width, 120);
poco.fillRectangle(background, 0, 120, poco.width, 120);
poco.blendOutline(foreground, 255, outline);
poco.end();
}
Eventually we use a timer to shift old samples and set a new sample.
const h = poco.height / 4;
const length = 33;
const samples = new Int8Array(length).fill(0);
Timer.repeat(function() {
samples.copyWithin(0, 1);
samples[length - 1] = Math.floor(255 * Math.random()) - 128;
drawSamplesPolygon(samples, length, h, h);
drawSamplesOutline(samples, length, 3 * h, h);
}, 50);
In this example, samples are random values but could of course be generated by a sensor.
Draw using Piu Shape Object
The Piu shape
object is a content
object that uses the fill color and the stroke color of its skin to display a fill outline and a stroke outline. The stroke outline is displayed above the fill outline.
By default, a shape
object is empty, its fill outline and stroke outlines are null
. Set the fillOutline
or strokeOutline
properties to change its fill outline or its stroke outline.
The measured width of a shape object is the maximum of its fill outline and stroke outline widths. The measured height of a shape object is the maximum of its fill outline and stroke outline heights. So changing the fill outline or the stroke outline of a shape
object can reflow the layout of its containers.
Usually the same path is used to create the fill outline with Outline.fill
and the stroke outline with Outline.stroke
. However, the shape
object assumes no relationships between the two outlines.
Shape([behaviorData, dictionary])
Argument |
Type |
Description |
behaviorData |
* |
A parameter that is passed into the onCreate function of this shape's behavior . This may be any type of object, including null or a dictionary with arbitrary parameters. |
dictionary |
object |
An object with properties to initialize the result. Same as for content object (see Dictionary in the section Content Object). |
Returns an shape
instance, an object that inherits from Shape.prototype
Prototype inherits from Content.prototype
.
Name |
Type |
Default Value |
Read Only |
Description |
fillOutline |
object |
null |
|
The outline object to display with the fill color |
strokeOutline |
object |
null |
|
The outline object to display with the stroke color |
Let us use shapes to build a simple analog clock.
First, we need to import modules.
import {} from "piu/MC";
import {} from "piu/shape";
import {Outline} from "commodetto/outline";
Then we define the shapes outlines in the onCreate
method of their behavior.
The frame is just a white circle.
class FrameBehavior extends Behavior {
onCreate(shape) {
const path = new Outline.CanvasPath();
path.arc(120, 120, 120, 0, 2 * Math.PI);
shape.fillOutline = Outline.fill(path);
}
}
The dial uses one outline for the minute ticks and one outline for the hour ticks.
class DialBehavior extends Behavior {
onCreate(shape) {
const fillPath = new Outline.CanvasPath();
const strokePath = new Outline.CanvasPath();
for (let i = 0; i < 60; i++) {
const a = 6 * i * Math.PI / 180;
const dx = Math.cos(a);
const dy = Math.sin(a);
if (i % 5) {
fillPath.moveTo(120 * dx, 120 * dy);
fillPath.lineTo(114 * dx, 114 * dy);
}
else {
strokePath.moveTo(120 * dx, 120 * dy);
strokePath.lineTo(95 * dx, 95 * dy);
}
}
shape.fillOutline = Outline.stroke(fillPath, 3, Outline.LINECAP_BUTT).translate(120, 120);
shape.strokeOutline = Outline.stroke(strokePath, 7, Outline.LINECAP_BUTT).translate(120, 120);
}
}
The hands inherit a behavior to rotate their outline.
Notice how the outline is cloned before being rotated in order to keep the coordinates precise.
class HandBehavior extends Behavior {
onClockChanged(shape, tick) {
const a = ((90 - (tick * 6)) % 360) * Math.PI / 180;
const cx = shape.width >> 1;
const cy = shape.height >> 1;
shape.fillOutline = this.outline.clone().rotate(a).translate(cx, cy);
}
}
Each hand has a different outline. All outlines are centered on 0,0
to be rotated and translated by onClockChanged
.
class HourBehavior extends HandBehavior {
onCreate(shape) {
const path = new Outline.CanvasPath();
path.moveTo(-7, -22);
path.lineTo(7, -22);
path.lineTo(6, 65);
path.lineTo(-6, 65);
path.lineTo(-7, -22);
path.closePath();
this.outline = Outline.fill(path);
}
}
class MinuteBehavior extends HandBehavior {
onCreate(shape) {
const path = new Outline.CanvasPath();
path.moveTo(-7, -22);
path.lineTo(7, -22);
path.lineTo(4, 98);
path.lineTo(-4, 98);
path.lineTo(-7, -22);
path.closePath();
this.outline = Outline.fill(path);
}
}
class SecondBehavior extends HandBehavior {
onCreate(shape) {
const path = new Outline.CanvasPath();
path.rect(-1, -30, 2, 96);
path.arc(0, 0, 4, 0, 2 * Math.PI);
path.closePath();
path.arc(0, 67, 6, 0, 2 * Math.PI);
path.closePath();
this.outline = Outline.fill(path);
}
}
Eventually we define the application behavior and template.
The application behavior uses anchors to distribute hour, minute and second ticks to the hands.
class ClockApplicationBehavior extends Behavior {
onCreate(application, $) {
this.$ = $;
application.interval = 500;
application.start();
}
onTimeChanged(application) {
const date = new Date();
const hours = date.getHours() % 12;
const minutes = date.getMinutes();
const seconds = date.getSeconds();
const $ = this.$;
$.HOURS.delegate("onClockChanged", (hours * 5) + (minutes / 12));
$.MINUTES.delegate("onClockChanged", minutes);
$.SECONDS.delegate("onClockChanged", seconds);
}
}
The application template creates the Piu hierarchy and binds shapes to their behaviors and skins.
let ClockApplication = Application.template($ => ({
Behavior: ClockApplicationBehavior, skin:{ fill:"black" },
contents: [
Shape($, { width:240, height:240, Behavior:FrameBehavior, skin:{ fill:"white", stroke:"black" } } ),
Shape($, { width:240, height:240, Behavior:DialBehavior, skin:{ fill:"black", stroke:"black" } } ),
Shape($, { anchor:"HOURS", width:240, height:240, Behavior:HourBehavior, skin:{ fill:"black" } } ),
Shape($, { anchor:"MINUTES", width:240, height:240, Behavior:MinuteBehavior, skin:{ fill:"black" } } ),
Shape($, { anchor:"SECONDS", width:240, height:240, Behavior:SecondBehavior, skin:{ fill:"red" } } ),
]
}));
export default new ClockApplication({}, { displayListLength:4096, touchCount:1, pixels: 240 * 64 });