Paths and Curves

Beyond the built-in shapes, Solandra has two classes for building your own: SimplePath (straight lines between points) and Path (lines and curves). Both offer a fluent, chainable API and a rich set of transformations.

SimplePath

A SimplePath is just an ordered list of points:

import { SimplePath } from "solandra"
 
// build up point by point
const path = SimplePath.startAt([0.1, 0.9])
  .addPoint([0.3, 0.4])
  .addPoint([0.7, 0.6])
  .addPoint([0.9, 0.1])
 
// or all at once
const zigzag = SimplePath.withPoints([
  [0.1, 0.5],
  [0.3, 0.3],
  [0.5, 0.7],
  [0.9, 0.4],
])
 
// close() joins the last point back to the first
const triangle = SimplePath.withPoints([
  [0.2, 0.8],
  [0.5, 0.2],
  [0.8, 0.8],
]).close()

Smoothing with chaiken

The chaiken method smooths a path by repeatedly cutting corners (Chaikin's algorithm). It's the easiest way to turn a jagged random walk into something organic. Pass n for the number of smoothing iterations, and looped: true for closed paths.

s.background(40, 40, 96)
s.lineWidth = 0.005
s.times(5, (n) => {
  const points = s.build(s.range, { from: 0.1, to: 0.9, n: 12 }, (x) => [
    x,
    0.15 + n * 0.18 + s.random() * 0.1 - 0.05,
  ])
  s.setStrokeColor(20 + n * 40, 70, 50)
  s.draw(SimplePath.withPoints(points).chaiken({ n: 1 + n }))
})

Measuring and sampling

A SimplePath knows how long it is and what happens where along it, all measured by distance travelled rather than by point index, so evenly spaced proportions give evenly spaced results however lumpily the path's own points are spread.

  • path.length — the total length
  • path.pointAt(proportion) — the point a proportion of the way along (0 is the start, 1 the end; anything outside is clamped)
  • path.tangentAt(proportion) — the unit vector the path is heading in there, so v.heading(...) gives an angle to rotate by
  • path.pointsAlong({ n, inclusive })n evenly spaced points; pass inclusive: false for a closed path, where the end is the start again
const wave = SimplePath.withPoints(
  s.build(s.range, { from: 0.05, to: 0.95, n: 40 }, (x) => [
    x,
    0.3 + 0.15 * Math.sin(x * 8),
  ])
)
 
s.setStrokeColor(215, 30, 45)
s.lineWidth = 0.004
s.draw(wave)
 
// beads spread evenly by distance, each turned to follow the wave
wave.pointsAlong({ n: 30 }).forEach((at, i) => {
  s.setFillColor(20 + i * 6, 75, 55)
  s.withTranslation(at, () => {
    s.withRotation(v.heading(wave.tangentAt(i / 29)), () => {
      s.fill(new Rect({ at: [0, 0], w: 0.02, h: 0.05, align: "center" }))
    })
  })
})

Doing this whilst drawing is common enough that SCanvas has alongPath for it, which hands you the point and the angle together.

Measuring shape: boxes, area and what is inside

Sampling tells you where a path goes; these tell you what shape it is.

  • path.boundingBox — the smallest box containing every point, given as { at, w, h }, exactly what Rect takes, so new Rect(path.boundingBox) is the box itself
  • path.area — the area enclosed, taking the path as closed whether or not close was called, and always positive whichever way round the points go
  • path.containsPoint(at) — whether a point falls inside it, concave shapes and all
  • path.convexHull — the smallest convex path containing the whole thing, as if a rubber band were stretched around it (the standalone convexHull does the same for bare points)

Together they cover the usual jobs: framing something, sorting shapes by size, and scattering things inside an outline rather than merely near it.

const outline = new Star({ at: [0.5, 0.5], n: 7, r: 0.35, r2: 0.16 }).path
 
s.setStrokeColor(215, 30, 40)
s.lineWidth = 0.003
s.draw(outline)
s.draw(new Rect(outline.boundingBox)) // the box it fits in
 
// dots inside the star, not merely inside its bounding box
s.times(500, () => {
  const at = s.randomPoint()
  if (outline.containsPoint(at)) {
    s.setFillColor(20 + 300 * outline.area, 70, 55, 0.8)
    s.fill(new Circle({ at, r: 0.006 }))
  }
})

A convex hull wraps a scattered cloud of points in the shape they suggest, from the points alone:

s.background(215, 35, 15)
s.forTiling({ n: 2, type: "square", margin: 0.05 }, (_at, [dX], c, i) => {
  const cloud = SimplePath.withPoints(
    s.build(s.times, 9, () => s.perturb({ at: c, magnitude: dX * 0.8 }))
  )
  s.setFillColor(20 + i * 40, 70, 55, 0.55)
  s.fill(cloud.convexHull)
  s.setFillColor(0, 0, 95, 0.9)
  cloud.points.forEach((at) => s.fill(new Circle({ at, r: 0.008 })))
})

Thinning paths out

Chaikin smoothing, tracing a flow field or sampling a curve all leave paths with far more points than their shape needs. simplified({ tolerance }) drops the ones that barely matter (the Ramer–Douglas–Peucker algorithm): everything left out lies within tolerance of the path that remains. That keeps later work — and exported SVG — manageable, and a heavy tolerance is an effect in its own right, faceting a smooth curve into something angular.

s.background(40, 20, 95)
s.lineWidth = 0.004
 
const blob = SimplePath.withPoints(
  s.build(s.aroundCircle, { at: [0, 0], r: 0.4, n: 20 }, (at) =>
    s.perturb({ at, magnitude: 0.2 })
  )
)
  .close()
  .chaiken({ n: 4, looped: true })
 
// the same loop, progressively less detailed
const tolerances = [0, 0.004, 0.03]
s.forHorizontal({ n: 3, margin: 0.05 }, (_at, [dX], c, i) => {
  const path = (
    tolerances[i] === 0 ? blob : blob.simplified({ tolerance: tolerances[i] })
  ).scaled(dX * 0.9)
  s.setStrokeColor(200 + i * 40, 60, 45)
  // simplifying moves the centroid a little, so centre each one on its tile
  s.draw(path.moved(v.subtract(c, path.centroid)))
})

SimplePath.flowLine, another way to end up with more points than you need, is covered under Randomness and Noise.

Offsetting: ribbons and contours

offset({ distance }) gives back the parallel path: every point shifted sideways by the same amount. A positive distance moves a quarter turn clockwise from the direction of travel, as it appears on screen — so a path drawn left to right is offset downwards, and a closed path drawn clockwise (as the built-in shapes' paths are) is offset inwards.

Offset a line each way and join the two up and you have a filled band, which is how you get a stroke whose width you control per line rather than per context:

s.background(205, 30, 12)
s.lineWidth = 0.0015
 
s.times(9, (i) => {
  const line = SimplePath.withPoints(
    s.build(s.range, { from: -0.02, to: 1.02, n: 14 }, (x) => [
      x,
      0.06 + i * 0.1 + 0.04 * perlin2(x * 2.5, i * 1.7),
    ])
  ).chaiken({ n: 3 })
 
  const halfWidth = 0.008 + 0.022 * s.random()
  const ribbon = line
    .offset({ distance: halfWidth })
    .withAppended(line.offset({ distance: -halfWidth }).reversed)
    .close()
 
  s.setFillColor(185 + i * 9, 70, 55, 0.85)
  s.fill(ribbon)
  s.setStrokeColor(0, 0, 100, 0.5)
  s.draw(ribbon)
})

Stepping the distance instead gives contours, each one further inside the last. Corners are mitred, so the points of a star stay sharp all the way in; miterLimit (in multiples of the distance, 4 by default) caps how far a very sharp corner may be thrown out.

s.background(35, 25, 96)
s.lineWidth = 0.0025
 
const outline = new Star({ at: s.meta.center, n: 7, r: 0.44, r2: 0.3 }).path
s.range({ from: 0, to: 0.26, n: 22 }, (d) => {
  s.setStrokeColor(20 + d * 260, 70, 45)
  s.draw(outline.offset({ distance: d }))
})

Every point moves, so a path offset by more than the radius of its own curves will fold over itself into little loops. That is sometimes the effect you want; when it is not, offset less far, or simplified first.

Path: curves made easy

Path supports cubic Bézier curves, but you never have to place control points by hand. Instead addCurveTo takes a target point and a descriptive configuration:

  • curveSize — how far the curve bulges (relative to the line length)
  • polarity — which side it bulges towards (1 or -1)
  • bulbousness — how rounded the curve is
  • curveAngle — skews the peak of the curve
  • twist — rotates the control points for S-like curves
import { Path } from "solandra"
 
const curve = Path.startAt([0.1, 0.5]).addCurveTo([0.9, 0.5], {
  curveSize: 0.5,
  bulbousness: 1.5,
})

Here is a grid exploring curveSize (left to right) against bulbousness (top to bottom):

s.background(0, 0, 15)
s.lineWidth = 0.004
s.forTiling({ n: 5, type: "square", margin: 0.05 }, ([x, y], [dX, dY]) => {
  const i = Math.round(x * 10)
  s.setStrokeColor(150 + i * 20, 60, 65)
  s.draw(
    Path.startAt([x + dX * 0.15, y + dY / 2]).addCurveTo(
      [x + dX * 0.85, y + dY / 2],
      {
        curveSize: 0.2 + x,
        bulbousness: 0.2 + y * 2,
      }
    )
  )
})

Mixing lines and curves, and closing back with a curve, makes leaf- and petal-like forms trivial:

s.background(120, 25, 94)
const { center } = s.meta
s.times(14, (n) => {
  const a = (n * Math.PI * 2) / 14
  const tip = v.polarToCartesian(center, 0.42, a)
  s.setFillColor(90 + n * 6, 55, 45, 0.8)
  s.fill(
    Path.startAt(center)
      .addCurveTo(tip, { curveSize: 0.25, polarity: 1 })
      .addCurveTo(center, { curveSize: 0.25, polarity: 1 })
  )
})

There is also addCurve({ to, ...config }) if you prefer a single configuration object, and curvify on SimplePath to convert an existing polyline into a curved Path:

const curvy = SimplePath.withPoints(points).curvify((i) => ({
  polarity: i % 2 === 0 ? 1 : -1,
  curveSize: 0.5,
}))

Transforming paths

Both Path and SimplePath support a family of (mostly immutable) transformations:

  • moved(delta) — translate by a vector
  • scaled(factor) — scale around the centroid
  • rotated(angle) — rotate around the centroid
  • transformed(fn) — apply any point-wise function (transformLooped keeps closed paths closed with non-deterministic transforms)
  • reversed — reverse direction (useful for cutting holes with CompoundPath)
  • centroid — the vertex-wise center
s.background(230, 30, 12)
const square = new RegularPolygon({ at: [0.5, 0.5], n: 4, r: 0.35 }).path
s.times(20, (n) => {
  s.setStrokeColor(180 + n * 6, 70, 60, 0.9)
  s.lineWidth = 0.002 + n * 0.0002
  s.draw(square.scaled(1 - n * 0.045).rotated(n * 0.12))
})

Cutting things up

Paths can be decomposed:

  • segmented splits a closed path into triangles around its centroid
  • exploded({ magnitude, scale }) does the same but displaces (magnitude) and shrinks (scale) each piece
  • subdivide({ m, n }) splits a path into two along the given vertex/edge indices
  • edges (on SimplePath) gives each segment as its own path
s.background(0, 0, 96)
const poly = new RegularPolygon({ at: [0.5, 0.5], n: 8, r: 0.35 }).path
poly.exploded({ magnitude: 1.4, scale: 0.85 }).forEach((piece, i) => {
  s.setFillColor(330 - i * 12, 70, 55)
  s.fill(piece)
})

Recursive subdivision is a classic generative technique; segmented makes it a one-liner per level:

s.background(210, 40, 15)
let pieces = [new RegularPolygon({ at: [0.5, 0.5], n: 6, r: 0.4 }).path]
s.times(4, () => {
  pieces = pieces.flatMap((p) =>
    s.random() > 0.4 ? p.segmented.map((q) => q.scaled(0.92)) : [p]
  )
})
pieces.forEach((p) => {
  s.setFillColor(s.sample([190, 210, 230, 40]), 70, 60, 0.85)
  s.fill(p)
})

Next: drive your paths with Iteration or displace them with Randomness and Noise.

Solandra was made by James Porter.

Check out the GitHub page or install with npm i solandra