Use SimplePath for straight lines between points and Path for a mix of lines and curves. Both support chained method calls and transformations.
A SimplePath is 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()chaiken smooths a path by cutting corners repeatedly (Chaikin's algorithm). Set n for the number of iterations and looped: true for a closed path.
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 }))
})SimplePath measures positions by distance along the path. Sampling at equal intervals gives evenly spaced points, regardless of the spacing of the original vertices.
path.length — the total lengthpath.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 direction vector at that position; use v.heading(...) to get its anglepath.pointsAlong({ n, inclusive }) — n evenly spaced points; pass inclusive: false for a closed path, where the end is the start againconst 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" }))
})
})
})SCanvas.alongPath passes each point and its angle to a callback; see Iteration.
These properties and methods treat the path as a shape:
path.boundingBox — the smallest axis-aligned box containing all points, as { at, w, h }; pass it to new Rect(...)path.area — the absolute signed area, treating the path as closed; regions traced in opposite directions cancel outpath.containsPoint(at) — whether a point is inside the closed path, including concave shapespath.convexHull — the smallest convex path containing all points; the standalone convexHull works on an array of pointsUse them to frame a shape, compare areas or place points inside an outline.
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 encloses a set of scattered points:
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 })))
})simplified({ tolerance }) removes points using the Ramer–Douglas–Peucker algorithm. Removed points lie within tolerance of the simplified path. Use a small tolerance to reduce point count, or a larger one for a more angular shape.
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)))
})See Randomness and Noise for SimplePath.flowLine, which traces paths through vector fields.
offset({ distance }) creates a parallel path with mitred corners. Positive distances offset to the right of the direction of travel: downwards for a line drawn left to right, or inwards for a clockwise outline. Negative distances offset the other way.
Offset a line in both directions and join the results to form a filled ribbon:
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 }))
})Large offsets or self-intersecting paths can produce loops. Reduce the distance to limit these. miterLimit caps corner extensions at a multiple of the offset distance (default 4).
Path.addCurveTo creates a cubic Bézier curve from a target point and these options:
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 iscurveAngle — skews the peak of the curvetwist — rotates the control points for S-like curvesimport { 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,
}
)
)
})Join two curves to make a petal:
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,
}))These methods return transformed copies of a Path or SimplePath. The centroid getter returns the average vertex position:
moved(delta) — translate by a vectorscaled(factor) — scale around the centroidrotated(angle) — rotate around the centroidtransformed(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 centers.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))
})Split paths into smaller pieces:
segmented splits a closed path into triangles around its centroidexploded({ magnitude, scale }) does the same but displaces (magnitude) and shrinks (scale) each piecesubdivide({ m, n }) splits a path into two along the given vertex/edge indicesedges (on SimplePath) gives each segment as its own paths.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)
})Call segmented repeatedly to subdivide a shape:
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