Creating Cut-and-Fold or Pop-Up Patterns with TypeScript

cut and fold pop-up abstract windows

In this article, we'll explore how to use TypeScript and Solandra-SVG to generate physical cut-and-fold and pop-up patterns. You will learn how to create physical artifacts from code.

The Graphics Library

I created a library for SVG graphics in TypeScript a few years ago. It offers a TypeScript-friendly API with good autocompletion and concise code that leverages fluent APIs: the method call returns the object for further method calls. This approach is a bit non-standard these days as it is more of an OOP pattern, but works nicely. One of the challenges with SVG is remembering all the random API names (is it cx or x?) but by having a few basic primitives: a top-level svg object, a path object, and an attributes object, the amount you need to remember is dramatically smaller. The top-level svg object takes care of additional complexities like pseudo-random number generation, iterating across the 'canvas' and outputting the actual SVG.

Using it can often feel declarative and is usually very easy to modify. For example, here we create a stroked path, add a fill then draw a regular polygon. At each stage TypeScript autocompletion guides us. We don't need to know or remember where to import a regularPolygon function, we just use it on the path, which we get from the top-level object.

 s.strokedPath((a) => a.fill(220, 90, 80, 0.5)).regularPolygon(
      s.meta.center,
      12,
      s.meta.bottom / 3,
      0,
      'center',
    )
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 0.6666666666666666" width="360" height="240">
  <path style="fill:#9EBDFA; stroke-width:0.005; stroke:#000000; stroke-opacity:1; stroke-linecap:round; fill-opacity:0.5;" d="M 0.7222222222222222 0.3333333333333333 L 0.6924500897298753 0.4444444444444444 L 0.6111111111111112 0.5257834230632086 L 0.5 0.5555555555555556 L 0.38888888888888895 0.5257834230632086 L 0.30754991027012474 0.4444444444444444 L 0.2777777777777778 0.3333333333333333 L 0.3075499102701247 0.22222222222222227 L 0.3888888888888888 0.1408832436034581 L 0.49999999999999994 0.1111111111111111 L 0.6111111111111112 0.14088324360345808 L 0.6924500897298752 0.2222222222222221 L 0.7222222222222222 0.33333333333333326" />
</svg>

I've included the raw SVG output on the right so you can inspect (and perhaps compare how much shorter the Solandra-SVG code is when compared to the generated SVG).

What is this s? In Solandra code it is the main object and is constructed like this:

const s = new SolandraSvg(WIDTH, HEIGHT, seed)

To make the examples shorter we will usually leave this out.

Let's dial this up a bit. What if we iterated across the canvas (let's say with 5 columns and whatever number of rows fit nicely), and changed the colour hue as we go? Let's adjust the size of the polygon based on a generated Poisson random number (a nice way to get low-ish numbers, with some spikes). That sounds like quite a lot of things to do but in fact barely adds to our previous example.

s.forTiling({ n: 5, type: 'square', margin: 0.1 }, (pt, d, c, i) => {
  s.strokedPath((a) => a.fill(220 - i * 15, 90, 80, 0.5)).regularPolygon(
    c,
    3 + i,
    d[0] * Math.max(s.poisson(3) / 4, 0.6),
    0,
    'center',
  )
})

Really the only new concepts are forTiling for iterating across the canvas and poisson for the RNG. The raw SVG is, of course, much larger.

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 0.6666666666666666" width="360" height="240">
  <path style="fill:#9EBDFA; stroke-width:0.005; stroke:#000000; stroke-opacity:1; stroke-linecap:round; fill-opacity:0.5;" d="M 0.276 0.17333333333333334 L 0.132 0.25647177209663946 L 0.13199999999999995 0.09019489457002725 L 0.276 0.1733333333333333" />
  <path style="fill:#9ED4FA; stroke-width:0.005; stroke:#000000; stroke-opacity:1; stroke-linecap:round; fill-opacity:0.5;" d="M 0.3 0.3333333333333333 L 0.18 0.4533333333333333 L 0.06 0.3333333333333333 L 0.17999999999999997 0.21333333333333332 L 0.3 0.33333333333333326" />
  <path style="fill:#9EEBFA; stroke-width:0.005; stroke:#000000; stroke-opacity:1; stroke-linecap:round; fill-opacity:0.5;" d="M 0.38 0.49333333333333335 L 0.24180339887498947 0.6835446365923641 L 0.01819660112501051 0.610890383791828 L 0.01819660112501048 0.3757762828748387 L 0.24180339887498944 0.3031220300743026 L 0.38 0.4933333333333333" />
  <path style="fill:#9EFAF2; stroke-width:0.005; stroke:#000000; stroke-opacity:1; stroke-linecap:round; fill-opacity:0.5;" d="M 0.46 0.17333333333333334 L 0.4 0.277256381787466 L 0.28 0.277256381787466 L 0.22000000000000003 0.17333333333333337 L 0.27999999999999997 0.06941028487920073 L 0.4 0.06941028487920071 L 0.46 0.1733333333333333" />
  <path style="fill:#9EFADB; stroke-width:0.005; stroke:#000000; stroke-opacity:1; stroke-linecap:round; fill-opacity:0.5;" d="M 0.46 0.3333333333333333 L 0.4148187762230481 0.4271531112294969 L 0.3132974879252423 0.45032468279515214 L 0.23188373585170974 0.3853993820274403 L 0.23188373585170974 0.28126728463922634 L 0.31329748792524226 0.2163419838715145 L 0.414818776223048 0.23951355543716973 L 0.46 0.33333333333333326" />
  <path style="fill:#9EFAC4; stroke-width:0.005; stroke:#000000; stroke-opacity:1; stroke-linecap:round; fill-opacity:0.5;" d="M 0.43600000000000005 0.49333333333333335 L 0.4078822509939086 0.561215584327242 L 0.34 0.5893333333333334 L 0.2721177490060915 0.561215584327242 L 0.24400000000000002 0.49333333333333335 L 0.2721177490060914 0.4254510823394248 L 0.34 0.3973333333333333 L 0.40788225099390857 0.42545108233942475 L 0.43600000000000005 0.49333333333333335" />
  <path style="fill:#9EFAAD; stroke-width:0.005; stroke:#000000; stroke-opacity:1; stroke-linecap:round; fill-opacity:0.5;" d="M 0.66 0.17333333333333334 L 0.6225671108990365 0.27617935088317963 L 0.5277837084267089 0.33090257381528665 L 0.42000000000000004 0.3118973979388435 L 0.34964918067425466 0.22805655626544036 L 0.34964918067425466 0.11861011040122635 L 0.41999999999999993 0.03476926872782318 L 0.5277837084267087 0.01576409285138003 L 0.6225671108990365 0.070487315783487 L 0.66 0.1733333333333333" />
  <path style="fill:#A6FA9E; stroke-width:0.005; stroke:#000000; stroke-opacity:1; stroke-linecap:round; fill-opacity:0.5;" d="M 0.62 0.3333333333333333 L 0.5970820393249937 0.40386756360843007 L 0.5370820393249937 0.44746011528875174 L 0.46291796067500635 0.44746011528875174 L 0.4029179606750063 0.4038675636084301 L 0.38 0.3333333333333333 L 0.4029179606750063 0.26279910305823656 L 0.4629179606750063 0.2192065513779149 L 0.5370820393249937 0.2192065513779149 L 0.5970820393249937 0.2627991030582365 L 0.62 0.33333333333333326" />
  <path style="fill:#BDFA9E; stroke-width:0.005; stroke:#000000; stroke-opacity:1; stroke-linecap:round; fill-opacity:0.5;" d="M 0.62 0.49333333333333335 L 0.6009504239397417 0.5582102314280051 L 0.5498498015602263 0.6024891727758755 L 0.4829222194072058 0.6121119063590452 L 0.4214167119265658 0.5840232822558443 L 0.38486084316626035 0.5271412401543049 L 0.3848608431662603 0.45952542651236183 L 0.42141671192656577 0.40264338441082237 L 0.48292221940720576 0.3745547603076214 L 0.5498498015602263 0.38417749389079114 L 0.6009504239397417 0.42845643523866167 L 0.62 0.49333333333333323" />
  <path style="fill:#D4FA9E; stroke-width:0.005; stroke:#000000; stroke-opacity:1; stroke-linecap:round; fill-opacity:0.5;" d="M 0.7559999999999999 0.17333333333333334 L 0.743138438763306 0.22133333333333333 L 0.708 0.25647177209663946 L 0.6599999999999999 0.2693333333333333 L 0.612 0.25647177209663946 L 0.5768615612366939 0.22133333333333333 L 0.564 0.17333333333333334 L 0.5768615612366939 0.12533333333333335 L 0.6119999999999999 0.09019489457002725 L 0.6599999999999999 0.07733333333333334 L 0.708 0.09019489457002723 L 0.743138438763306 0.1253333333333333 L 0.7559999999999999 0.1733333333333333" />
  <path style="fill:#EBFA9E; stroke-width:0.005; stroke:#000000; stroke-opacity:1; stroke-linecap:round; fill-opacity:0.5;" d="M 0.8599999999999999 0.3333333333333333 L 0.837091205130642 0.42627796774208704 L 0.7736129493462311 0.4979301065120646 L 0.6841073360510646 0.5318751081529441 L 0.5890790225914928 0.5203365818704163 L 0.5102978503657797 0.4659578649814924 L 0.4658116365147895 0.38119646619084485 L 0.4658116365147895 0.28547020047582183 L 0.5102978503657797 0.20070880168517433 L 0.5890790225914927 0.14633008479625037 L 0.6841073360510646 0.13479155851372251 L 0.7736129493462309 0.1687365601546019 L 0.837091205130642 0.24038869892457965 L 0.8599999999999999 0.3333333333333334" />
  <path style="fill:#FAF29E; stroke-width:0.005; stroke:#000000; stroke-opacity:1; stroke-linecap:round; fill-opacity:0.5;" d="M 0.7559999999999999 0.49333333333333335 L 0.7464930113186321 0.5349861722886189 L 0.7198550209784383 0.5683891556502643 L 0.6813620096598061 0.5869264129027885 L 0.6386379903401938 0.5869264129027885 L 0.6001449790215615 0.5683891556502643 L 0.5735069886813677 0.5349861722886189 L 0.564 0.49333333333333335 L 0.5735069886813677 0.4516804943780478 L 0.6001449790215615 0.4182775110164025 L 0.6386379903401938 0.3997402537638783 L 0.681362009659806 0.39974025376387823 L 0.7198550209784383 0.4182775110164025 L 0.7464930113186322 0.4516804943780478 L 0.7559999999999999 0.49333333333333335" />
  <path style="fill:#FADB9E; stroke-width:0.005; stroke:#000000; stroke-opacity:1; stroke-linecap:round; fill-opacity:0.5;" d="M 0.9159999999999999 0.17333333333333334 L 0.9077003639336896 0.21238005106861016 L 0.8842365382104503 0.24467523657916318 L 0.8496656314599949 0.2646347588976681 L 0.8099652675263053 0.26880743528868756 L 0.772 0.25647177209663946 L 0.742334368540005 0.22976071755341076 L 0.7260978303295547 0.19329285565183824 L 0.7260978303295547 0.15337381101482847 L 0.742334368540005 0.11690594911325593 L 0.7719999999999999 0.09019489457002725 L 0.8099652675263052 0.07785923137797911 L 0.8496656314599949 0.08203190776899859 L 0.8842365382104503 0.10199143008750351 L 0.9077003639336897 0.13428661559805652 L 0.9159999999999999 0.17333333333333323" />
  <path style="fill:#FAC49E; stroke-width:0.005; stroke:#000000; stroke-opacity:1; stroke-linecap:round; fill-opacity:0.5;" d="M 0.9159999999999999 0.3333333333333333 L 0.9086924351210834 0.37007094284038194 L 0.8878822509939085 0.40121558432724186 L 0.8567376095070486 0.42202576845441686 L 0.82 0.42933333333333334 L 0.7832623904929513 0.42202576845441686 L 0.7521177490060914 0.4012155843272419 L 0.7313075648789165 0.37007094284038194 L 0.724 0.3333333333333333 L 0.7313075648789165 0.2965957238262847 L 0.7521177490060914 0.26545108233942477 L 0.7832623904929513 0.24464089821224982 L 0.82 0.2373333333333333 L 0.8567376095070486 0.2446408982122498 L 0.8878822509939085 0.2654510823394247 L 0.9086924351210834 0.29659572382628463 L 0.9159999999999999 0.3333333333333333" />
  <path style="fill:#FAAD9E; stroke-width:0.005; stroke:#000000; stroke-opacity:1; stroke-linecap:round; fill-opacity:0.5;" d="M 0.9159999999999999 0.49333333333333335 L 0.9095173340228181 0.5280125332873 L 0.8909448560531832 0.5580081151234029 L 0.8627908821545476 0.5792690093034193 L 0.828857762508477 0.5889238142576567 L 0.79372835295308 0.585668595077924 L 0.7621470749075914 0.5699429871522363 L 0.738379154969957 0.5438708209695595 L 0.7256345824303454 0.5109732870437241 L 0.7256345824303454 0.4756933796229426 L 0.738379154969957 0.44279584569710717 L 0.7621470749075913 0.4167236795144304 L 0.79372835295308 0.40099807158874273 L 0.828857762508477 0.39774285240901003 L 0.8627908821545476 0.40739765736324735 L 0.8909448560531832 0.4286585515432638 L 0.9095173340228181 0.45865413337936667 L 0.9159999999999999 0.49333333333333335" />
</svg>

So we can quickly and clearly create standard SVG graphics. But where it gets more interesting is when we start to do more algorithmic things.

Creating Cut and Fold Patterns in TypeScript

The basic idea will be to create a template out of cuts and folds. We'll draw these paths with Solandra-SVG using the s.cutPath and s.creasePath methods (but really there is nothing that special about these).

Paul Jackson's Cut and Fold Techniques for Pop-Up Designs is a nice basic introduction to creating pop-up cards/designs.

Cut and Fold Techniques for Pop-Up Designs by Paul Jackson cover

It suggests a number of easy to implement patterns, such as this one which has been adorned with additional small 'windows' (the choice of a rectangle or triangle is pseudorandom).

a basic pop-up

Let's do something a bit fancier (and more algorithmic). We'll create this pattern (or really a system that generates such patterns, where we choose the randomness, number of windows, and so on).

A couple of different cut-and-fold cutouts look like this and have a nice architectural quality.

cut and fold pop-up abstract windows

How to create these

We start with a basic SVG.

const s = new SolandraSvg(900, 400, seed)

We'll add some major cuts; these are the frames that hold the 'windows'. This is a bit fiddly but nothing too special; it just follows the pattern you see above.

Solandra-SVG has built-in s.meta which gives us various sizes and things like the center location.

  s.groupWithId('main-cuts', () => {
    for (let p of [0, third, 2 * third]) {
      s.cutPath()
        .moveTo([p + third / 2, padding])
        .lineTo([p + third - padding, padding])
        .lineTo([p + third - padding, s.meta.bottom - padding])
        .lineTo([p + third / 2, s.meta.bottom - padding])
    }

    s.cutPath()
      .moveTo([0, 0])
      .lineTo([1, 0])
      .lineTo([1, s.meta.bottom])
      .lineTo([0, s.meta.bottom])
      .close()
  })

We use s.groupWithId to keep elements in one group in the SVG. We also add cuts around the outside to create the precisely shaped rectangle we want.

We need to add the folds (for the pop-out). This is similar:

  s.groupWithId('creases', () => {
    for (let p of [0, third, 2 * third]) {
      s.creasePath()
        .moveTo([p + third / 2, 0])
        .lineTo([p + third / 2, padding])
        .moveTo([p + third / 2, s.meta.bottom - padding])
        .lineTo([p + third / 2, s.meta.bottom])
    }
  })

Let's do the windows. We have three panes to iterate over. Let's assume we have a helper function to build them.

s.groupWithId('windows', () => {
    for (let p of [0, third, 2 * third]) {
      const windows = buildWindows(
        [padding + p, padding * 2],
        [padding + p + third - padding * 3, s.meta.bottom - padding * 2],
        N,
        windowFrameProportion,
        s,
      )

And for each, let's draw:

windows.forEach((w) => {
  const path = s.cutPath()
  const pts = w.points

  path.moveTo(pts.pop()!)
  pts.forEach((p) => path.lineTo(p))
  path.close()
})

Done. Well not quite. The interesting part is generating the patterns. Let's support this with a custom WindowItem class, which will hold the position, size, and type of window. We can then use this to create a number of windows and to track whether we merged (or replaced) it.

class WindowItem {
  id: string

  constructor(
    public pos: Vector2D,
    public size: Vector2D,
    public kind: 'base' | 'replaced',
    public x: number,
    public y: number,
  ) {
    this.id = uuid()
  }

  get points(): Vector2D[] {
    const [x, y] = this.pos
    const [w, h] = this.size

    return [
      [x, y],
      [x + w, y],
      [x + w, y + h],
      [x, y + h],
    ]
  }
}

Now let's implement a buildWindows function. The first part is fiddly but standard: We iterate from the start and end positions, creating n windows as we go, carefully adding frames (and getting the fencepost counting right!).

function buildWindows(
  start: Vector2D,
  end: Vector2D,
  n: number,
  frameProportion: number,
  s: SolandraSvg,
): WindowItem[] {
  const windows: WindowItem[] = []

  const W = end[0] - start[0]
  const H = end[1] - start[1]

  const windowWidth = (W * (1 - frameProportion)) / n
  const windowHeight = (H * (1 - frameProportion)) / n

  const fWidth = (W * frameProportion) / (n - 1)
  const fHeight = (H * frameProportion) / (n - 1)

  const dX = windowWidth + fWidth
  const dY = windowHeight + fHeight

  for (let i = 0; i < n; i++) {
    for (let j = 0; j < n; j++) {
      const pos: Vector2D = [start[0] + i * dX, start[1] + j * dY]
      const size: Vector2D = [windowWidth, windowHeight]

      windows.push(new WindowItem(pos, size, 'base', i, j))
    }
  }

To make it more interesting, before returning, we will pick some windows to merge into bigger ones.

  for (let i = 0; i < N; i++) {
    let j = s.uniformRandomInt({ from: 0, to: N - 1, inclusive: false })

    const expand = windows.find((w) => w.x === i && w.y === j)
    const replaced = windows.find((w) => w.x === i && w.y === j + 1)

    replaced!.kind = 'replaced'
    expand!.size = [expand!.size[0], expand!.size[1] * 2 + fHeight]
  }

  return windows
}

Now we are actually done with the above. We could add more shapes or fancier merging to make it more interesting.

A Cut and Fold Pattern

You may have made some of these as a child: You cut out a pattern that includes tabs, apply glue to them, and fold it into a 3D shape. Let's create something like one of these traditional cut-and-fold (and glue) patterns: an abstract tree.

abstract tree

One possible pattern looks like this (all sorts of details are selectable in the system we will build):

How to create these

We will create a parameterized tree based on a regular polygon cross section. The branches will fold into the base.

Let's create a function for this. Start by calculating the basic positions and sizes:

function sketch(
  N: number,
  seed: number,
  baseProportion: number,
  baseH: number,
  touchGround: boolean,
) {
  const s = new SolandraSvg(1000, 1000, seed)
  const treeProportion = 1 - baseProportion
  const bottom = s.meta.bottom
  const treeY = bottom * (1 - baseH)
  const treeBottom = bottom * (1 - 0.02)
  const dX = treeProportion / N

The branch folds are quite simple (a bunch of vertical lines), but there is some fiddliness around counting.

  s.groupWithId('tree-fold', () => {
    s.creasePath().moveTo([0, treeY]).lineTo([treeProportion, treeY])
    s.creasePath().moveTo([0, treeBottom]).lineTo([treeProportion, treeBottom])

    for (let i = 1; i <= N; i++) {
      s.creasePath()
        .moveTo([i * dX, treeY])
        .lineTo([i * dX, treeBottom])
    }
  })

The cuts are more interesting, with a special glue surface tucked apart.

  s.groupWithId('tree-cuts', () => {
    for (let i = 0; i <= N + 1; i++) {
      if (i === 0) {
        s.cutPath()
          .moveTo([i * dX, 0])
          .lineTo([i * dX, bottom])
      } else if (i === N + 1) {
        s.cutPath()
          .moveTo([(i - 1) * dX, treeBottom])
          .lineTo([i * dX, treeBottom - 0.025])
          .lineTo([i * dX, treeY + 0.025])
          .lineTo([(i - 1) * dX, treeY])
      } else {
        s.cutPath()
          .moveTo([i * dX, 0])
          .lineTo([i * dX, treeY])

        s.cutPath()
          .moveTo([i * dX, treeBottom])
          .lineTo([i * dX, bottom])
      }
    }

    s.cutPath().moveTo([0, bottom]).lineTo([treeProportion, bottom])

    s.cutPath().moveTo([0, 0]).lineTo([treeProportion, 0])
  })

The base is a square with a correctly sized cutout for the tree to sit in:

s.groupWithId('base-cuts', () => {
    // only needed on three sides, as we will already have enough on the LHS from the tree cuts
    s.cutPath()
      .moveTo([1 - baseProportion, 0])
      .lineTo([1, 0])
      .lineTo([1, baseProportion])
      .lineTo([1 - baseProportion, baseProportion])

    const cx = 1 - baseProportion / 2
    const cy = baseProportion / 2

    const r = radius(N, dX)
    const dA = (2 * Math.PI) / N

    let x = cx + r * Math.cos(0)
    let y = cy + r * Math.sin(0)

    const rootPath = s.cutPath().moveTo([x, y])

    for (let i = 1; i <= N; i++) {
      const a = dA * i
      let x = cx + r * Math.cos(a)
      let y = cy + r * Math.sin(a)

      rootPath.lineTo([x, y])
    }

    if (touchGround) {
      /// Fancy code to build slots to tuck the branches in
    }
  })

We will need a helper to calculate the radius here:

function radius(n: number, s: number) {
  return s / (2 * Math.sin(Math.PI / n))
}

The touchGround part can be implemented like this: We take the original base part of the tree and move it outward, then expand the line to allow for easy tucking.

for (let i = 1; i <= N; i++) {
  const a1 = dA * (i - 1)
  const a2 = dA * i
  let x1 = cx + r * Math.cos(a1)
  let y1 = cy + r * Math.sin(a1)

  let x2 = cx + r * Math.cos(a2)
  let y2 = cy + r * Math.sin(a2)

  const deltaScale = ((0.15 + s.random()) * baseProportion) / 3.2

  const vX = deltaScale * Math.cos((a1 + a2) / 2)
  const vY = deltaScale * Math.sin((a1 + a2) / 2)

  const v1: Point2D = [x1 + vX, y1 + vY]
  const v2: Point2D = [x2 + vX, y2 + vY]

  // make the cuts a bit bigger to allow easy slotting in
  const v1Adj = v.pointAlong(v1, v2, -0.1)
  const v2Adj = v.pointAlong(v1, v2, 1.1)

  s.cutPath().moveTo(v1Adj).lineTo(v2Adj)
}

A Robot to Make the Cut-and-Fold or Pop-Up Patterns (or, How Did We Actually Convert Those Designs into Physical Objects?)

We could just print out these patterns onto card, then cut and fold them by hand. But that is quite tedious and leads to a lot of ugly ink on your pattern.

I've recently used a Cricut Maker (I picked it up years ago, massively discounted, but similar models are available today). One nice feature is that it can have both a cutting tool and scoring stylus simultaneously set up. So, the 'print' process is a single job.

You install their software (Design Space) and upload the SVG file. They will ask you to pay them over and over again.

Cricut: Please pay us money

However, you don't need to. Having loaded the SVG, you will want to attach the layers. If you don't do this, the software will try to move the layers around, and you will end up with a mess.

You can do this by selecting all the layers and clicking the 'Attach' button. As long as you have grouped items into a few groups, this is quite quick.

Attach layers (fix to canvas)

You will also need to select the right tool for each layer. The default is a cutting tool, but we will also want to use the scoring tool for the 'fold' lines.

Score tool

As mentioned before, Solandra-SVG provides s.groupWithId which is useful to keep things organised and easy to work with in Cricut. If you don't create groups, attaching or changing the tool on a large number of items is a massive pain.

s.groupWithId('main-cuts', () => {
  // ... create paths here
})

Next Steps

You can read more about Solandra-SVG. It also works nicely for pen plotting (I originally used with an AxiDraw plotter) or more generally for all sorts of SVG graphic creation. The programmatic approach offers precision and power versus manual drawing.