OPENRNDR pen plotting tricks (Axidraw etc.)

Thread for useful tricks when working with plotters and OPENRNDR.

1. Boolean operations (clipMode)

A useful operation when generating designs to plot is using boolean operations. Here some examples:

  • ClipMode.REVERSE_DIFFERENCE draws shapes as occluded by (behind) existing content

latest.Dedupe-2021-02-26-18.24.01

imports
import aBeLibs.geometry.dedupe
import org.openrndr.KEY_SPACEBAR
import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.dialogs.saveFileDialog
import org.openrndr.shape.ClipMode
import org.openrndr.shape.drawComposition
import org.openrndr.svg.saveToFile
fun main() {
    application {
        program {
            // create design
            val svg = drawComposition {
                fill = null
                circle(width / 2.0 - 100.0, height / 2.0, 100.0)
                circle(width / 2.0 + 100.0, height / 2.0, 100.0)
                clipMode = ClipMode.REVERSE_DIFFERENCE
                circle(width / 2.0, height / 2.0, 100.0)
            }
            extend {
                drawer.clear(ColorRGBa.WHITE)
                // render design
                drawer.composition(svg)
            }
            keyboard.keyDown.listen {
                when (it.key) {
                    KEY_SPACEBAR -> saveFileDialog(supportedExtensions = listOf("SVG" to listOf("svg"))) { file ->

                        // save design for plotting
                        svg.saveToFile(file)
                    }
                }
            }
        }
    }
}

  • ClipMode.UNION combines shapes

latest.Dedupe-2021-02-26-18.22.20

val svg = drawComposition {
  fill = null // note that there's no fill!
  circle(width / 2.0 - 100.0, height / 2.0, 100.0)
  clipMode = ClipMode.UNION
  circle(width / 2.0, height / 2.0, 100.0)
  circle(width / 2.0 + 100.0, height / 2.0, 100.0)
}
  • ClipMode.INTERSECTION keeps the areas in which a new shape overlaps with existing ones

latest.Dedupe-2021-02-26-18.23.43

  • ClipMode.DIFFERENCE uses a shape to delete parts of the existing shapes

latest.Dedupe-2021-02-26-18.21.12

3 Likes

2. Deduplicating segments

When plotting the REVERSE_DIFFERENCE example above we might have a small issue: let’s open it in Inkscape and move the shapes a bit:
image

The issue is that we have repeated lines. This is not an issue when showing the design on the screen, but when plotting, some lines are plotted twice. This will make plotting slower and some lines may look darker than others. No big deal with 3 circles, but with complex designs it does make a difference.

To get around this issue I wrote a simple function called .dedupe(). Using it couldn’t be easier: call the method before saving the file like this:

svg.dedupe().saveToFile(file)

Here’s the source code for Composition.dedupe() and for the required Segment.contains():

/**
 * For a Composition, filter out bezier segments contained in longer bezier segments.
 * The goal is to avoid drawing lines multiple times with a plotter.
 */
fun Composition.dedupe(err: Double = 1.0): Composition {
    val segments = this.findShapes().flatMap {
        it.shape.contours.flatMap { contour -> contour.segments }
    }
    val deduped = mutableListOf<Segment>()
    segments.forEach { curr ->
        if (deduped.none { other -> other.contains(curr, err) }) {
            deduped.add(curr)
        }
    }
    return drawComposition {
        contours(deduped.map { it.contour })
    }
}
/**
 * Simple test to see if a segment contains a different Segment.
 * Compares start, end and two points at 1/3 and 2/3.
 * Returns false when comparing a Segment to itself.
 */
fun Segment.contains(other: Segment, error: Double = 0.5): Boolean =
    this !== other &&
            this.on(other.start, error) != null &&
            this.on(other.end, error) != null &&
            this.on(other.position(1.0 / 3), error) != null &&
            this.on(other.position(2.0 / 3), error) != null

It probably doesn’t cover every case but maybe it helps someone?

2 Likes

3. Manipulating SVG files

Instead of writing one program that produces a finished design ready for plotting it can be useful to work in multiple passes. For example, write a program that creates a basic design, then a second one that applies an effect to the output of the first program.

If you have several such effects you can experiment with applying them in different order, or with different parameters, etc.

An example of such an SVG altering effect can look like this:

// load svg
val original = loadSVG("my_generative.svg").findShapes().map { it.shape }

// some kind of contour I will use to cut and bend my svg file
val knife = contour { ... }

// process all the contours from the original file using .split()
val cutLines = original.flatMap {
    it.contours // get contours from shapes
}.flatMap {
    split(it, knife) // For the image below I also apply .bend(), not shown
}

// make a new svg with the modified contours
val modified = drawComposition {
    fill = null
    stroke = ColorRGBa.BLACK
    contours(cutLines)
}

// save result
saveFileDialog(supportedExtensions = listOf("SVG" to listOf("svg"))) {
    it.writeText(writeSVG(modified))
}

That’s the approach I used to produce this design:

One program produced the base design, a second one was used for cutting and bending segments.

2 Likes

4. Find the contour-point nearest to another point

2021-03-06-121214_388x352_scrot

One aspect we can consider when generating a design to plot is this: do previously added lines and curves influence the properties of the line or curve I will add next?

Personally I find it most interesting when curves interact with other curves, when lines start or end at other lines, or cross them at specified locations or angles, or when something happens at the intersections of lines.

OPENRNDR offers many tools (methods) to help with this. We can query distances, intersections, normals, we can offset curves, create sub-curves, sample them at various locations and much more.

In this post I will just mention one of such tools: ShapeContour.nearest(point: Vector2). With it we can ask: what point in this contour is the closest to this other point?

import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.shape.Circle
import org.openrndr.shape.LineSegment

fun main() {
    application {
        program {
            // create two nested circles positioned relative to the canvas
            // Circle is a "mathematical circle" (a data type)
           //  `.contour` converts it into a drawable one.
            val c1 = Circle(drawer.bounds.position(0.45, 0.45), 150.0).contour
            val c2 = Circle(drawer.bounds.position(0.55, 0.55), 50.0).contour

            // Create a list with 60 LineSegments. Each one connects
            // a position sampled from the first circle with the nearest 
            // point in the second circle.
            val lineCount = 60
            val lines = List(lineCount) {
                val a = c1.position(it / lineCount.toDouble()) // 0.0 .. 1.0
                val b = c2.nearest(a).position
                LineSegment(a, b)
            }

            extend {
                drawer.apply { // make `this` = drawer
                    clear(ColorRGBa.WHITE)
                    fill = null
                    stroke = ColorRGBa.BLACK
                    contour(c1)
                    contour(c2)
                    lineSegments(lines)
                }
            }
        }
    }
}

Another result using the same approach:
2021-03-06-121824_413x356_scrot

More about Shape, ShapeContour and Segment

1 Like

5. Specifying pen height, speed and pauses per layer (v2)

I’ve always wished the Axidraw would allow precise vertical control for plotting with brushes. For example by taking the color of the stroke and mapping that to height. Unfortunately that feature is not there and the makers don’t seem very interested in it for now.

But according to AxiDraw Layer Control - Evil Mad Scientist Wiki it is possible to create layers in Inkscape and have special names in the layers which allow configuring the pen height and speed to use when plotting that layer. The layer names also allow introducing delays and waiting for user interaction (to change pens for example).

In the code below I added two methods to OPENRNDR to set Inkscape layer names and to save the SVG file in the right format.

import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.drawComposition
import org.openrndr.math.Vector2
import org.openrndr.shape.Composition
import org.openrndr.shape.GroupNode
import org.openrndr.svg.toSVG
import java.io.File
/**
 * ## Helper methods to create SVG files with layer names for Inkscape + AxiDraw
 *
 * Layer names in Inkscape can contain special characters like "+! %" which
 * have meaning when plotting on an AxiDraw device. Those characters can:
 * - pause plotting
 * - delay plotting (in milliseconds)
 * - specify the pen height (0-100%)
 * - specify the pen motion speed (1-100%)
 * - ignore a layer
 *
 * See [AxiDraw Layer Control](https://wiki.evilmadscientist.com/AxiDraw_Layer_Control)
 * for the correct syntax.
 *
 * Here I provide two helper methods to embed layer names in SVG files:
 *
 * 1. `setInkscapeLayer()` can be called on [GroupNode]s to set the desired Inkscape layer name.
 *
 * 2. `saveToInkscapeFile()` saves the SVG file removing the root `<g>` element. The reason for this
 * is that the AxiDraw Inkscape driver does not make use of the special characters in sublayer names,
 * therefore I upgrade sublayers to layers in the root node.
 *
 */
fun main() = application {
    program {
        val comp = drawComposition {
            translate(drawer.bounds.center)
            stroke = ColorRGBa.BLACK

            group {
                circle(Vector2.ZERO, 50.0)
            }.setInkscapeLayer("1+D2000+H30+S30 first")

            group {
                circle(Vector2.ZERO, 70.0)
                circle(Vector2.ZERO, 80.0)
            }.setInkscapeLayer("!2 a normal layer")

            group {
                circle(Vector2.ZERO, 90.0)
            }.setInkscapeLayer("%3 do not plot this one")

            group {
                circle(Vector2.ZERO, 110.0)
            }.setInkscapeLayer("!4+H35 higher")
        }
        comp.saveToInkscapeFile(File("/tmp/result_with_layers.svg"))
    }
}
private fun GroupNode.setInkscapeLayer(name: String) {
    attributes["inkscape:groupmode"] = "layer"
    attributes["inkscape:label"] = name
}

private fun Composition.saveToInkscapeFile(file: File) = file.writeText(
    toSVG().replace(
        Regex("""(<g\s?>(.*)</g>)""", RegexOption.DOT_MATCHES_ALL), "$2"
    )
)

Running this programs produces an SVG file. When opened in Inkscape it looks like this:

See those layer names on the top right? With those layer names it is setting a 2000 ms delay for the first layer, a custom pen height for layers 1 and 4, and custom layer speed for layer 1, a pause command on layers 2 and 4 and ignoring completely layer 3.

Now I can create designs where curves are drawn at different speeds or heights. Time to experiment with it :slight_smile:

As an example of the previous helper methods, one could do this:

fun main() = application {
    program {
        val comp = drawComposition {
            translate(drawer.bounds.center)
            stroke = ColorRGBa.BLACK

            repeat(40) {
                group {
                    circle(Vector2.ZERO, 30.0 + it * 5)
                }.setInkscapeLayer("$it+H${20+it}")

            }
        }
        comp.saveToInkscapeFile(File("/tmp/result_with_layers.svg"))
    }
}

to have 40 circles plotted at pen heights 20% to 60%:

We could place a ! character in front of the layer names to make the plotter pause and wait so we could add ink to the brush :paintbrush:

6. Hand-written single-stroke text

7. Reduce pen up/down movements

Also: “how to clone a List”

In OPENRNDR, Shape objects are made out of ShapeContours which are made out of Segments. A Segment can be linear or a quadratic (one control point) or cubic (two control points) Bezier curves.

I often generate designs to plot that have many ShapeContours or Segments. The most time-costly operation with a device like the Axidraw is raising and lowering the pen. Therefore it’s tempting to make designs with one huge complex ShapeContour instead of thousands of ShapeContours or Segments. But sometimes we can’t choose.

The tip I’m going to describe here applies to collections of Segments in which consecutive ones often start where the previous one ended. This assumes a sorted list. If the list is not sorted, that’s a harder problem in which one needs to search for Segments that start or end in the same locations.

I ran into this situation when scanning positions on a Segment pixel by pixel comparing pixel colors in an invisible ColorBuffer (to have an image affect the design).

So one simple program to concatenate Segments may look like this:

import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.shape.Circle
import org.openrndr.shape.Segment
import org.openrndr.shape.ShapeContour

fun main() = application {
    // A bunch of segments, this would actually be a generative 
    // design of some kind made out of tons of segments.
    // We simulate such a design with just 3 circles broken into
    // their segments
    val segments = List(3) {
        Circle(100.0 + it * 100.0, 200.0, 30.0).contour.segments
    }.flatten()

    val bucket = mutableListOf<Segment>()
    val result = mutableListOf<ShapeContour>()

    segments.forEach {
        if(bucket.isEmpty() || bucket.last().end.squaredDistanceTo(it.start) < 10E-6) {
            bucket.add(it)
        } else {
            result.add(ShapeContour(bucket.toMutableList(), false))
            bucket.clear()
            bucket.add(it)
        }
    }
    result.add(ShapeContour(bucket, false))

    println("We went from ${segments.size} pen up/down movements to ${result.size}.")

    program {
        extend {
            drawer.fill = null
            drawer.stroke = ColorRGBa.WHITE
            drawer.contours(result)
        }
    }
}

It’s a very simple program that reconstructs the original ShapeContour’s out of a list of segments. In this case it wasn’t necessary, because we could have avoided breaking up the ShapeContours in the first place.

Gotcha: the only tricky part is to call .toMutableList() to produce a clone of the list. If we don’t do that, the references to previous ShapeContours get overwritten with the later ones, and we would obtain a list with the same circle 3 times. To see what I mean, try deleting .toMutableList() and observe the result.

Running the program will print

We went from 12 pen up/down movements to 3.

Note: the Inkscape Axidraw driver has an optimizer. What it does is to sort the segments to avoid unnecessary 2D movement, but it does not avoid pen up/down movements (at least last time I tried during 2022).

8. Creating thumbnails for your SVG files

I have a folder full of SVG files created with my OPENRNDR programs. Being able to view all the thumbnails at once can be useful, which is not always easy if we only have SVG files.

Fortunately Inkscape can be executed from the command line to create PNG images.

for f in *.svg
    inkscape $f --export-area-drawing --export-background=white --export-overwrite --export-margin=10 -w 300 --export-type=png -o $f.png
end

This script works on my Linux box with Fish shell, but it should be easy to adapt to other OSes. The -w argument specifies the width in pixels, but you could leave this out, or specify a DPI or height value.

Once you have those PNG images you could use image magick if you want to create contact sheets.

magick montage *png -tile 10x6 -geometry 200x200+10+10 -pointsize 9 -set label '%f' /tmp/all_%d.png

In my case it creates six and this is the second one:

Since the generated SVG files have the name of the program which created them, now I can easily browse these contact sheets, find a thumbnail I like, look at its filename, then open that Kotlin program and iterate on it.

Not really an OPENRNDR tip, but it can be useful if you are creating many vector files for your pen plotter.

1 Like

9. Hatching

Here goes a very basic example of how one could approach hatching.

axi.PatternBasic-2024-02-27-15.18.04

Import
import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.drawComposition
import org.openrndr.extra.noise.uniform
import org.openrndr.extra.shapes.hobbycurve.hobbyCurve
import org.openrndr.math.Polar
import org.openrndr.shape.ClipMode
import org.openrndr.shape.CompositionDrawer
import org.openrndr.shape.Rectangle
import org.openrndr.shape.draw
import org.openrndr.svg.saveToFile
import java.io.File
fun main() {
    application {
        program {
            val cutterShape = hobbyCurve(
                List(10) {
                    Polar(it * 36.0, 100.0 + (it % 2) * 100.0).cartesian +
                            drawer.bounds.center
                }, true
            ).shape

            val svg = drawComposition {
                fill = null
                messHatching(100, cutterShape.bounds)
                clipMode = ClipMode.INTERSECT
                shape(cutterShape)
            }

            // We can include the cutterShape in the design
            //svg.draw { shape(cutterShape) }

            svg.saveToFile(File("/tmp/messHatching.svg"))

            extend {
                drawer.clear(ColorRGBa.WHITE)
                drawer.composition(svg)
            }
        }
    }
}

// A method to add lines to a `Composition` using a `CompositionDrawer`.
// The `CompositionDrawer` has most drawing methods `Drawer` has.
// For example here we use lineSegment(). We receive `bounds` as an argument
// to create content only in that area.
private fun CompositionDrawer.messHatching(count: Int, bounds: Rectangle) {
    repeat(count) {
        val a = bounds.uniform()
        val b = bounds.uniform()
        lineSegment(a, b)
    }
}

The basic idea is to have a shape (in this case a rounded star created with hobbyCurve) and fill that shape with lines. The inner lines could be short or long, straight or curved, open or closed and arranged randomly, in a regular pattern, with equal or varying separation between them. Endless possibilities.

The most important part might be the drawComposition with ClipMode.INTERSECT which is used to eliminate anything that is outside of the cutterShape.

An idea for you: create a collection of hatching functions with arguments to control the output.

Also, instead of passing the bounds as an argumnet you could pass the cutter shape itself if you want the hatching algorithm to interact with the orientation of the cutter curve.

2 Likes

@abe , I really appreciate you posting this example!

1 Like

10. Hidden line removal with an accumulated mask

There are a number of techniques for hidden line removal: some rely on post-processing an SVG file (e.g. vpype & AxiDraw InkScape extension) and others within OPENRNDR have been discussed earlier in this topic.

Another approach is to maintain a mask as shapes are drawn so that once a shape has been drawn no other shape will intersect with it visually.

maskedcircles

imports
import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.drawComposition
import org.openrndr.extra.noise.Random
import org.openrndr.extra.noise.uniform
import org.openrndr.shape.Circle
import org.openrndr.shape.Shape
import org.openrndr.shape.difference
import org.openrndr.svg.saveToFile
import java.io.File
fun main() {
    application {
        program {

            val circles = List(150) {
                Circle(drawer.bounds.uniform(), Random.double(30.0, 60.0))
            }

            val svg = drawComposition {
                fill = null
                // start with the mask set to allow shapes on the whole draw area
                var accumulatedMask: Shape = drawer.bounds.shape
                for (circle in circles) {
                    mask = accumulatedMask
                    circle(circle)
                    // progressively mask out the area of shapes as they are drawn
                    accumulatedMask = accumulatedMask.difference(circle.shape)
                }
            }

            svg.saveToFile(File("/tmp/maskedCircles.svg"))

            extend {
                drawer.clear(ColorRGBa.WHITE)
                drawer.composition(svg)
            }
        }
    }
}

2 Likes

Very nice idea! I also like how the edges of the canvas are not a full rectangle, but only present where it intersects with circles :slight_smile: Thank you for the contribution!

By the way: this is like drawing circles behind other circles, right? You draw a circle, then that place is taken, so the next circle drawn seems to be behind it. Just an interesting thing because normally last elements are on top of previous elements, but in this case it’s the opposite.

Yes you are correct that you do have to reverse your thinking somewhat! In more complex examples I keep the model and view code separate so that I can build the model the way that’s logical to me and the view code can draw the elements in the necessary order for this technique to work.

11. Using Signed Distance Fields to create contours with math

The orx-marching-squares extension lets us define mathematical functions to generate contours which we could send to the pen plotter.

Take a look at the simpler examples available in the readme.

In the following example I ported a few functions by iquilez and from the hg_sdf library to create this design:

Basically, I created two sdf shapes (a circle and a rounded box), created “ripples” around them using cos, combined the ripples using fOpDifferenceRound, then used a circular mask to crop the design using fOpIntersectionRound.

import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.extra.marchingsquares.findContours
import org.openrndr.math.*
import kotlin.math.*

fun main() = application {
    configure {
        width = 1000
        height = 1000
    }

    program {
        fun Vector2.abs() = Vector2(x.absoluteValue, y.absoluteValue)
        fun Vector2.ndot(other: Vector2) = x * other.x - y * other.y
        fun Vector2.dot2() = this.dot(this)

        // https://iquilezles.org/articles/distfunctions2d/
        fun sdCircle(p: Vector2, r: Double) = p.length - r

        fun sdRoundedBox(p: Vector2, b: Vector2, r: Vector4): Double {
            val r2 = if (p.x > 0.0) r else r.copy(r.z, r.w)
            val r3 = if (p.y > 0.0) r2 else r2.copy(r2.y)
            val q = p.abs() - b + r3.x
            return min(max(q.x, q.y), 0.0) + max(q, Vector2.ZERO).length - r3.x
        }

        // http://mercury.sexy/hg_sdf/
        fun fOpUnionRound(a: Double, b: Double, r: Double): Double {
            val u = max(Vector2(r - a, r - b), Vector2.ZERO)
            return max(r, min(a, b)) - u.length
        }

        fun fOpIntersectionRound(a: Double, b: Double, r: Double): Double {
            val u = max(Vector2(r + a, r + b), Vector2.ZERO)
            return min(-r, max(a, b)) + u.length
        }

        fun fOpDifferenceRound(a: Double, b: Double, r: Double) = fOpIntersectionRound(a, -b, r)

        fun f(v: Vector2): Double {
            val cir = cos(
                sdCircle(v - drawer.bounds.position(0.3, 0.3), 100.0) * 0.1
            ) - 0.7

            val box = cos(
                sdRoundedBox(
                    v - drawer.bounds.position(0.7, 0.7),
                    Vector2(150.0, 100.0),
                    Vector4(10.0, 30.0, 50.0, 70.0)
                ) * 0.15
            ) + 0.7

            val c = fOpDifferenceRound(cir, box, 1.0)
            val mask = sdCircle(v - drawer.bounds.center, 400.0)
            return fOpIntersectionRound(c, mask, 10.0)
        }

        val c = findContours(::f, drawer.bounds, 3.0)

        extend {
            drawer.clear(ColorRGBa.WHITE)
            drawer.stroke = ColorRGBa.BLACK
            drawer.fill = null
            drawer.contours(c)
        }
    }
}

Note that there are many more SDF functions to play with available in the two links above.

Sculpting shapes via math can be somewhat unintutive, although this technique is common in shader programs running on the GPU.

1 Like