The Shape of a Stroke: Envelopes, Bulges, and Lambdas

Part 2 of 3 in our series on variable-width strokes.

Series: Variable-Width Strokes

  1. The Swelling Line — variableOffset and compoundVariableOffset
  2. The Shape of a Stroke (this post) — envelopes, bulges, and lambdas
  3. The Reliable Line — hash, noise, and envelopes join the stdlib

The Swelling Line introduced variableOffset and compoundVariableOffset: place stops along a path, give each one a distance, and the stroke breathes. That post ended with ribbons. This one asks the next question: what does it take to make a stroke genuinely rich — layered, shaped, textured — and still reusable?

The answer turns out to be a ladder of abstractions over one idea: width as a function of position. Climbing that ladder is what this post is about, and near the top it required growing the language itself. Pathogen now has lambda expressions:

let f = {|a, b| return a + b; };
let three = f(1, 2);

The same {|...|} block syntax you already know from map and the gradient builders, promoted to a first-class value — with true lexical capture. Here's the stroke that earns it.

A rich stroke is a stack of thin ones

One compoundVariableOffset pass gives you a ribbon. A glow takes sixteen: loop an index from 16 down to 1, and give each layer stop widths scaled by the index — wide soft passes first, a narrow bright core last. Drive the widths with randomRange and the result is pleasingly organic:

// viewBox="0 60 400 140" //-- A layered "glow" stroke: sixteen compoundVariableOffset passes, widths //-- driven by randomRange scaled by the layer index. Wide soft layers paint //-- first, a narrow bright core lands last. Organic -- and unrepeatable: //-- every compile rolls new widths. //-- (Ranges are inclusive on both ends: 0..stepMax yields stepMax + 1 stops.) define ViewBox(0, 60, 400, 140); let spine = @{ c 80 -100 160 100 240 0 }; let px = 80; let py = 130; let base = oklch(0.72 0.14 20); for (haloIndex in 16..1) { let halo = spine.compoundVariableOffset() {|vo, pb| let stepMax = 48; for (step in 0..stepMax) { let time = step / stepMax; vo.stop(time, randomRange(0.1, 0.6 * haloIndex), CurveContinuity.G1, randomRange(-0.3 * haloIndex, -0.1), CurveContinuity.G1); } vo.startCap(Cap.tapered(2, CurveContinuity.G0)); vo.endCap(Cap.tapered(2, CurveContinuity.G0)); }; let haloColor = base.hueShift(calc(haloIndex * -6)); let haloLayer = PathLayer(`halo-${haloIndex}`) ${ fill: haloColor; stroke: none; opacity: 0.25; }; haloLayer.apply { M calc(px + halo.anchor.x) calc(py + halo.anchor.y) halo.draw(); } } Sixteen stacked compoundVariableOffset layers, widths driven by randomRange scaled by the layer index. Fuzzy, organic — and unrepeatable.

Organic, and a dead end. Every knob is buried in the loop body; the randomness means no two compiles match; and the moment you try to extract a reusable function, you hit the real design question: the thing you want to pass around isn't a number. It's a shape.

Width as a function: the envelope

Call that shape an envelope — width as a function of position t along the spine. The first envelope worth designing is a bulge: a chosen spot where the stroke swells, entered and left smoothly. A raised cosine does it in four lines:

fn bulge(t, center, spread) {
  let d = clamp(abs(t - center) / spread, 0, 1);
  return 0.5 * (1 + cos(mpi(d)));
}

d is normalized distance from the bulge center; 0.5·(1 + cos(πd)) is easeInOutSine wearing its trig clothes. The property that matters: its derivative is zero at both ends, so the swell leaves the base width and rejoins it without a visible crease.

// viewBox="0 0 400 320" //-- SEE the easing: three candidate bulge envelopes plotted as graphs, with //-- the stroke the raised cosine produces rendered on a STRAIGHT spine below. //-- On a straight spine the silhouette of the stroke IS its envelope; the //-- dashed vertical guide ties the plot's peak to the stroke's widest point. //-- //-- tent: 1 - d crease at the peak AND the edges //-- smoothstep: u*u*(3 - 2u), u = 1-d polynomial ease, flat at both ends //-- raised cos: 0.5 * (1 + cos(pi*d)) = easeInOutSine; derivative is 0 //-- at d=0 and d=1, so the width enters and leaves the bulge //-- flat -- no crease where the swell meets the base width. //-- Smoothstep tracks the raised cosine within 1% of the peak (max gap 0.010 //-- at u = 0.72) -- drawn solid it would vanish underneath, hence the dashes. define ViewBox(0, 0, 400, 320); //-- Normalized distance from the bulge center: 0 at the peak, 1 at the edge //-- of the bulge window, clamped outside it. fn win(t, center, spread) { return clamp(abs(t - center) / spread, 0, 1); } fn tentEnv(t, center, spread) { let d = win(t, center, spread); return 1 - d; } fn smoothEnv(t, center, spread) { let d = win(t, center, spread); let u = 1 - d; return u * u * (3 - 2 * u); } fn cosEnv(t, center, spread) { let d = win(t, center, spread); return 0.5 * (1 + cos(mpi(d))); } //-- The same shape-agnostic wrapper used throughout this post. fn strokeFromPathBlock(pathBlock, steps, bulgeTime, spread, base1, peak1, base2, peak2, continuity, cap) { return pathBlock.compoundVariableOffset() {|vo, pb| vo.startCap(cap); for (i in 0..steps) { let t = i / steps; let e = cosEnv(t, bulgeTime, spread); vo.stop(t, base1 + peak1 * e, continuity, base2 + peak2 * e, continuity); } vo.endCap(cap); }; } //-- Plot geometry ----------------------------------------------------------- let plotX = 80; let plotW = 240; let plotY = 150; let plotH = 80; let samples = 48; let center = 0.5; let spread = 0.35; //-- envFn arrives BY NAME — functions are first-class values in Pathogen. fn plotEnvelope(curveLayer, envFn) { curveLayer.apply { M plotX calc(plotY - plotH * envFn(0, center, spread)) for (i in 1..samples) { let t = i / samples; L calc(plotX + plotW * t) calc(plotY - plotH * envFn(t, center, spread)) } } } let axis = PathLayer('axis') ${ fill: none; stroke: #bbb; stroke-width: 1; }; axis.apply { M plotX plotY L calc(plotX + plotW) plotY } //-- Vertical dashed guide through the bulge center: plot peak -> stroke waist. let peakGuide = PathLayer('peak-guide') ${ fill: none; stroke: #999; stroke-width: 0.75; stroke-dasharray: 4 3; }; peakGuide.apply { M calc(plotX + plotW * center) calc(plotY - plotH - 8) L calc(plotX + plotW * center) 280 } //-- Smoothstep is plotted last so its dashes sit on top of the cosine. let tentLayer = PathLayer('env-tent') ${ fill: none; stroke: #b0b0b0; stroke-width: 1.25; stroke-dasharray: 5 3; }; let cosLayer = PathLayer('env-raised-cos') ${ fill: none; stroke: oklch(0.55 0.18 260); stroke-width: 2; }; let smoothLayer = PathLayer('env-smoothstep') ${ fill: none; stroke: oklch(0.62 0.16 160); stroke-width: 1.25; stroke-dasharray: 2 4; }; plotEnvelope(tentLayer, tentEnv); plotEnvelope(cosLayer, cosEnv); plotEnvelope(smoothLayer, smoothEnv); //-- The stroke shaped by the raised-cosine envelope, on a straight spine. let spine = @{ l 240 0 }; let ribbon = strokeFromPathBlock(spine, 48, center, spread, 0.75, 13, -0.75, -13, CurveContinuity.G1, Cap.tapered(2, CurveContinuity.G0)); let strokeLayer = PathLayer('shaped-stroke') ${ fill: oklch(0.55 0.18 260); stroke: none; opacity: 0.9; }; strokeLayer.apply { M calc(plotX + ribbon.anchor.x) calc(240 + ribbon.anchor.y) ribbon.draw(); } let labels = TextLayer('labels') ${ font-family: system-ui, sans-serif; font-size: 11; fill: #888; text-anchor: start; }; labels.apply { text(78, 28)`bulge envelopes` text(80, 172)`t=0` text(190, 172)`t=0.5` text(304, 172)`t=1` text(78, 292)`raised-cosine stroke (straight spine)` } //-- Legend: each entry gets a line sample drawn with that curve's real //-- stroke and dash pattern, so the key reads without relying on color. let swatchTent = PathLayer('swatch-tent') ${ fill: none; stroke: #b0b0b0; stroke-width: 1.25; stroke-dasharray: 5 3; }; swatchTent.apply { M 252 37 L 268 37 } let legendTent = TextLayer('legend-tent') ${ font-family: system-ui, sans-serif; font-size: 10; text-anchor: start; fill: #b0b0b0; }; legendTent.apply { text(274, 40)`linear tent` } let swatchSmooth = PathLayer('swatch-smoothstep') ${ fill: none; stroke: oklch(0.62 0.16 160); stroke-width: 1.25; stroke-dasharray: 2 4; }; swatchSmooth.apply { M 252 51 L 268 51 } let legendSmooth = TextLayer('legend-smoothstep') ${ font-family: system-ui, sans-serif; font-size: 10; text-anchor: start; fill: oklch(0.62 0.16 160); }; legendSmooth.apply { text(274, 54)`smoothstep` } let swatchCos = PathLayer('swatch-raised-cos') ${ fill: none; stroke: oklch(0.55 0.18 260); stroke-width: 2; }; swatchCos.apply { M 252 65 L 268 65 } let legendCos = TextLayer('legend-raised-cos') ${ font-family: system-ui, sans-serif; font-size: 10; text-anchor: start; fill: oklch(0.72 0.15 260); }; legendCos.apply { text(274, 68)`raised cosine` } let scene = GroupLayer('scene') ${}; scene.append(axis, peakGuide, tentLayer, cosLayer, smoothLayer, strokeLayer, labels, swatchTent, legendTent, swatchSmooth, legendSmooth, swatchCos, legendCos); Three candidate envelopes plotted above the stroke the winner produces. On a straight spine, the stroke's silhouette is its envelope — the dashed guide connects the plot's peak to the widest point. Smoothstep (drawn dashed) hides inside the cosine: the two differ by at most 1% of the peak height.

Passing the shape

Pathogen functions are values: hand a named fn to another function and call it through the parameter. So the general stroke-maker takes its envelopes as functions — one per profile — and samples them at each stop:

// viewBox="0 0 400 560" //-- Generalize the SHAPE, not the knobs: the wrapper takes envelope //-- functions (t) -> signed offset and samples one per profile at each stop. //-- Functions are first-class values in Pathogen -- pass them by name, call //-- them through the parameter. //-- //-- Convention: envelopes return SIGNED offsets (the sign picks the side), //-- matching vo.stop's profile semantics. define ViewBox(0, 0, 400, 560); fn bulge(t, center, spread) { let d = clamp(abs(t - center) / spread, 0, 1); return 0.5 * (1 + cos(mpi(d))); } //-- The shape-agnostic wrapper. env1/env2 are functions. fn strokeWithEnvelopes(pathBlock, steps, env1, env2, continuity, cap) { return pathBlock.compoundVariableOffset() {|vo, pb| vo.startCap(cap); for (i in 0..steps) { let t = i / steps; vo.stop(t, env1(t), continuity, env2(t), continuity); } vo.endCap(cap); }; } //-- Plug-in envelopes as named fns. Before lambdas shipped, this was the //-- only form a passable envelope could take -- note how bulged1/bulged2 //-- bake their center and peak in as constants. fn constant1(t) { return 4; } fn constant2(t) { return -4; } fn taper1(t) { return lerp(9, 0.5, t); } fn taper2(t) { return -lerp(9, 0.5, t); } fn bulged1(t) { return 0.5 + 10 * bulge(t, 0.55, 0.3); } fn bulged2(t) { return -(0.5 + 4 * bulge(t, 0.55, 0.3)); } //-- Both profiles ride the SAME sine, so the band wanders side to side while //-- its width stays near-constant — a wavy ribbon. (If an envelope crosses //-- zero, the profile crosses the spine; same-sign stretches legally float //-- off one side as a detached band.) fn wave1(t) { return 5 + 4 * sin(TAU() * 3 * t); } fn wave2(t) { return -5 + 4 * sin(TAU() * 3 * t); } let spine = @{ c 80 -100 160 100 240 0 }; let px = 80; let taperCap = Cap.tapered(2, CurveContinuity.G0); let base = oklch(0.55 0.18 260); fn drawRow(name, ribbon, py, hueOffset) { let rowColor = base.hueShift(hueOffset); let rowLayer = PathLayer(`row-${name}`) ${ fill: rowColor; stroke: none; opacity: 0.9; }; rowLayer.apply { M calc(px + ribbon.anchor.x) calc(py + ribbon.anchor.y) ribbon.draw(); } } drawRow('constant', strokeWithEnvelopes(spine, 48, constant1, constant2, CurveContinuity.G1, taperCap), 95, 0); drawRow('taper', strokeWithEnvelopes(spine, 48, taper1, taper2, CurveContinuity.G1, taperCap), 225, 60); drawRow('bulge', strokeWithEnvelopes(spine, 48, bulged1, bulged2, CurveContinuity.G1, taperCap), 355, 120); drawRow('wave', strokeWithEnvelopes(spine, 96, wave1, wave2, CurveContinuity.G1, taperCap), 490, 180); let labels = TextLayer('labels') ${ font-family: system-ui, sans-serif; font-size: 11; fill: #888; text-anchor: start; }; labels.apply { text(78, 32)`constant1 / constant2` text(78, 162)`taper1 / taper2 (lerp 9 -> 0.5)` text(78, 292)`bulged1 / bulged2 (asymmetric peaks)` text(78, 425)`wave1 / wave2 (same sine: wandering ribbon)` } One wrapper, four envelope pairs passed by name: constant, taper, bulge, wave. The wrapper neither knows nor cares what shape arrives.

This is a real abstraction — the wrapper is finished, forever, no matter what envelope you invent next. But look at the envelopes themselves. Every one is a named, top-level function with its constants baked in. bulged1 hard-codes its center and peak, because until now there was no way to write "a bulge envelope for these particular parameters" as a value. Pathogen's named functions are dynamically scoped — free names resolve in the caller's scope at call time — so a function couldn't carry values from where it was written. The workarounds are all familiar and all unsatisfying: top-level lets you hope nobody shadows, spec-object arrays interpreted by a helper, or parameter lists that grow a slot for every knob.

{|a, b| ... } grows up

The missing piece is a function literal that remembers. A block literal in expression position is now a lambda: a function value that captures the scope where it was written.

let scale = 3;
let times = {|x| return calc(x * scale); };

fn caller() {
  let scale = 100;    // does NOT affect the lambda
  return times(2);
}
let six = caller();   // 6 — lexical capture, not the caller's scale

Three design decisions worth spelling out:

  • Lambdas are lexical; named fns stay dynamic. Changing fn scoping would silently alter existing programs, so it doesn't. Both behaviors are now documented — and if you've ever been surprised by a named function picking up a caller's variable, that section is worth two minutes.
  • Capture is by reference, per loop iteration. Loops create a fresh scope each pass, so lambdas born in a loop each remember their iteration — the classic capture trap from other languages resolves the friendly way here.
  • Zero parameters is {|| ... } — one grammar wrinkle, since two bare pipes otherwise lex as logical-or.

And the part that makes the feature feel native: a builtin can take a lambda you already built, applied with the << operator — the operator you already use to merge objects and apply style blocks, here wearing a second hat. A literal trailing block still works exactly as before — that's what every sample above uses. The << form is the worker spelling of the same idea, for when the callback is a value with a name: items.map() << f, items.sort() << cmp, grid.fill() << f, and — the one this post has been building toward — spine.compoundVariableOffset() << mk. The parentheses keep the builtin's real parameters (reduce(init) << f); << supplies the worker. The full rules live in Applying workers.

Envelopes on demand

The baked-constants problem, dissolved — twice. The first row replaces four named taper functions with two inline lambda literals. Then the payoff: three bulge strokes from one loop, each iteration's lambdas closing over that iteration's center and peak:

// viewBox="0 0 400 560" //-- Closures dissolve the baked-constants problem. A lambda {|t| ...} //-- captures the scope where it is WRITTEN, so a parameterized envelope can //-- be built inline, wherever its parameters live. Compare with //-- 03-named-envelopes.pathogen: same wrapper, but the envelopes no longer //-- need to be named top-level fns with constants baked in. //-- //-- Rows 2-4 are the payoff: lambdas built INSIDE a loop, each closing over //-- that iteration's center and peak. Loops create a fresh scope per //-- iteration, so each lambda remembers its own values. define ViewBox(0, 0, 400, 560); fn bulge(t, center, spread) { let d = clamp(abs(t - center) / spread, 0, 1); return 0.5 * (1 + cos(mpi(d))); } //-- The identical wrapper — it neither knows nor cares whether env1 / env2 //-- arrived as named fns or closures. fn strokeWithEnvelopes(pathBlock, steps, env1, env2, continuity, cap) { return pathBlock.compoundVariableOffset() {|vo, pb| vo.startCap(cap); for (i in 0..steps) { let t = i / steps; vo.stop(t, env1(t), continuity, env2(t), continuity); } vo.endCap(cap); }; } let spine = @{ c 80 -100 160 100 240 0 }; let px = 80; let taperCap = Cap.tapered(2, CurveContinuity.G0); let base = oklch(0.55 0.18 260); fn drawRow(name, ribbon, py, hueOffset) { let rowColor = base.hueShift(hueOffset); let rowLayer = PathLayer(`row-${name}`) ${ fill: rowColor; stroke: none; opacity: 0.9; }; rowLayer.apply { M calc(px + ribbon.anchor.x) calc(py + ribbon.anchor.y) ribbon.draw(); } } //-- Row 1: inline lambda envelopes — the taper, without any named //-- top-level fns. let taperUp = {|t| return lerp(9, 0.5, t); }; let taperDn = {|t| return -lerp(9, 0.5, t); }; drawRow('taper', strokeWithEnvelopes(spine, 48, taperUp, taperDn, CurveContinuity.G1, taperCap), 95, 0); //-- Rows 2-4: closures born in a loop. Each iteration's lambdas capture THAT //-- iteration's center and peak (loops create a fresh scope per iteration). for (rowIndex in 0..2) { let center = 0.25 + rowIndex * 0.25; let peak = 6 + rowIndex * 3; let envUp = {|t| return 0.5 + peak * bulge(t, center, 0.22); }; let envDn = {|t| return -(0.5 + peak * bulge(t, center, 0.22)); }; let ribbon = strokeWithEnvelopes(spine, 48, envUp, envDn, CurveContinuity.G1, taperCap); drawRow(`loop-${rowIndex}`, ribbon, calc(225 + rowIndex * 130), calc(60 + rowIndex * 60)); } let labels = TextLayer('labels') ${ font-family: system-ui, sans-serif; font-size: 11; fill: #888; text-anchor: start; }; labels.apply { text(78, 32)`inline lambdas: taperUp / taperDn` text(78, 162)`loop closures: center 0.25, peak 6` text(78, 292)`center 0.5, peak 9` text(78, 422)`center 0.75, peak 12` } Row 1: inline lambdas replace four named taper fns. Rows 2-4: each loop iteration builds envelope lambdas capturing that iteration's center and peak — the bulge marches along the spine.

The wrapper is untouched — it still just calls env1(t) and env2(t). What changed is where envelopes can come from: anywhere, parameterized by whatever is in scope at that spot.

The rich stroke, assembled

Now rebuild the sixteen-layer glow with intent instead of Math.random. Each layer wants: a base width and two bulge peaks scaled by the layer index, a deterministic jitter texture with a per-layer stream, and tapered caps. As closures, that's three lambdas per layer — width1, width2, jitter, each capturing the layer index — plus a builder lambda mk, applied with spine.compoundVariableOffset() << mk:

// viewBox="0 60 400 140" //-- The glow, rebuilt on closures -- deterministic this time. Without //-- closures this program threads TEN parameters through a helper //-- (pathBlock, steps, base1, bulges1, base2, bulges2, jitterAmount, salt, //-- continuity, cap). Here each layer builds lambdas that CAPTURE the layer //-- index and the scaled widths, and the builder lambda is applied with //-- compoundVariableOffset() << mk -- a function value doing the trailing //-- block's job. The closures carry the state; the parameter lists //-- collapse. //-- Recompiles are byte-identical: the jitter is a hash of the stop index //-- and a captured per-layer salt, not randomRange. define ViewBox(0, 60, 400, 140); fn bulge(t, center, spread) { let d = clamp(abs(t - center) / spread, 0, 1); return 0.5 * (1 + cos(mpi(d))); } fn hash01(i) { let s = sin(i * 12.9898) * 43758.5453; return s - floor(s); } let spine = @{ c 80 -100 160 100 240 0 }; let px = 80; let py = 130; let steps = 48; let taperCap = Cap.tapered(2, CurveContinuity.G0); let base = oklch(0.72 0.14 20); for (haloIndex in 16..1) { //-- Everything below closes over THIS iteration's haloIndex. let jitter = {|i| return 1 + (hash01(i * 7 + haloIndex * 1013) * 2 - 1) * 0.2; }; let width1 = {|t| return 0.15 * haloIndex + 0.6 * haloIndex * bulge(t, 0.35, 0.3) + 0.35 * haloIndex * pow(bulge(t, 0.78, 0.18), 2); }; let width2 = {|t| return -0.15 * haloIndex - 0.3 * haloIndex * bulge(t, 0.55, 0.4); }; //-- The builder itself is a lambda, applied with << — the worker form //-- of `spine.compoundVariableOffset() {|vo, pb| ... }`. let mk = {|vo, pb| vo.startCap(taperCap); for (i in 0..steps) { let t = i / steps; vo.stop(t, width1(t) * jitter(i * 2), CurveContinuity.G1, width2(t) * jitter(i * 2 + 1), CurveContinuity.G1); } vo.endCap(taperCap); }; let halo = spine.compoundVariableOffset() << mk; let haloColor = base.hueShift(calc(haloIndex * -6)); let haloLayer = PathLayer(`halo-${haloIndex}`) ${ fill: haloColor; stroke: none; opacity: 0.25; }; haloLayer.apply { M calc(px + halo.anchor.x) calc(py + halo.anchor.y) halo.draw(); } } The glow rebuilt on closures: designed swells, deterministic hash jitter with a per-layer stream (the salt is just a captured variable), and a builder lambda applied with << onto compoundVariableOffset. Compare the opening sample: the random fuzz is gone, the swells sit where they were put, and recompiles are byte-identical.

Before lambdas, this program needed a ten-parameter helper function to thread base widths, bulge specs, jitter amount, salt, continuity, and caps down into the builder. The closure version was verified against that parameter-threaded build — the same deterministic design, not the randomized stroke that opened this post — by diffing the compiled SVG: byte-identical. Same geometry, radically less plumbing — which is the whole argument for closures in one sentence.

And unlike the randomized original, this stroke is a design: move a bulge, sharpen a peak, retune the jitter, recompile, and get exactly what you asked for — every time.

The fine print

Version one has honest edges, all documented:

  • Call lambdas through a plain name. fns[0](5), obj.f(1), and immediately-invoked literals aren't callable yet — bind to a let first.
  • A lambda literal can't sit inside a call in path-argument position (M use({|x| ...}) 0) — path arguments stop at |. Pass a name.
  • Constructor binding blocks (LinearGradient(...) {|g| ...}, Marker, Pattern, filters, Grid(...) {|g| ...}) still take literal blocks; the callback-style methods also accept << workers.

Where this goes

An envelope is just the first function worth capturing. The same pattern — small lambdas closing over local parameters, applied to a builder with << — reaches anywhere Pathogen takes a callback: comparator families for sort, field functions for Grid.fill, per-glyph stroke treatments built inside a contours loop. Calligraphic nibs, pressure-simulating taper families, multi-pass glows with per-pass texture: all of them are a lambda closing over the parameters that make each instance this instance.

Every sample on this page is live — open the code pane, change a captured center or peak, and watch the closure carry it into the stroke. The full reference is in the Lambdas docs, the scoping rules in Functions vs lambdas, and the stroke machinery in the Variable Offset docs.

The stroke was always a function. Now the language lets you treat it like one.