The Arcology Garden

Paper.js as a Tool for Generative Vector Art

LifeTechEmacsTopicsArcology

Paper.js is a JavaScript Tool for Generative Art. This is a Literate Programming environment for building little toys in Paper.js. Eventually, I would like to have different AxiDraw projects in their own files, input presented with output as with all my other creative output.

I have looked at some other libraries for Generative Art like axi's turtle library and various other turtle implementations, Quill, snek, and other lisp libraries … and here I am. Paper.js seems like a pretty straightforward thing to do what I want, which is to be able to program in the literate fashion, present the source code and a rendering of the artwork, and also be able to bring that artwork to physical medium with the AxiDraw. For this I need to either be able to directly drive the AxiDraw with my code, or I need to be able to capture an SVG of the work. Paper.js provides the latter, while conseving the former, an optimal design, but implemented in JavaScript, an ecosystem I am not so excited for in its current state. I could write ClojureScript but that leaves me similarly unexcited.

And so I tread carefully in to the territory; I'm terribly disinterested in setting up a full webpack gruntfile source map buildsystem madlads – this is going to be terribly simple, and I want to be able to integrate this work in to Arcology – the Arcology must be able to render this creative output.

But for now: the bare minimum. Run the server below after org-exporting this document, navigate to http://127.0.0.1:8081/

Server Setup and simple index.html for play

I try to contain all of this in the paper subdirectory, so that it's easy enough to start over or exclude these files from sync to certain devices. I'm being really half-assed here, installing paper with the Node Package Mangler and then pulling it out and shoving it in my public web directory under js.

mkdir -p paper/public; pushd paper
npm install paper http-server

mkdir public/js/ 
cp node_modules/paper/dist/paper-full.js public/js/

To start the static server, run elisp:(async-shell-command "paper/node_modules/.bin/http-server ./paper/public/"). I don't want to really deal with a server-side aspect yet, and optimally this is built in to Arcology, so I'm just using this standard http-server package.

At this point, I can include Paper.js in web-exports here, and some CSS to make sure my canvases look good.

html,
body {
    height: 100%;
}

/* Scale canvas with resize attribute to full size */
canvas[resize] {
    width: 840px;
    height: 594px;
    background-color: white;
}

And this document is the "index.html" referenced in the heading title. It's a little bit janky right now, but I can sort of keep this system running. It's a mix of org-babel and org-export, and that sort of sucks. I have this python function, right? It's got a javascript template in it with the curly braces doubled so that Python's format function doesn't mind. How stupid is that? I'd like to rewrite this eventually, but figuring out how to get download buttons working requires a fair bit of mess. This creates a window.globals.projects object that is shared between the paperscript and the parent context so that I can export the SVGs from Javascript. What fun what fun.

def paperscript_it(canvas, code):
    return """
<script type="text/paperscript" canvas="{canvas}">
{input}
// shuttle the project out
globals.projects["{canvas}"] = paper.project;
</script>
<script type="text/javascript">
    window.globals = {{projects:{{}}}};
    var download_fn = function() {{
     var svg = window.globals.projects["{canvas}"].exportSVG({{asString:true}});
     var fileName = "{canvas}.svg"
     var url = "data:image/svg+xml;utf8," + encodeURIComponent(svg);
     var link = document.createElement("a");
     link.download = fileName;
     link.href = url;
     link.click();
   }}
   document.addEventListener("DOMContentLoaded", function(event) {{
    var els = document.getElementById("b_{canvas}");
    els.onclick = download_fn;
   }});
</script>
<button id="b_{canvas}" class="download-btn">Download</button>
""".format(canvas=canvas, input=code)

It's simple, it prints some HTML given a string of code and a target canvas's ID. That can be included in the export, then,

* %?

#+HTML: <canvas id="%^{name}" resize></canvas>

#+name: %^{name}
#+begin_src javascript
#+end_src

#+begin_src python :noweb yes :exports results :results html :session paperscript-export
f = """
<<%^{name}>>"""
paperscript_it("%^{name}", f)
#+end_src
(provide 'cce/paper-js)
(with-eval-after-load 'org-capture
  (add-to-list 'org-capture-templates
               '("P" "Paper.js Entry" entry
                 (file "~/org/paper_js.org") 
                 (file "~/org/paper/org-capture-template")
                 :clockin t)))

Tutorials, Getting Started, etc..

function rand(max) {
    return Math.floor(Math.random() * Math.floor(2*max))-max;
}
function jitter(dist) {
  return new Point(rand(dist), rand(dist));
}
var path = new Path();
path.strokeColor = 'black';
path.moveTo(new Point(200,200))
for(var i=0; i<=6; i++) {
    path.lineBy(jitter(50))
}

Spiral Example

You can feed an object with angle and length included in it to make relative polar movements! mind the end, the smoothing struggles a bit.

var path = new Path();
path.strokeColor = 'black';
path.add(view.center);

for (var i = 0; i < 300; i++) {
    // oh wow!
    var vector = new Point({
        angle: i * 45,
        length: i / 2
    });
    path.lineBy(vector);
}

// Smooth the handles of the path:
path.smooth();

generative wander

this is an attempt at doing more complicated things, including relative movements. Sort of just meandering around. Being able to feed an angle and a distance is pretty neat. Next, I'd like to figure out how to get these movements to be relative.

function rand(max) {
    return Math.floor(Math.random() * Math.floor(max));
}

function recenter(val, max) {
    return val - max/2;
}

for (var h = 0; h < 10; h++) {
    var path = new Path();
    path.strokeColor = 'black';
    path.add(view.center);

    var angle = rand(360);
    for (var i = 0; i < 50; i++) {
        angle = angle+recenter(rand(180), 90) % 360;
        var vector = new Point({
            angle: angle,
            length: 25
        });
        path.lineBy(vector);
    }

    // Smooth the handles of the path:
    path.smooth();
}

Grid Functions

some circle patterns, clipping.

this is a pattern I should probably move in to a library of babel.

var w = view.size.width;
var h = view.size.height;
var dw = 20, dh = 20;
var colors = ['black', 'green', 'red', 'blue'];
var cgroups = {};

for (cid in colors) {
    var color = colors[cid];
    cgroups[color] = new Layer({name: "" + (cid+1) + " " + color});
}

for (var y = 0; y < h; y=y+dh) {
    for (var x = 0; x < w; x+=dw) {
        var color = colors[(y/dh % 4)];
        var path = new Path.Circle({
            center: [x, y],
            radius: (3*x + y) % 50,
            strokeColor: color,
        });
        cgroups[color].addChild(path);
    }
}

Grid Functions 2

Playing more with this theme, I could build a little harnes out of this…

var w = view.size.width;
var h = view.size.height;
var dw = 20, dh = 20;
var colors = ['black', 'green', 'red', 'blue'];
var cgroups = {};

for (cid in colors) {
    var color = colors[cid];
    cgroups[color] = new Layer({name: "" + cid + " " + color});
}

for (var y = 0; y < h; y=y+dh) {
    for (var x = 0; x < w; x+=dw) {
        var color = colors[(Math.abs(-1 * x/dw % 10) + (y/dh % 17)) % 4];
        var path = new Path.Rectangle({
            center: [x, y],
            size: [
                (3*x + y) % 25,
                (3*x + y) % 25
            ],
            strokeColor: color,
        });
        cgroups[color].addChild(path);
    }
}

Grid Functions Harness

Let's make this a bit more repeatable…

ColorGridFunction = function(props) {
    this.width = props.width || 250;
    this.height = props.height || 250;
    this.dw = props.dw || 250;
    this.dh = props.dh || 25;
    this.colors = props.colors || ['black'];

    this.project = props.project;
    this.drawFn = props.drawFn;
    this.cgroups = {};

    this.makeLayers()
}

ColorGridFunction.prototype.makeLayers = function() {
    for (cid in this.colors) {
        var color = this.colors[cid],
            name  = "" + cid + " " + color;
        this.cgroups[color] = new Layer({
            name: name,
        });
        this.project.addLayer(this.cgroups[color]);
    }
}

ColorGridFunction.prototype.draw = function() {
    for (var y = 0; y < this.height; y=y+this.dh) {
        for (var x = 0; x < this.width; x+=this.dw) {
            this.drawFn(x,y);
        }
    }
    paper.view.draw();
}

ColorGridFunction.prototype.color = function(idx) {
    return this.colors[idx];
}

ColorGridFunction.prototype.path = function(color, path) {
    this.cgroups[color].addChild(path);
}

cgf = function(props) {
    return new ColorGridFunction(props);
}

cgf({
    project: project,
    width: view.size.width,
    height: view.size.height,
    dw: 20, dh: 20,
    colors: ['black', 'green', 'red', 'blue'],
    drawFn: function(x,y) {
        var color = this.color(
            (Math.abs(-1 * x/this.dw % 10)
             + (y/this.dh % 17))
            % 4
        );
        var path = new Path.Rectangle({
            strokeStyle: color,
            center: [x, y],
            size: [
                (3*x + y) % 25,
                (3*x + y) % 25
            ],
        });
        this.path(color, path);
    }
}).draw();

I don't know why this works and I don't feel like making it… this tool is baroque and so is writing eczema script 5. sketch online.

Hatching

Some code to generate hatching suitable for the plotter. This hatching pattern is a +.

The masking didn't work at all when I ran this in axicli

var hatch = function(rectangle, lineLength, lineDensity) {
    var g = new Group({});
    var spacing = 10 * lineDensity;
    var slip = false;
    var startx = rectangle.x,
        starty = rectangle.y,
        lx = rectangle.width,
        ly = rectangle.height;
    for (var y=starty; y<starty + ly; y+=spacing) {
        var x = startx;
        if (slip) x += (spacing+lineLength) / 2;
        for (; x<startx + lx; x+=(spacing+lineLength)) {
            g.addChild(new Path.Line({
                from: [x, y],
                to:   [x+lineLength, y],
                strokeColor: 'blue',
            }));
            var path = new Path.Line({
                from: [x, y],
                to:   [x+lineLength, y],
                strokeColor: 'blue',
            }).rotate(90);
            g.addChild(path);
        }
        if (slip) {
            x-=(lineLength / 2);
            slip = false;
        } else slip = true;
    }
    return g;
}


var lines = [
    new Path.Circle({
        center: view.center,
        radius: 240,
        strokeColor: 'black',
        fillColor: 'white',
    }),
    new Path.Circle({
        center: view.center,
        radius: 120,
        strokeColor: 'black',
        fillColor: 'white',
    })
]
for (var y=view.center.y-100; y<=view.center.y+100; y+=20) {
    lines.concat(new Path.Line({
        from: [0,y],
        to: [view.size.width,y],
        strokeColor: 'black',
    }));
}
blackLines = new Layer({
    name: '3 outline',
    children: lines,
});

// hatching goes last, gets pushed to top.
outerClip = new Layer({
    name: '1 outer clip',
    children: [
        new Path.Circle({
            center: view.center,
            radius: 240,
            strokeColor: 'black',
            fillColor: 'white',
            clipMask: true,
        }),
        hatch(new Rectangle([0, 0], [view.size.width, view.size.height]), 20, 2.0),
        // this is the inner path of the donut, used for occluding these hashes
        new Path.Circle({
            center: view.center,
            radius: 120,
            strokeColor: 'black',
            fillColor: 'white',
        }),
    ],
    clipped: true
});
innerClip = new Layer({
    name: '2 inner clip',
    children: [
        new Path.Circle({
            center: view.center,
            radius: 120,
            strokeColor: 'black',
            fillColor: 'white',
            clipMask: true,
        }),
        hatch(new Rectangle([200,200], [400,400]), 10, 1.0)
            .rotate(45),
    ],
    clipped: true
});
outerClip.bringToFront();
innerClip.bringToFront();