Ollin

Isolines

isolines traces the level curves of a scalar field by marching squares. Give it any (Vector2) -> Double field and a level, and it returns the contours where the field crosses that level. The field can be noise, image brightness, or your own math. The same call reads a surface as a topographic map, outlines a metaball blob, and renders a photograph in tone lines.

A rectangle of gray dots sampled on a grid, darker where a hidden field runs higher, with an orange contour threading between them: a closed ring around the bump that fits inside, and an open arc ending where it runs off the right edge

A contour that closes inside the bounds comes back as a closed Contour. A contour that runs off the edge comes back open, and it ends on the boundary, so check isClosed when you draw. The output feeds drawPolyline, drawCurve for the rounded reading, smoothed(iterations:), and hatching and SVG export.

Three panels of the same noise field: as a grayscale picture, then orange contours tracing a single level through it, then a full stack of black contours reading as a topographic map

Contents

isolines (a field)

isolines(at level: Double,
         in bounds: Rectangle? = nil,
         resolution: Int = 128,
         field: (Vector2) -> Double) -> [Contour]

This form samples field on a grid over bounds and traces every contour at level. bounds is the whole canvas by default. resolution is the number of grid cells across the longer side. Raise it for tighter curves, which costs linearly more field samples.

let rings = isolines(at: 0.5, in: frame) { p in
    fbm(p.x * 0.004, p.y * 0.004)
}
noFill()
stroke(.black)
for ring in rings { drawPolyline(ring.points, closed: ring.isClosed) }

In a saddle cell the curve could pair up in two ways. Ollin settles those cells by the cell's average value, which is the standard marching-squares rule, so metaball necks merge and split cleanly.

isolines (levels)

isolines(at levels: [Double],
         in bounds: Rectangle? = nil,
         resolution: Int = 128,
         field: (Vector2) -> Double) -> [[Contour]]

This form traces a stack of levels from a single sampling pass and a single march over the grid. It returns one [Contour] per level, in the order you gave them, so it is the contour-map form. Each cell visits only the levels that pass between its corners, so a level adds the cost of its own crossings and nothing more. When the field is costly to evaluate (noise, fbm), sampling is nearly all of the work, and ten levels cost barely more than one. When the field is a cheap lookup, joining each level's crossings into contours is most of what is left: a 650-cell-wide grid of a smooth lookup took about 3.5 ms for one level and 11 ms for 28 in a release build on an M2.

let levels = Array(stride(from: 0.3, through: 0.7, by: 0.04))
for (i, group) in isolines(at: levels, in: frame, field: terrain).enumerated() {
    strokeWeight(i % 5 == 0 ? 2 : 0.8)     // the cartographer's index contour
    for curve in group { drawPolyline(curve.points, closed: curve.isClosed) }
}

isolines (an image)

isolines(of image: Image,
         at level: Double,          // or at levels: [Double]
         in bounds: Rectangle? = nil,
         resolution: Int = 128) -> [Contour]

This form draws the tone lines of a picture. It traces the contours where the image brightness crosses level. The tone runs from 0 for black to 1 for white. Ollin reads brightness as if the picture sat on white paper, so transparency counts as light. The sampling is bilinear, so the curves stay smooth past the pixel grid. The image is stretched over bounds, so pass a Rectangle(fitting:in:) of the image's size to keep its aspect. A stack of levels turns a photograph into a topographic map of its lighting.

for group in isolines(of: portrait, at: [0.25, 0.45, 0.65], in: frame) {
    for curve in group { drawPolyline(curve.points, closed: curve.isClosed) }
}

This form needs CPU pixels. Read a video frame through its snapshot() first.

Practical notes

  • Cheap enough to re-trace live. The trace itself is fast, so the field samples are what costs you. A drifting field at the default resolution re-traces comfortably every frame, and the levels form shares one sampling across the whole stack.
  • Resolution follows the features. Size the grid so the field's features span many cells, which hides the traced chords inside the curve. On a coarse grid, one pass of smoothed(iterations:) rounds the corners.
  • Metaballs are a field. For blobs in the plane, put Metaballs balls at z = 0, trace field.value(at: Vector3(p.x, p.y, 0)), and pass field.level as the level. The outlines merge and pinch the way the 3D surface does, and a lone ball's outline lands on its radius at the default level of 0.5. Each ball reaches twice its radius and no further. A sum of strength / distanceSquared has no reach, so every blob pulls on every other however far apart they sit.
  • The exact level matters less than its neighbors. For noise in 0...1, the levels between 0.3 and 0.7 cross the most terrain. A level near the field's extremes traces only a few small islands.

Related: Noise (fields worth tracing, including the looping forms), Spanning tree and Single line (the other plotter-friendly renderings), Curves (Chaikin smoothing), Export (SVG and the plotter path).

↑↓ move ↩ open