Transforms, Clipping and State

Canvas state (colours, line width, transforms, clipping, blend modes) is global and mutable, which is a classic source of bugs. Solandra's answer is a family of with... methods: each takes a callback, and every change you make inside is automatically undone afterwards. No manual save/restore, no leaking state.

withContext

The plainest one: withContext just scopes state changes:

s.background(0, 0, 95)
s.setFillColor(215, 70, 45)
s.withContext(() => {
  s.setFillColor(340, 80, 55) // only applies inside
  s.fill(new Circle({ at: [0.3, 0.5], r: 0.15 }))
})
s.fill(new Circle({ at: [0.7, 0.5], r: 0.15 })) // back to blue

Translation, rotation and scale

withTranslation(vector, cb), withRotation(angle, cb) and withScale([sX, sY], cb) transform the coordinate system inside their callback. Rotation and scaling happen around the origin, so the standard recipe for "rotate this thing in place" is: translate to where you want it, then rotate, then draw at [0, 0]:

s.background(40, 30, 95)
s.forTiling(
  { n: 7, type: "square", margin: 0.05 },
  (_pt, [dX], [cX, cY], i) => {
    s.setFillColor(200 + i * 2, 60, 50)
    s.withTranslation([cX, cY], () => {
      s.withRotation(i * 0.15, () => {
        s.fill(
          new Rect({ at: [0, 0], w: dX * 0.7, h: dX * 0.7, align: "center" })
        )
      })
    })
  }
)

Nesting transforms composes them, which makes recursive figures natural:

s.background(230, 40, 12)
s.setStrokeColor(180, 70, 60)
s.lineWidth = 0.006
const branch = (depth) => {
  if (depth === 0) return
  s.draw(new Line([0, 0], [0, -0.22]))
  s.withTranslation([0, -0.22], () => {
    s.withScale([0.72, 0.72], () => {
      s.withRotation(0.5, () => branch(depth - 1))
      s.withRotation(-0.4, () => branch(depth - 1))
    })
  })
}
s.withTranslation([0.5, 0.95], () => branch(8))

For an arbitrary affine transform there is withTransform({ hScale, hSkew, vSkew, vScale, dX, dY }, cb), mirroring the raw canvas transform matrix.

Clipping

withClipping(shape, cb) restricts all drawing inside the callback to the area of any Traceable. Great for keeping wild textures inside tidy frames:

s.background(45, 30, 94)
s.withClipping(new Circle({ at: [0.5, 0.5], r: 0.4 }), () => {
  s.background(215, 70, 25)
  s.lineWidth = 0.012
  s.times(40, (n) => {
    s.setStrokeColor(180 + n * 3, 70, 60)
    const y = 0.05 + n * 0.025
    s.draw(
      SimplePath.withPoints(
        s.build(s.range, { from: -0.1, to: 1.1, n: 20 }, (x) => [
          x,
          y + 0.02 * Math.sin(x * 12 + n),
        ])
      ).chaiken({ n: 2 })
    )
  })
})

Blend modes

withBlendMode(mode, cb) scopes a globalCompositeOperation: "multiply", "screen", "overlay", "lighten", "difference" and friends.

s.background(0, 0, 98)
s.withBlendMode("multiply", () => {
  const hues = [215, 340, 45]
  s.times(3, (n) => {
    s.setFillColor(hues[n], 80, 60)
    s.fill(
      new Circle({
        at: [
          0.5 + 0.12 * Math.cos((n * Math.PI * 2) / 3 - Math.PI / 2),
          0.52 + 0.12 * Math.sin((n * Math.PI * 2) / 3 - Math.PI / 2),
        ],
        r: 0.22,
      })
    )
  })
})

Symmetry

withSymmetry(config, cb) runs the callback once per copy with the canvas already rotated and/or reflected around a centre, so drawing one thing gives you a symmetric arrangement of it. It is the with... idea applied to composition rather than to style: draw a petal, get a flower.

  • type: "rotational" (the default) — n copies, evenly spaced around a full turn
  • type: "mirror" — a reflected pair; axis is "vertical" (the default, flipping left to right) or "horizontal"
  • type: "kaleidoscope"n rotations, each drawn twice, once reflected, so 2n copies
  • at — the centre of the symmetry, the middle of the canvas unless you say otherwise

The callback is given the index of the copy and whether that copy is a reflection.

s.background(255, 30, 12)
s.withSymmetry({ n: 12 }, (i) => {
  s.setFillColor(280 + i * 4, 70, 55, 0.55)
  s.fill(new Ellipse({ at: [0.5, 0.16], w: 0.14, h: 0.34 }))
})
s.setFillColor(45, 85, 65)
s.fill(new Circle({ at: s.meta.center, r: 0.06 }))

A motif placed off the axis is what makes a kaleidoscope look like one: each rotation appears twice, mirrored.

s.background(230, 25, 12)
const [cX, cY] = s.meta.center
 
s.withSymmetry({ type: "kaleidoscope", n: 6 }, (i, reflected) => {
  s.setFillColor(30 + i * 25, 75, 55, 0.5)
  s.fill(new Ellipse({ at: [cX + 0.06, cY - 0.16], w: 0.13, h: 0.22 }))
 
  s.setFillColor(reflected ? 195 : 45, 85, 60, 0.7)
  s.fill(new Circle({ at: [cX + 0.025, cY - 0.25], r: 0.028 }))
})

Being a with... helper, each copy is drawn in its own saved state, so transforms and styles set inside one copy do not leak into the next, and symmetry nests inside (and around) everything else here.

Everything composes

The scoped helpers nest freely; clip, then rotate, then blend, and it all unwinds correctly:

s.background(220, 40, 10)
s.withClipping(new RegularPolygon({ at: [0.5, 0.5], n: 6, r: 0.42 }), () => {
  s.withBlendMode("screen", () => {
    s.times(30, (n) => {
      s.withTranslation([0.5, 0.5], () => {
        s.withRotation(n * 0.4, () => {
          s.setFillColor(180 + n * 6, 80, 50, 0.5)
          s.fill(new Rect({ at: [0.05, -0.02], w: 0.4, h: 0.04 }))
        })
      })
    })
  })
})

Solandra was made by James Porter.

Check out the GitHub page or install with npm i solandra