Creating Cut and Folds 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 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 thing, a path thing and an attributes thing the amount you need to remember is dramatically smaller. The top level svg thing 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. The path we get from the top level thing.

 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 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 nicely fit in), let's change the colour hue as we go. Let's adjust the size of the polygon based on a poisson random number generated (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 a lot 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).

Cut and Fold Techniques for Pop-Up Designs by Paul Jackson 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 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 the randomness, number of windows and so on we choose).

A couple of different cut and fold-outs 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, just follows the pattern you see above.

Solandra-SVG has a 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 in one group in the SVG. We also add cuts around the outside to have 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 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 actually generating the patterns. Let's support this with a custom class WindowItem 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 (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 over from the start and end positions, creating n windows as we go, carefully adding in frames (and getting the fence-post 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 some 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 that you apply glue to 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 parameterised 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 part.

  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 cut out for the tree to sit in:

s.groupWithId('base-cuts', () => {
    // only need on three sides, as already will have enough on LHS from 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 bit can be implemented like this. We take the original base part of tree and move it outward, then expand the line to allow 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 lots 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. They will ask you to pay them. Over and over again.

Cricut: Please pay us money

But you don't need to. Having loaded the SVG you will want to attach the layers. If you don't do this then 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 things 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 a s.groupWithId which is useful to keep things organised and easy to work with in Cricut. If you don't create groups, then attaching or changing the tool on a large number of things 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 an approach of manual drawing.