Transforms, Clipping and State

The with... methods save canvas drawing state before running a callback and restore it afterwards. This scopes colours, line widths, transforms, clipping and blend modes. Drawn pixels remain on the canvas.

withContext

withContext scopes changes to the drawing state:

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 coordinates inside a callback. Rotation and scaling use the origin. To rotate a shape in place, translate to its position, 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" })
        )
      })
    })
  }
)

Nest transforms to build recursive patterns:

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 drawing to the area enclosed by a Traceable:

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) sets globalCompositeOperation for the callback. Modes include "multiply", "screen", "overlay", "lighten" and "difference".

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) repeats a drawing with rotations or reflections around a centre:

  • 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 symmetry; defaults to the canvas centre

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 }))

Place the motif off the axis to see each rotation and its reflection:

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 }))
})

Each copy has its own saved drawing state. Transforms and styles in one callback do not affect later copies.

Combining helpers

Nest helpers to combine their effects. This example clips, rotates and blends:

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