Stencils: Bridges Are Just Gaps

Part 4 of 5 in Broken Lines — projects that treat the stroke not as paint, but as geometry you can hold.

Series: Broken Lines

  1. Stroke geometry — dashes, outlines, and start points as real paths
  2. Sashiko — running stitches from binary sequences
  3. Leathercraft — stitch holes that can't disagree
  4. Stencils (this post) — bridges are just gaps
  5. What Broken Lines taught the language — the friction log, resolved

Prerequisites: Part 1's dash() and outline(), and the boolean operations — every aperture here is a difference(). The seam-merge behavior uses dash-seam from the stroke-geometry docs.

What it does

A stencil is a sheet with shapes cut out — and one hard constraint: every piece of the sheet must stay connected. Cut a ring and the circle in the middle — stencil makers call it an island, typographers call it a counter — is attached to nothing. It falls out on the workbench, and your "O" sprays as a dot. The fix, as old as stenciling: bridges, small tabs of sheet left uncut across the ring.

Today bridge placement is a manual chore — draw the shape, hand-draw tabs across it, cut, discover the counter on the floor, iterate. But look at what a bridged ring actually is: a centerline, cut most of the way around, with a few deliberate interruptions. Cut segments and interruptions. Dashes and gaps. The whole problem is a dash array.

Why you'd use it

Because the recipe is three steps and it parameterizes everything a stencil maker argues about — band width, bridge width, bridge count, and (through the dash offset) where the bridges land — while guaranteeing the geometry stays cuttable:

let cutLength = calc(centerline.length / bridgeCount - bridgeWidth);
let pieces = centerline.dash(#{
  stroke-dasharray: ${cutLength} ${bridgeWidth};
  dash-seam: merge;
});
for (segment in pieces.filter {|piece| piece.kind == 'dash'}) {
  stencil = stencil.difference(segment.path.outline(#{
    stroke-width: ${bandWidth};
  }));
}

Dash the centerline, outline() each dash to the band width, difference() each one from the sheet. The gaps were never cut — they are the bridges. Everything below is cut from mylar — the tough polyester film most stencils are cut from — but the sheet material never enters the math.

Example 1 — The island problem

The failure mode first, per house rules. Left: the design — a clean ring, counter in place. Right: the same cut on the bench — the counter is attached to nothing, and gravity is undefeated.

//-- The stencil problem in one picture: cut a ring and the middle is //-- an island — connected to nothing. Left: the design. Right: what //-- gravity does to it. define ViewBox(0, 0, 480, 250); let table = PathLayer('table') #{ fill: #1c1917; stroke: none; }; layer('table').apply { rect(0, 0, 480, 250); } let scene = GroupLayer('scene') #{}; let mylar = PathLayer('mylar') #{ fill: #d9e2ec; stroke: #94a3b8; stroke-width: 1; }; let fallen = PathLayer('fallen') #{ fill: #8298ad; stroke: #64748b; stroke-width: 1; }; let driftMarks = PathLayer('drift-marks') #{ stroke: #f59e0b; stroke-width: 1.5; stroke-dasharray: 4 3; fill: none; }; let leaders = PathLayer('leaders') #{ stroke: #94a3b8; stroke-width: 1; fill: none; }; let labels = TextLayer('labels') #{ font-family: monospace; font-size: 10; fill: #94a3b8; text-anchor: middle; }; scene.append(mylar, fallen, driftMarks, leaders, labels); let sheet = @{ roundRect(0, 0, 170, 170, 10); }; let ringOuter = @{ circle(85, 85, 56); }; let ringInner = @{ circle(85, 85, 32); }; // The cut sheet is the same in both panels: the outer circle removed. // What differs is only where the counter — a separate piece of mylar, // attached to nothing — ends up. let holed = sheet.difference(ringOuter); // Left: as designed — the counter sits in place (for now). mylar.apply { M 42 42 holed.draw() } mylar.apply { M 42 42 ringInner.draw() } // Right: gravity. The dashed ring marks where the island used to sit; // the short line traces its fall. mylar.apply { M 272 42 holed.draw() } fallen.apply { M 294 76 ringInner.draw() } driftMarks.apply { M 272 42 ringInner.draw() M 379 129 l 22 34 } leaders.apply { M 127 36 v 50 } labels.apply { text(127, 28)`island (the counter)`; text(127, 232)`as designed`; text(357, 232)`as cut — the island falls`; } An unbridged ring. The middle is an island; islands fall.

Example 2 — Dash the centerline first

Before anything is cut, dash the ring's centerline and just look at the pieces — part 1's vocabulary, on a loop. The dark arcs are what the blade will follow; the short green arcs are the bridges, and they exist as geometry before any cutting happens. Bridge placement is the dash offset: stroke-dashoffset shifts the pattern within the path (part 1's startAt() moved the path's start instead — same effect, different knob), and here a half-segment shift parks the four bridges on the diagonals instead of the cardinals.

//-- Before anything is cut: dash the ring's centerline and LOOK at the //-- pieces. The dashes are what the blade will follow; the gaps are the //-- bridges — decided here, as lengths, before any geometry moves. define ViewBox(0, 0, 480, 256); let table = PathLayer('table') #{ fill: #1c1917; stroke: none; }; table.apply { rect(0, 0, 480, 256); } let scene = GroupLayer('scene') #{}; let sheetLayer = PathLayer('sheet') #{ fill: #d9e2ec; stroke: #94a3b8; stroke-width: 1; }; let cutMarks = PathLayer('cut-marks') #{ stroke: #334155; stroke-width: 4; stroke-linecap: butt; fill: none; }; let bridgeMarks = PathLayer('bridge-marks') #{ stroke: #22c55e; stroke-width: 6; stroke-linecap: butt; fill: none; }; let labels = TextLayer('labels') #{ font-family: monospace; font-size: 10; fill: #94a3b8; text-anchor: middle; }; scene.append(sheetLayer, cutMarks, bridgeMarks, labels); let sheet = @{ roundRect(0, 0, 190, 190, 10); }; sheetLayer.apply { M 145 26 sheet.draw() } let centerline = @{ circle(95, 95, 48); }; let bridgeWidth = 13; let bridgeCount = 4; let cutLength = calc(centerline.length / bridgeCount - bridgeWidth); // Shift back half a segment so the bridges land on the diagonals. let pieces = centerline.dash(#{ stroke-dasharray: ${cutLength} ${bridgeWidth}; stroke-dashoffset: calc(0 - cutLength / 2); dash-seam: merge; }); for (piece in pieces) { if (piece.kind == 'dash') { cutMarks.apply { M 145 26 piece.path.draw() } } else { bridgeMarks.apply { M 145 26 piece.path.draw() } } } labels.apply { text(240, 238)`dark: to be cut — green: the bridges, never cut at all`; } The plan before the cut: dark will be cut, green never will.

That shift has one consequence worth understanding. A closed path has a seam — the point where it starts and ends, and where the dash pattern begins. Shift the pattern half a segment and the seam lands mid-cut, which would hand back two half-segments; dash-seam: merge stitches them back into one, so the counts below stay honest.

Example 2b — Outline and subtract

Now the cut. Each dash is outlined to the band width with butt caps — flat bridge shoulders, the way a blade would leave them — and subtracted from the sheet. The leader lines mark the three parameters.

//-- The fix: don't cut the whole ring. Dash its centerline — the //-- dashes become cut segments, the gaps become bridges — and the //-- island is held. dash-seam: merge keeps the wrap-around segment //-- whole, so the counts are honest. define ViewBox(0, 0, 480, 256); let table = PathLayer('table') #{ fill: #1c1917; stroke: none; }; layer('table').apply { rect(0, 0, 480, 256); } let scene = GroupLayer('scene') #{}; let mylar = PathLayer('mylar') #{ fill: #d9e2ec; stroke: #94a3b8; stroke-width: 1; }; let centerlineGhost = PathLayer('centerline-ghost') #{ stroke: #f59e0b; stroke-width: 1.5; fill: none; }; let leaders = PathLayer('leaders') #{ stroke: #94a3b8; stroke-width: 1; fill: none; }; let labels = TextLayer('labels') #{ font-family: monospace; font-size: 10; fill: #94a3b8; text-anchor: middle; }; scene.append(mylar, centerlineGhost, leaders, labels); let sheet = @{ roundRect(0, 0, 190, 190, 10); }; // The ring to cut, described by its CENTERLINE and a band width. let centerline = @{ circle(95, 95, 48); }; let bandWidth = 24; let bridgeWidth = 13; let bridgeCount = 4; // Shift back half a segment so the bridges land on the diagonals, off // the cardinals. That drops the path's own seam mid-cut — dash-seam: // merge stitches the two halves back into one honest segment. let cutLength = calc(centerline.length / bridgeCount - bridgeWidth); let pieces = centerline.dash(#{ stroke-dasharray: ${cutLength} ${bridgeWidth}; stroke-dashoffset: calc(0 - cutLength / 2); dash-seam: merge; }); let cutSegments = pieces.filter() {|piece| piece.kind == 'dash'}; let stencil = sheet; for (segment in cutSegments) { let aperture = segment.path.outline(#{ stroke-width: ${bandWidth}; stroke-linecap: butt; }); stencil = stencil.difference(aperture); } mylar.apply { M 145 26 stencil.draw() } centerlineGhost.apply { M 145 26 centerline.draw() } // Leader-line annotations in the side canvas: the three parameters a // stencil maker argues about, pointed at the geometry they control. leaders.apply { M 358 96 l -81 -7 M 358 134 l -56 -11 M 358 176 l -74 -28 } labels.apply { text(240, 238)`${cutSegments.length} cut segments, ${pieces.length - cutSegments.length} bridges — island held`; } let sideLabels = TextLayer('side-labels') #{ font-family: monospace; font-size: 10; fill: #94a3b8; }; scene.append(sideLabels); sideLabels.apply { text(364, 100)`bridgeWidth 13`; text(364, 138)`bandWidth 24`; text(364, 180)`centerline`; } Four dashes cut, four gaps kept. The island is held by what you didn't cut.

Example 3 — Any loop will do

Nothing in the recipe mentions a circle. A rounded-rectangle band gets the same treatment — and its rounded band corners come from a nice piece of geometry: the centerline's fillets are already curves, so outline() simply offsets them, concentrically — inner radius 16, outer 36 around a radius-26 fillet. (The stroke-linejoin in the sample is honest-to-goodness inert here: a tangent-continuous centerline has no corner for a join to act on. It would matter on a sharp-cornered rect() centerline.) The bridges land wherever the offset puts them — drive the offset to move them. Swap the centerline, keep the recipe.

//-- The mechanic doesn't care about the shape. A rounded-square band: //-- dash the centerline, outline each cut with the band width — the //-- round joins follow the corners — and place bridges where the //-- pattern says. define ViewBox(0, 0, 480, 250); let table = PathLayer('table') #{ fill: #1c1917; stroke: none; }; layer('table').apply { rect(0, 0, 480, 250); } let scene = GroupLayer('scene') #{}; let mylar = PathLayer('mylar') #{ fill: #d9e2ec; stroke: #94a3b8; stroke-width: 1; }; let labels = TextLayer('labels') #{ font-family: monospace; font-size: 10; fill: #94a3b8; text-anchor: middle; }; scene.append(mylar, labels); let sheet = @{ roundRect(0, 0, 250, 180, 10); }; let centerline = @{ roundRect(45, 40, 160, 100, 26); }; let bandWidth = 20; let bridgeWidth = 12; let bridgeCount = 6; let cutLength = calc(centerline.length / bridgeCount - bridgeWidth); // Shift back half a segment: bridges off the corners. let pieces = centerline.dash(#{ stroke-dasharray: ${cutLength} ${bridgeWidth}; stroke-dashoffset: calc(0 - cutLength / 2); dash-seam: merge; }); let cutSegments = pieces.filter() {|piece| piece.kind == 'dash'}; let stencil = sheet; for (segment in cutSegments) { // stroke-linejoin is inert here: the roundRect centerline is already // tangent-continuous, so there is no corner for a join to act on — // the band's rounded corners are concentric OFFSETS of the fillets. let aperture = segment.path.outline(#{ stroke-width: ${bandWidth}; stroke-linecap: butt; stroke-linejoin: round; }); stencil = stencil.difference(aperture); } mylar.apply { M 115 28 stencil.draw() } labels.apply { text(240, 232)`same recipe, cornered loop — the band offsets the fillets`; } Six segments around a filleted loop — the band offsets the fillets.

Two practical floors before you scale this: cutLength goes negative — a compile error, since negative dash entries are rejected — as soon as bridgeWidth × bridgeCount reaches the centerline's length, so a small ring caps how many bridges it can carry. And these are user units; real bridges have a material minimum width (too narrow tears during cutting or lifts under spray), which no geometry can supply.

Example 4 — The trail marker

The finished artifact: a spray-ready sheet. The bridged ring from Example 2, a solid arrow aperture (no island — no bridge needed), and corner registration crosses derived from the sheet's dimensions. This is the file you'd hand a laser — with one practitioner detail already encoded: the crosses live on their own layer, because a cutter has to be told which lines are cuts and which are reference. Send the sheet layer to the blade; the registration layer is for aligning pass two.

//-- The finished artifact: a spray-ready trail-marker stencil. Bridged //-- ring, solid arrow aperture, corner registration marks — one sheet. define ViewBox(0, 0, 480, 300); let table = PathLayer('table') #{ fill: #1c1917; stroke: none; }; layer('table').apply { rect(0, 0, 480, 300); } let scene = GroupLayer('scene') #{}; let mylar = PathLayer('mylar') #{ fill: #d9e2ec; stroke: #94a3b8; stroke-width: 1; }; let registration = PathLayer('registration') #{ stroke: #64748b; stroke-width: 1.5; fill: none; }; let labels = TextLayer('labels') #{ font-family: monospace; font-size: 10; fill: #94a3b8; text-anchor: middle; }; scene.append(mylar, registration, labels); let sheet = @{ roundRect(0, 0, 340, 220, 12); }; // Bridged ring, as before. let ringCenter = @{ circle(105, 110, 62); }; let bandWidth = 26; let bridgeWidth = 14; let cutLength = calc(ringCenter.length / 4 - bridgeWidth); // Shift back half a segment: bridges off the cardinals. let ringPieces = ringCenter.dash(#{ stroke-dasharray: ${cutLength} ${bridgeWidth}; stroke-dashoffset: calc(0 - cutLength / 2); dash-seam: merge; }); // Solid arrow aperture — no island, no bridge needed. let arrow = @{ m 200 96 h 70 v -22 l 52 36 l -52 36 v -22 h -70 z }; let cutSegments = ringPieces.filter() {|piece| piece.kind == 'dash'}; let stencil = sheet.difference(arrow); for (segment in cutSegments) { let aperture = segment.path.outline(#{ stroke-width: ${bandWidth}; stroke-linecap: butt; }); stencil = stencil.difference(aperture); } mylar.apply { M 70 34 stencil.draw() } // Corner registration marks on their own layer — a cutter needs to be // told which lines are cuts and which are reference, and the layer is // how you tell it. Placed from the sheet's own dimensions. let sheetW = 340; let sheetH = 220; let sheetX = 70; let sheetY = 34; let markInset = 14; fn crosshair(centerX, centerY) { registration.apply { M calc(centerX - 6) centerY h 12 M centerX calc(centerY - 6) v 12 } } crosshair(calc(sheetX + markInset), calc(sheetY + markInset)); crosshair(calc(sheetX + sheetW - markInset), calc(sheetY + markInset)); crosshair(calc(sheetX + markInset), calc(sheetY + sheetH - markInset)); crosshair(calc(sheetX + sheetW - markInset), calc(sheetY + sheetH - markInset)); labels.apply { text(240, 282)`trail marker — bridged ring, solid arrow, registration crosses`; } One sheet: bridged ring, solid arrow, registration marks on their own layer.

Where to go next

Three crafts, one toolkit — the full surface is in the stroke geometry reference, and the stitching posts this one leans against are sashiko and leathercraft. And there's a stack of notes about everywhere the language pushed back while these posts were being written: the closing post opens the friction log — what hurt, what got fixed, and what the fixes look like.