Leathercraft: Stitch Holes That Can't Disagree
Part 3 of 5 in Broken Lines — projects that treat the stroke not as paint, but as geometry you can hold.
Series: Broken Lines
- Stroke geometry — dashes, outlines, and start points as real paths
- Sashiko — running stitches from binary sequences
- Leathercraft (this post) — stitch holes that can't disagree
- Stencils — bridges are just gaps
- What Broken Lines taught the language — the friction log, resolved
Prerequisites: Part 1's
dash()andoutline(). The samples also use expression-bodied lambdas ({|prick| prick.kind == 'dash'}),fillet()to round the stitch line's corners,difference()to punch the holes for real, and<<concatenation with.reverse()to build two pieces around one shared edge.
What it does
Saddle-stitched leather is sewn through pre-punched holes at fixed
pitch — 3 to 4 mm irons, hole after hole down every seam. Saddle
stitch means two needles working the same hole from opposite sides,
which is why everything below cares so much about where the holes go.
In dash terms a hole is a dash of nearly zero length, followed by a
pitch-sized gap. stroke-dasharray: 0.01 16 puts a dash every 16
units; outline() with round caps turns each one into a dot of
exactly the punch's diameter. Walk a pricking iron down a seam and
the marks it leaves are the dash array. (Whether your iron marks
for an awl or a stitching chisel punches clean through, the template
is the same row of points.)
One craft rule dominates everything else, and it's worth stating before any picture: two pieces that share a seam must carry the same hole count at the same spacing. A wallet whose flap has 17 holes and whose body has 18 does not assemble. Designers re-count after every resize. This post's answer is structural: derive the holes from the one shared edge, and the two rows cannot disagree, because they are the same row.
Why you'd use it
Because hole layout is the mechanical, error-prone step between a
shape and a template — the part designers do today with Illustrator's
Blend tool and a hand-typed step count, or by duplicating a dot and
eyeballing the run. Pitch, margins, corner behavior, and mating-seam
agreement are all arithmetic along a path, which is exactly what
dash() measures. Resize the piece and the holes re-derive; nothing
is re-counted by hand.
Example 1 — The prick line
One seam. The dash array's first entry is 0.01 — a dash so short
it's a point — and the second is the pitch. Outline each one with
round caps at the punch diameter and the seam becomes a row of holes.
//-- A leather stitch line is a row of punched holes at fixed pitch.
//-- In dash terms: a near-zero dash, a pitch-sized gap — then outline()
//-- each dash round and every "stitch" becomes a punchable dot.
define ViewBox(0, 0, 480, 170);
let leather = PathLayer('leather') #{
fill: #7c5335;
stroke: none;
};
layer('leather').apply {
rect(0, 0, 480, 170);
}
let scene = GroupLayer('scene') #{};
let seamGhost = PathLayer('seam-ghost') #{
stroke: #00000040;
stroke-width: 1;
fill: none;
};
let holes = PathLayer('holes') #{
fill: #f8fafc;
stroke: none;
};
let labels = TextLayer('labels') #{
font-family: monospace;
font-size: 10;
fill: #f1e9dd;
};
scene.append(seamGhost, holes, labels);
let seam = @{
h 340
};
seamGhost.apply {
M 70 92 seam.draw()
}
let pricks = seam.dash(#{
stroke-dasharray: 0.01 16;
});
let seamHoles = pricks.filter() {|prick| prick.kind == 'dash'};
for (prick in seamHoles) {
let dot = prick.path.outline(#{
stroke-width: 5;
stroke-linecap: round;
});
holes.apply {
M 70 92 dot.draw()
}
}
labels.apply {
text(70, 44)`stroke-dasharray: 0.01 16 — the dash is the prick`;
text(70, 136)`outline(round, width 5) turns each prick into a hole`;
}
Example 2 — Centering the run
The naive row starts with a hole exactly on the corner and dumps the leftover length at the far end — every leatherworker's first-template mistake. The fix is one negative dash offset: shift the pattern back by half the remainder, and the margins agree at both ends.
//-- Punching from a corner leaves a half-space at the far end. A
//-- negative stroke-dashoffset centers the run: the leftover length,
//-- split evenly between both ends.
define ViewBox(0, 0, 480, 200);
let leather = PathLayer('leather') #{
fill: #7c5335;
stroke: none;
};
layer('leather').apply {
rect(0, 0, 480, 200);
}
let scene = GroupLayer('scene') #{};
let seamGhost = PathLayer('seam-ghost') #{
stroke: #00000040;
stroke-width: 1;
fill: none;
};
let holes = PathLayer('holes') #{
fill: #f8fafc;
stroke: none;
};
let labels = TextLayer('labels') #{
font-family: monospace;
font-size: 10;
fill: #f1e9dd;
};
scene.append(seamGhost, holes, labels);
let seam = @{
h 340
};
let pitch = 22;
fn punchRow(anchorY, offsetValue) {
seamGhost.apply {
M 70 anchorY seam.draw()
}
let pricks = seam.dash(#{
stroke-dasharray: 0.01 ${pitch};
stroke-dashoffset: ${offsetValue};
});
let rowHoles = pricks.filter() {|prick| prick.kind == 'dash'};
for (prick in rowHoles) {
let dot = prick.path.outline(#{
stroke-width: 5;
stroke-linecap: round;
});
holes.apply {
M 70 anchorY dot.draw()
}
}
}
// Naive: first hole lands exactly on the corner.
punchRow(78, 0);
// Centered: split the leftover between both ends.
let leftover = calc(seam.length % pitch);
punchRow(140, calc(0 - leftover / 2));
labels.apply {
text(70, 46)`offset 0 — a hole ON the corner, a long remainder at the end`;
text(70, 176)`offset -(length % pitch) / 2 — margins agree at both ends`;
text(30, 82)`0`;
text(424, 82)`10`;
text(30, 144)`5`;
text(424, 144)`5`;
}
One gotcha the centering trick hides: it guarantees equal end
margins, not adequate ones. When the leftover is tiny, "centered"
puts a hole a hair from the corner — where leather tears out. The
craft fix is to give up one hole and split a whole extra pitch across
the ends: endMargin = (leftover + pitch) / 2. The next two examples
do exactly that.
Example 3 — One edge, two pieces
The heart of the post. A flap and a body share one seam — the same
sagging curve is the flap's bottom edge and the body's top edge, built
by concatenating the shared
seamEdge into both outlines. (<< reads as feed the left thing
the right thing in both of its jobs here: gluing path pieces
together, and handing filter() its predicate. And note .length on
a path is arc length, while .length on the holes array is a count —
same word, two rulers.) The holes are derived once, from that edge,
and drawn onto both pieces — one array, drawn twice. They could not
differ if you tried.
//-- The saddle-stitch rule: two mating pieces must carry the SAME hole
//-- count at the SAME spacing. Derive the holes from the one shared
//-- seam edge and the counts cannot disagree.
define ViewBox(0, 0, 480, 330);
let bench = PathLayer('bench') #{
fill: #1c1917;
stroke: none;
};
layer('bench').apply {
rect(0, 0, 480, 330);
}
let scene = GroupLayer('scene') #{};
let pieces = PathLayer('pieces') #{
fill: #7c5335;
stroke: #3f2a17;
stroke-width: 1.5;
};
let holes = PathLayer('holes') #{
fill: #f8fafc;
stroke: none;
};
let labels = TextLayer('labels') #{
font-family: monospace;
font-size: 10;
fill: #d6c9b8;
};
scene.append(pieces, holes, labels);
// ONE shared seam edge — a gentle sag, one width to change.
let seamWidth = 320;
let seamEdge = @{
c 60 22 260 22 seamWidth 0
};
// The flap's bottom edge and the body's top edge are the same curve.
let flap = @{
m 0 -64
h seamWidth
v 64
} << seamEdge.reverse() << @{
z
};
let body = seamEdge << @{
v 92
h calc(0 - seamWidth)
z
};
pieces.apply {
M 80 112 flap.draw()
M 80 206 body.draw()
}
// Holes derived ONCE, from the shared edge — then drawn on both pieces.
// Equal end margins aren't automatically ADEQUATE ones: when the
// leftover is small, give up a hole and split a whole extra pitch, so
// no hole lands on a corner. (Bare variables in style values need
// backtick interpolation — a logged language limitation.)
let pitch = 20;
let leftover = calc(seamEdge.length % pitch);
let endMargin = calc((leftover + pitch) / 2);
let pricks = seamEdge.dash(#{
stroke-dasharray: 0.01 ${pitch};
stroke-dashoffset: calc(0 - endMargin);
});
let seamHoles = pricks.filter() {|prick| prick.kind == 'dash'};
for (prick in seamHoles) {
let dot = prick.path.outline(#{
stroke-width: 4.5;
stroke-linecap: round;
});
holes.apply {
M 80 112 dot.draw()
M 80 206 dot.draw()
}
}
labels.apply {
text(80, 28)`${seamHoles.length} + ${seamHoles.length} holes — one array, drawn twice`;
text(20, 96)`flap`;
text(20, 250)`body`;
}
This mirrors real practice, and honestly: irons are usually walked a
few millimeters inside the edge, through both glued layers at once —
one marked line, both pieces. Deriving a separate inset line per piece
with offset() would reintroduce the disagreement, because offsetting
changes a curve's length. Mark one line; punch through both. That gap
— dashing an inset line by the edge it came from — is this series'
deepest feature request, and it went straight into
the friction log with this
wallet as its motivating artifact.
Example 4 — The wallet template
The finished artifact, at an honest scale: 4 units to the
millimetre, a panel big enough for a real ISO card, a 4 mm iron.
The pocket's stitch line is a fillet()-rounded U derived from
three named dimensions — resize the pocket and every hole re-derives
— with the safe end margin from Example 2's gotcha. Then, because
outlined holes are closed paths, every hole is subtracted from the
pocket with difference(). Until now the holes were painted white so
you could see them; here they're absent material — the darker panel
beneath shows through each one.
//-- The finished artifact: a card-wallet punch template at 4 units per
//-- millimetre — the panel really holds an ISO card. The stitch line is
//-- derived from three named dimensions, so resizing the pocket
//-- re-derives every hole.
define ViewBox(0, 0, 480, 320);
let bench = PathLayer('bench') #{
fill: #1c1917;
stroke: none;
};
bench.apply {
rect(0, 0, 480, 320);
}
let scene = GroupLayer('scene') #{};
let backPanel = PathLayer('back-panel') #{
fill: #2e2018;
stroke: #3f2a17;
stroke-width: 1.5;
};
let pocket = PathLayer('pocket') #{
fill: #7c5335;
stroke: #3f2a17;
stroke-width: 1.5;
};
let guides = PathLayer('guides') #{
stroke: #00000035;
stroke-width: 1;
fill: none;
};
let labels = TextLayer('labels') #{
font-family: monospace;
font-size: 10;
fill: #d6c9b8;
};
scene.append(backPanel, pocket, guides, labels);
// 4 units = 1 mm. Panel 360 x 236 = 90 x 59 mm — an ISO card
// (85.6 x 54 mm) fits with margin. Every other number derives.
let anchorX = 60;
let anchorY = 36;
let panelW = 360;
let panelH = 236;
let pocketH = 148;
let seamInset = 12;
let pitch = 16;
// 4 mm — a standard iron
backPanel.apply {
roundRect(anchorX,
anchorY,
panelW,
panelH,
16);
}
// The pocket covers the lower part; its stitch line is a filleted U
// derived from the same three dimensions.
let pocketY = calc(panelH - pocketH);
let pocketShape = @{
roundRect(0,
calc(panelH - pocketH),
panelW,
pocketH,
16);
};
let stitchLine = @{
m seamInset calc(panelH - pocketH + 8)
v calc(pocketH - 8 - seamInset)
h calc(panelW - seamInset * 2)
v calc(0 - (pocketH - 8 - seamInset))
};
let seamU = stitchLine.fillet(8);
// Center the run with a guaranteed edge margin: equal is not enough —
// give up one hole and split a whole extra pitch across the ends.
let leftover = calc(seamU.length % pitch);
let endMargin = calc((leftover + pitch) / 2);
let pricks = seamU.dash(#{
stroke-dasharray: 0.01 ${pitch};
stroke-dashoffset: calc(0 - endMargin);
});
let seamHoles = pricks.filter() {|prick| prick.kind == 'dash'};
let punched = pocketShape;
for (prick in seamHoles) {
let hole = prick.path.outline(#{
stroke-width: 4.5;
stroke-linecap: round;
});
punched = punched.difference(hole);
}
pocket.apply {
M anchorX anchorY punched.draw()
}
guides.apply {
M anchorX anchorY seamU.draw()
}
labels.apply {
text(60, 24)`card wallet — pocket punch template, 4 mm pitch (4 units = 1 mm)`;
text(60, 300)`holes are difference()d out — the darker panel shows through`;
}
Where to go next
The stroke-geometry surface this post leaned on is documented in the reference. Holes are material removed along a line. The next post inverts the trick: stencils survive because of the material you keep — bridges — and a bridge is nothing but a gap, placed on purpose, in a stroke that's about to be cut out.