Papercraft: Cut Lines, Fold Lines, and Glue Tabs from One Plate
Part 1 of 4 in The Cutting Room — projects that put cut() and
segment labels to work together.
Series: The Cutting Room
- Papercraft (this post) — cut lines, fold lines, and glue tabs from one plate
- Jigsaw — wavy knives, piece identity, and a scattered puzzle
- Garment patterns — named edges, seam allowances, and notches
- Stained glass — tinted panes, leading, and a rose window
Prerequisites: This series builds on
PathBlock.cut()— slicing a shape along the strokes of a second PathBlock — and on segment labels, theas segment('name')clause that makes parts of a path queryable. Examples 3 and 6 also lean on the parametric sampling tools (get,normal,subPath). Skim those first if any are new.
What it does
When cut() slices a closed shape apart, every piece comes back healed
shut — and every healed edge remembers that it used to be a wound. (An
open subject severs into open fragments instead; the healing is for
closed material.) Each seam carries the automatic segment label cut,
so a piece can be asked, after the fact, where was I cut?
let pieces = plate.cut(knife);
let placed = pieces[0].project(20, 30);
placed.segmentAll('cut'); // every healed edge, as drawable runs
Two things to know before the pictures, because everything below leans on them:
- Query the projected form. A piece is a PathBlock, and a
PathBlock answers sub-queries in its own frame — each returned run is
rebased so its own start is
(0, 0). Callproject(x, y)first and the same queries answer in canvas coordinates, ready to draw with. - Adjacent commands with the same label merge into one run. Ask
segmentAllfor two labeled edges that follow each other and you get one queryable run, not two. That is a feature — a fold line that turns a corner is still one fold — and when you do want the pieces back individually, the language has answers: Example 6 shows the rule and the way around it.
Your own labels survive the cut too: name an edge as segment('roof')
before cutting and whichever piece keeps that edge still answers for
the name. That is the other half of this series' toolkit.
One more thing this series is: a working friction log. These projects were built against the real language, and where they exposed a bug or a missing piece, the fix went back into Pathogen — each post that hit something grows a closing section, What this project taught the language, telling that story.
Why you'd use it
Papercraft is the cleanest possible demonstration, because a paper template is nothing but annotated cuts: solid lines to scissor, dashed lines to fold, tabs to glue. Before seam labels, decorating a cut meant re-deriving where the knife went — intersecting lines by hand, tracking which piece got which fragment. Now the pieces carry the answer. The idiom the whole series leans on:
for (seam in placed.segmentAll('cut')) {
seam.draw();
}
A projected value knows where it lives, so draw() draws it exactly
there — into whatever layer is active, with whatever stroke style that
layer carries. Dashed layer, fold lines. Amber layer, highlights. The
seam is just a path; the meaning comes from where you draw it.
Example 1 — The first seam
One rectangular plate, one S-curved knife, two pieces. On the left, the pieces drawn plain, reassembled. On the right, the same two pieces nudged apart, after each was asked for its healed seams — stroked in amber with the idiom above.
// viewBox="0 0 480 240"
//-- The bare mechanism: cut a plate in two, then ask each piece where it
//-- was healed. Left: the pieces, drawn plain. Right: the same pieces with
//-- every seam the cut created stroked in amber via segmentAll('cut').
define ViewBox(0, 0, 480, 240);
let bg = PathLayer('bg') ${
fill: #0f172a;
stroke: none;
};
layer('bg').apply {
rect(0, 0, 480, 240);
}
let scene = GroupLayer('scene') ${};
let pieceLayer = PathLayer('pieces') ${
fill: #e7dfd0;
stroke: #0f172a;
stroke-width: 2;
};
let seamLayer = PathLayer('seams') ${
stroke: #f59e0b;
stroke-width: 2.5;
fill: none;
stroke-linecap: round;
};
let divider = PathLayer('divider') ${
stroke: #334155;
stroke-width: 1;
stroke-dasharray: 4 4;
fill: none;
};
let labels = TextLayer('labels') ${
font-family: monospace;
font-size: 9;
fill: #94a3b8;
text-anchor: middle;
};
scene.append(pieceLayer, seamLayer, divider, labels);
let plate = @{
h 140
v 100
h -140
z
};
let knife = @{
m 88 -15
c -30 50 30 80 -12 130
};
let pieces = plate.cut(knife);
// Left: pieces drawn at one shared position — the plate reassembles.
pieceLayer.apply {
for (piece in pieces) {
M 50 70 piece.draw()
}
}
// Right: same pieces nudged apart, and each one is asked for its own
// healed seam — two amber curves, one per piece.
let knifeX = 88;
for (piece in pieces) {
let bounds = piece.boundingBox();
let side = calc(bounds.x + bounds.width / 2 < knifeX ? -9 : 9);
let placeX = calc(290 + side);
pieceLayer.apply {
M placeX 70 piece.draw()
}
// The projected form answers queries in absolute coordinates.
let placed = piece.project(placeX, 70);
seamLayer.apply {
for (seam in placed.segmentAll('cut')) {
seam.draw();
}
}
}
divider.apply {
M 240 30
L 240 210
}
labels.apply {
text(120, 200)`the cut pieces`;
text(345, 200)`each piece's own segmentAll('cut'), stroked`;
}
Note that each piece answers separately: two amber curves, one per piece, each the exact edge where that piece was healed. Seams come from the piece you ask, which is what makes everything downstream per-piece by construction.
Example 2 — Cut lines and fold lines
The first real template. An accordion card is one plate cut by three
vertical creases — and an accordion's creases alternate: mountain,
valley, mountain. That's three line styles, not two, and the knife
itself carries the distinction. Name a knife's edge
as segment('mountain') and the seams it heals come back labeled
cut.mountain — so the plate's boundary is where scissors go (solid
red), and one loop over the seams routes each fold to its dash style by
asking which knife made it.
// viewBox="0 0 480 220"
//-- A four-panel accordion card as one template: the plate's boundary is
//-- the cut line (solid), and the creases alternate mountain / valley —
//-- each knife is named, so its healed seams carry the fold's name and
//-- one loop routes every seam to the right dash style.
define ViewBox(0, 0, 480, 220);
let bg = PathLayer('bg') ${
fill: #0f172a;
stroke: none;
};
layer('bg').apply {
rect(0, 0, 480, 220);
}
let scene = GroupLayer('scene') ${};
let panelLayer = PathLayer('panels') ${
fill: #e7dfd0;
stroke: none;
};
let cutLayer = PathLayer('cut-line') ${
stroke: #ef4444;
stroke-width: 2;
fill: none;
};
let mountainLayer = PathLayer('mountain-folds') ${
stroke: #334155;
stroke-width: 1.5;
fill: none;
stroke-dasharray: 7 3 1.5 3;
};
let valleyLayer = PathLayer('valley-folds') ${
stroke: #475569;
stroke-width: 1.5;
fill: none;
stroke-dasharray: 5 4;
};
let cutCaption = TextLayer('cut-caption') ${
font-family: monospace;
font-size: 9;
fill: #f87171;
text-anchor: middle;
};
let mountainCaption = TextLayer('mountain-caption') ${
font-family: monospace;
font-size: 9;
fill: #94a3b8;
text-anchor: middle;
};
let valleyCaption = TextLayer('valley-caption') ${
font-family: monospace;
font-size: 9;
fill: #94a3b8;
text-anchor: middle;
};
scene.append(panelLayer,
cutLayer,
mountainLayer,
valleyLayer,
cutCaption,
mountainCaption,
valleyCaption);
let card = @{
h 288
v 96
h -288
z
};
// An accordion alternates fold directions — so the creases alternate
// names. A knife's label rides through the cut: the seams it heals come
// back labeled cut.mountain / cut.valley.
let creases = [];
for (i in 0..2) {
let foldName = 'valley';
if (i % 2 == 0) {
foldName = 'mountain';
}
creases.push(@{
m calc(72 * (i + 1)) -15
l 0 126 as segment(foldName);
});
}
let panels = card.cut(creases);
let originX = 96;
let originY = 48;
panelLayer.apply {
for (piece in panels) {
M originX originY piece.draw()
}
}
// The original outline is the cut line: scissors follow the solid path.
cutLayer.apply {
card.drawTo(originX, originY);
}
// One pass over the physical seams, each routed by the name its knife
// left on it — no fold is drawn twice, and no geometry bookkeeping
// decides which crease is which.
for (seam in panels.seams()) {
if (seam.segmentAll('cut.mountain').length > 0) {
mountainLayer.apply {
seam.project(originX, originY).draw();
}
} else {
valleyLayer.apply {
seam.project(originX, originY).draw();
}
}
}
// Captions sit under what they name: the valley crease is at x=240,
// the right mountain crease at x=312 (nearest the right caption).
cutCaption.apply {
text(96, 175)`solid = cut`;
}
valleyCaption.apply {
text(240, 175)`dashed = valley fold`;
}
mountainCaption.apply {
text(388, 175)`dash-dot = mountain fold`;
}
One thing the sample does not have to do: dedupe. Each interior fold
is shared by two panels, so asking every panel for its seams would
stroke each fold twice — and two dashed strokes running opposite
directions fill in each other's gaps. panels.seams(), asked of the
cut result as a whole, answers with each physical seam exactly once,
so the fold pass is one loop with no ownership rules. (Per-piece
segmentAll('cut') is still the right query when you want each
piece's own view of its edges — Example 1 and the tabs to come.)
Example 3 — Glue tabs that grow on seams
Tabs are the first payoff that would genuinely hurt to do by hand. The
seam is a parametric path, so get(t) walks along it and normal(t)
points straight out of it — which is all a trapezoid tab needs. The
left piece grows three tabs along its healed edge, every other seventh
of the way; the outer boundary, which was never cut, stays clean.
// viewBox="0 0 480 240"
//-- Glue tabs, generated: the left piece grows trapezoid tabs along its
//-- healed seam — and only there. get(t) walks the seam, normal(t) points
//-- the tabs outward, and the original boundary never grows a tab.
define ViewBox(0, 0, 480, 240);
let bg = PathLayer('bg') ${
fill: #0f172a;
stroke: none;
};
layer('bg').apply {
rect(0, 0, 480, 240);
}
let scene = GroupLayer('scene') ${};
let tabLayer = PathLayer('tabs') ${
fill: #cbbfa3;
stroke: #ef4444;
stroke-width: 1.5;
};
let pieceLayer = PathLayer('pieces') ${
fill: #e7dfd0;
stroke: #0f172a;
stroke-width: 2;
};
let labels = TextLayer('labels') ${
font-family: monospace;
font-size: 9;
fill: #94a3b8;
text-anchor: middle;
};
scene.append(tabLayer, pieceLayer, labels);
let plate = @{
h 150
v 110
h -150
z
};
let knife = @{
m 92 -15
c -26 45 26 75 -10 140
};
let pieces = plate.cut(knife);
// Pull the two pieces apart so the tabs have room to show.
let plateBB = plate.boundingBox();
let plateCx = calc(plateBB.x + plateBB.width / 2);
for (piece in pieces) {
let bounds = piece.boundingBox();
let side = calc(bounds.x + bounds.width / 2 < plateCx ? -34 : 34);
let placeX = calc(165 + side);
pieceLayer.apply {
M placeX 55 piece.draw()
}
// Tabs go on the left piece only.
if (side < 0) {
let placed = piece.project(placeX, 55);
tabLayer.apply {
for (seam in placed.segmentAll('cut')) {
// Walk the seam in sevenths; every other interval grows a tab.
for (k in 0..2) {
let t0 = calc((k * 2 + 1) / 7);
let t1 = calc((k * 2 + 2) / 7);
let tabStart = seam.get(t0);
let tabEnd = seam.get(t1);
// Seam normals point away from the piece's material —
// guaranteed by the cut, so the tab needs no direction test.
let outwardAngle = seam.normal(calc((t0 + t1) / 2)).angle;
let edgeAngle = calc(outwardAngle + PI() / 2);
// Trapezoid: seam edge tabStart→tabEnd, outer edge tapered inward by 4.
M calc(tabStart.x) calc(tabStart.y)
L calc(tabStart.x + cos(outwardAngle) * 13 + cos(edgeAngle) * 4) calc(tabStart.y + sin(outwardAngle) * 13 + sin(edgeAngle) * 4)
L calc(tabEnd.x + cos(outwardAngle) * 13 - cos(edgeAngle) * 4) calc(tabEnd.y + sin(outwardAngle) * 13 - sin(edgeAngle) * 4)
L calc(tabEnd.x) calc(tabEnd.y)
}
}
}
}
}
labels.apply {
text(240, 205)`tabs ride the seam - the outer boundary stays clean`;
}
The one judgment call in the sample is direction. normal(t) always
returns the left-hand normal of the path's travel — but Example 1
showed that the two pieces traverse their shared seam in opposite
directions, so "left of the seam" lands inside one piece and outside
the other. The sample settles it by comparing the normal against the
direction to the piece's own center — if it points inward, flip it by
adding PI().
Example 4 — Pieces that introduce themselves
So far the seams did all the talking. This example is about your
labels: a house-shaped plate names its roof as segment('roof') and
its floor as segment('base'), then a horizontal cut splits it. Each
piece still answers for the names it kept — the roof piece strokes and
counts its roof, the base piece its base.
// viewBox="0 0 480 215"
//-- Name the edges before you cut, and the pieces introduce themselves.
//-- A house-shaped plate labels its roof and base; after a horizontal cut
//-- each piece still answers for the labels it kept — stroked and counted.
define ViewBox(0, 0, 480, 215);
let bg = PathLayer('bg') ${
fill: #0f172a;
stroke: none;
};
layer('bg').apply {
rect(0, 0, 480, 215);
}
let scene = GroupLayer('scene') ${};
let pieceLayer = PathLayer('pieces') ${
fill: #e7dfd0;
stroke: #0f172a;
stroke-width: 2;
};
let roofLayer = PathLayer('roof-runs') ${
stroke: #2dd4bf;
stroke-width: 3;
fill: none;
stroke-linecap: round;
};
let baseLayer = PathLayer('base-runs') ${
stroke: #c084fc;
stroke-width: 3;
fill: none;
stroke-linecap: round;
};
let roofCount = TextLayer('roof-count') ${
font-family: monospace;
font-size: 9;
fill: #2dd4bf;
};
let baseCount = TextLayer('base-count') ${
font-family: monospace;
font-size: 9;
fill: #c084fc;
};
scene.append(pieceLayer,
roofLayer,
baseLayer,
roofCount,
baseCount);
let house = @{
l 70 -50 as segment('roof');
l 70 50 as segment('roof');
v 90
h -140 as segment('base');
z
};
let knife = @{
m -15 30
l 170 0
};
let pieces = house.cut(knife);
for (piece in pieces) {
// The labels place the pieces too: the roof piece slides up, the
// other slides down — no coordinate checks anywhere.
let roofRuns = piece.segmentAll('roof');
let originY = calc(roofRuns.length > 0 ? 66 : 90);
pieceLayer.apply {
M 120 originY piece.draw()
}
let placed = piece.project(120, originY);
let baseRuns = placed.segmentAll('base');
roofRuns = placed.segmentAll('roof');
roofLayer.apply {
for (run in roofRuns) {
run.draw();
}
}
baseLayer.apply {
for (run in baseRuns) {
run.draw();
}
}
// Each piece reports which of the plate's names it still owns,
// color-keyed to the strokes.
let placedBounds = placed.boundingBox();
let labelY = calc(placedBounds.y + placedBounds.height / 2 + 3);
roofCount.apply {
text(310, labelY)`roof ${roofRuns.length}`;
}
baseCount.apply {
text(378, labelY)`base ${baseRuns.length}`;
}
}
The counts are the quiet star. segmentAll('roof') on the top piece
has length 1, on the bottom piece length 0 — which means a program can
identify pieces by what they kept, with no geometry tests at all.
The sample practices what it preaches: even the up-or-down placement
of each piece is decided by the roof query, not by a coordinate check.
The garment-pattern post (part 3) builds its whole workflow on this.
Example 5 — The exploded view
Assembly diagrams pull pieces apart along rays from the center, and
boundingBox() gives every piece its own ray for free. The ghost of
the uncut plate stays behind, dashed; each drifted piece strokes its
seams amber, so mating edges face each other across the gaps.
// viewBox="0 0 480 260"
//-- The assembly diagram: four pieces drift away from the plate's center
//-- along their own centroid rays, the ghost of the uncut plate stays
//-- behind, and every mating edge glows — that's the seam group again.
define ViewBox(0, 0, 480, 260);
let bg = PathLayer('bg') ${
fill: #0f172a;
stroke: none;
};
layer('bg').apply {
rect(0, 0, 480, 260);
}
let scene = GroupLayer('scene') ${};
let ghost = PathLayer('ghost') ${
stroke: #334155;
stroke-width: 1;
stroke-dasharray: 3 4;
fill: none;
};
let pieceLayer = PathLayer('pieces') ${
fill: #e7dfd0;
stroke: #0f172a;
stroke-width: 2;
};
let seamLayer = PathLayer('seams') ${
stroke: #f59e0b;
stroke-width: 2;
fill: none;
stroke-linecap: round;
};
let labels = TextLayer('labels') ${
font-family: monospace;
font-size: 9;
fill: #94a3b8;
text-anchor: middle;
};
scene.append(ghost, pieceLayer, seamLayer, labels);
let plate = @{
h 150
v 110
h -150
z
};
let knives = @{
m 68 -15
c -20 40 20 80 -4 140
m -79 -83
l 180 12
};
let pieces = plate.cut(knives);
let originX = 165;
let originY = 70;
ghost.apply {
plate.drawTo(originX, originY);
}
// Drift each piece along the ray from the plate's bounding-box center
// to its own.
let plateBB = plate.boundingBox();
let plateCenterX = calc(plateBB.x + plateBB.width / 2);
let plateCenterY = calc(plateBB.y + plateBB.height / 2);
for (piece in pieces) {
let bounds = piece.boundingBox();
let dx = calc(bounds.x + bounds.width / 2 - plateCenterX);
let dy = calc(bounds.y + bounds.height / 2 - plateCenterY);
let len = calc(sqrt(dx * dx + dy * dy));
let placeX = calc(originX + dx / len * 34);
let placeY = calc(originY + dy / len * 34);
pieceLayer.apply {
M placeX placeY piece.draw()
}
let placed = piece.project(placeX, placeY);
seamLayer.apply {
for (seam in placed.segmentAll('cut')) {
seam.draw();
}
}
}
labels.apply {
text(240, 235)`amber edges are the mates — seams face seams`;
}
Nothing here is new — it is Example 1's stroke and a square root — but this is the moment the toolkit starts reading as a diagram rather than a demo: the seams are doing the explanatory work a technical illustrator would do with a highlighter.
Example 6 — The kit sheet
Everything at once: a hexagonal medallion, three straight knives
through the center, six numbered wedges exploded into a ring around the
assembled ghost. Each wedge tabs one of its healed edges (red — cut
around the tab) and fold-dashes the other (glue your neighbor's tab
under it), and wears its piece number — derived from its angle around
the ring, because cut() makes no promise about the order pieces come
back in.
// viewBox="0 0 480 300"
//-- The finished kit sheet: a hex medallion cut into six wedges, exploded
//-- into a ring. Every wedge tabs one healed seam, fold-dashes the other,
//-- and wears its number — the knives are named, so each radial edge is
//-- queried by the knife that made it.
define ViewBox(0, 0, 480, 300);
let bg = PathLayer('bg') ${
fill: #0f172a;
stroke: none;
};
layer('bg').apply {
rect(0, 0, 480, 300);
}
let scene = GroupLayer('scene') ${};
let ghost = PathLayer('ghost') ${
stroke: #334155;
stroke-width: 1;
stroke-dasharray: 3 4;
fill: none;
};
let tabLayer = PathLayer('tabs') ${
fill: #cbbfa3;
stroke: #ef4444;
stroke-width: 1.2;
};
let wedgeLayer = PathLayer('wedges') ${
fill: #e7dfd0;
stroke: #0f172a;
stroke-width: 2;
};
let foldLayer = PathLayer('folds') ${
stroke: #475569;
stroke-width: 1.4;
fill: none;
stroke-dasharray: 4 3;
};
let numbers = TextLayer('numbers') ${
font-family: monospace;
font-size: 10;
fill: #0f172a;
text-anchor: middle;
};
let title = TextLayer('title') ${
font-family: monospace;
font-size: 10;
fill: #94a3b8;
text-anchor: middle;
letter-spacing: 3;
};
let caption = TextLayer('caption') ${
font-family: monospace;
font-size: 9;
fill: #94a3b8;
text-anchor: middle;
};
scene.append(ghost,
tabLayer,
wedgeLayer,
foldLayer,
numbers,
title,
caption);
let plate = @{
polygon(0, 0, 62, 6);
};
let knifeReach = 78;
// Three straight knives through the center, 60 degrees apart — each its
// own block, handed to cut() as an array, and each named for its seams.
let knives = [];
for (k in 0..2) {
let knifeAngle = calc(k * PI() / 3);
let dirX = cos(knifeAngle);
let dirY = sin(knifeAngle);
knives.push(@{
m calc(0 - knifeReach * dirX) calc(0 - knifeReach * dirY)
l calc(knifeReach * 2 * dirX) calc(knifeReach * 2 * dirY) as segment(`k${k}`);
});
}
let wedges = plate.cut(knives);
let originX = 240;
let originY = 158;
ghost.apply {
plate.drawTo(originX, originY);
}
for (wedge in wedges) {
// Drift outward along the ray to the wedge's bounding-box center.
let bounds = wedge.boundingBox();
let dx = calc(bounds.x + bounds.width / 2);
let dy = calc(bounds.y + bounds.height / 2);
let len = calc(sqrt(dx * dx + dy * dy));
let placeX = calc(originX + dx / len * 44);
let placeY = calc(originY + dy / len * 44);
wedgeLayer.apply {
M placeX placeY wedge.draw()
}
let placed = wedge.project(placeX, placeY);
let placedBounds = placed.boundingBox();
let wedgeCenterX = calc(placedBounds.x + placedBounds.width / 2);
let wedgeCenterY = calc(placedBounds.y + placedBounds.height / 2);
// A wedge's two radial edges came from two DIFFERENT knives, and each
// knife left its name on the seams it healed — so exact sub-label
// queries hand each edge back on its own, no merged-run surgery.
for (k in 0..2) {
for (seam in placed.segmentAll(`cut.k${k}`)) {
let edgeMid = seam.get(0.5);
// Which side of the centroid ray does this edge sit on?
let cross = calc((wedgeCenterX - placeX) * (edgeMid.y - placeY) - (wedgeCenterY - placeY) * (edgeMid.x - placeX));
if (cross > 0) {
// Clockwise edge: glue tab, pointing away from the wedge.
let tabStart = seam.get(0.12);
let tabEnd = seam.get(0.88);
// Seam normals point away from the wedge's material — guaranteed
// by the cut, so the tab needs no direction test.
let outwardAngle = seam.normal(0.5).angle;
let edgeAngle = calc(outwardAngle + PI() / 2);
tabLayer.apply {
M calc(tabStart.x) calc(tabStart.y)
L calc(tabStart.x + cos(outwardAngle) * 10 + cos(edgeAngle) * 4) calc(tabStart.y + sin(outwardAngle) * 10 + sin(edgeAngle) * 4)
L calc(tabEnd.x + cos(outwardAngle) * 10 - cos(edgeAngle) * 4) calc(tabEnd.y + sin(outwardAngle) * 10 - sin(edgeAngle) * 4)
L calc(tabEnd.x) calc(tabEnd.y)
}
} else {
// Counter-clockwise edge: fold line for the neighbor's tab.
foldLayer.apply {
seam.draw();
}
}
}
}
// Number by angle around the ring, not by loop order — cut() makes
// no promise about the order pieces come back in.
let ringAngle = calc(atan2(dy, dx) + PI());
let num = calc(floor(ringAngle / (PI() / 3)) + 1);
numbers.apply {
text(wedgeCenterX, calc(wedgeCenterY + 3))`${num}`;
}
}
title.apply {
text(240, 32)`HEX MEDALLION KIT`;
}
caption.apply {
text(240, 278)`tab the red edge, fold the dashed one, rejoin in order`;
}
And here is the merge rule from the top of the post, doing real
work. A wedge's two radial edges meet at the hexagon's center, and
under the umbrella query segmentAll('cut') they come back as one
V-shaped run (adjacent seam commands merge regardless of which knife
made them). But the knives are named — k0, k1, k2 — and each
wedge's two edges come from two different knives, so the exact
queries segmentAll('cut.k0')… hand each edge back on its own. The
sample decides tab-or-fold per edge by which side of the wedge's
center ray it lies on. If you ever ask for two seams and receive one,
the umbrella merge is why — and a named knife (or, for a run one label
covers, the
:atomic pseudo-selector)
re-divides it.
What this project taught the language
The friction-log promise from the top of the post, kept — what building this project changed in Pathogen:
The seam idiom became a real draw(). When this series first
shipped, the loop above took two lines per seam:
seam.drawTo(seam.startPoint.x, seam.startPoint.y) — "draw yourself
where you already are," said with two property reads and a re-anchor.
Worse, the same expression applied to a whole cut piece silently drew
it in the wrong place, because a piece's projected startPoint was, at
the time, its frame origin rather than its first command (the garment
post tells that part of the story — including the epilogue where
startPoint itself was later made truthful). Projected values now have
an in-place
draw(): it
anchors on the value's first command by definition, so the misplacement
cannot be written at all and the idiom is one self-evident line.
// before
seam.drawTo(seam.startPoint.x, seam.startPoint.y);
// after
seam.draw();
Example 2 grew up twice. The fold lines you see above are the
result of two rounds of the loop. First, dedup: the original sample
kept shared folds from double-drawing with an ownership rule — each
panel stroked only the seams on its right-hand side, a
midpoint-versus-center comparison that worked and taught nothing. Cut
results now answer
pieces.seams(): each
physical seam exactly once, subject-local like the pieces themselves,
so the double-draw bug class (opposite-phase dashes filling each
other's gaps) is unwritable. Second, direction: even deduped, every
fold shipped in one dash style, because all seams shared one anonymous
cut group and mountain-versus-valley — the distinction a real
accordion template turns on — was inexpressible. Knife labels now
ride through the cut: an edge
authored as segment('mountain') heals into seams labeled
cut.mountain, the umbrella segmentAll('cut') still answers
everything, and sub-label queries answer one knife at a time.
// original: per-panel ownership rule (~10 lines)
for (seam in placed.segmentAll('cut')) {
let mid = seam.get(0.5);
if (mid.x > panelCenterX) {
seam.draw();
}
}
// today: each physical seam once, routed by the knife that made it
for (seam in panels.seams()) {
if (seam.segmentAll('cut.mountain').length > 0) {
mountainLayer.apply {
seam.project(originX, originY).draw();
}
} else {
valleyLayer.apply {
seam.project(originX, originY).draw();
}
}
}
The knife names paid off a second time in Example 6: a wedge's two
radial edges come from two different knives, so exact sub-label
queries return each edge on its own, and the merged-V subPath
surgery at guessed fractions is gone.
The tabs' direction test turned out to be dead code. Example 3 settles which way a tab points by reading the seam normal — and the original sample compared it against the piece's center, flipping when it aimed inward: three lines of ceremony per tab. Working the friction log revealed the flip never fires. A cut always orients each piece's outline the same way around its material, so a seam normal points out of the piece by construction. The guarantee is now documented and pinned by tests, and both tab samples dropped the dance — with byte-identical output, the strongest proof the code was dead. The purest friction-log lesson in the series: the feature existed all along; the fix was a sentence, and the sentence was the feature.
// before: probe, dot product, conditional flip
let n = seam.normal(tabMid);
let toCenter = calc(cos(n.angle) * (cx - n.point.x) + sin(n.angle) * (cy - n.point.y));
let outwardAngle = calc(toCenter > 0 ? n.angle + PI() : n.angle);
// after: the normal already faces out of the material
let outwardAngle = seam.normal(tabMid).angle;
The medallion's knives stopped doing arithmetic. Example 6's three
knives were originally one cutter block whose strokes chained together
with hand-computed relative moves — and one of those moves shipped
wrong before review caught it. cut() now accepts an
array of cutters, so
the sample builds one single-stroke knife per angle in a loop and
hands the set over in a single call. A knife that states only "start
here, cut this" has no arithmetic to get wrong.
The query language grew its : half. When knife names landed
(Example 2's second act, above), the . in cut.mountain was only
half of a deliberate decision: label names reserve all punctuation,
with : explicitly held back for CSS-style pseudo-selectors that
didn't exist yet. Now they do — :atomic, :first, :last, and
:nth(k). :atomic is the
merge rule's official escape hatch (one block per drawing command — a
labeled circle() hands back its individual arcs), and the position
family selects whole runs from a group. Honest accounting: nothing in
this series needs them anymore — the sub-labels above already
dissolved the case that demanded an escape hatch — which is exactly why
they shipped as query syntax rather than yet another method: the
grammar was already paid for.
// before: recover an individual edge by fraction guessing
let firstArc = run.subPath(0, 0.5);
// after: ask for it
let arcs = wheel.segmentAll('rim:atomic');
let lastTooth = comb.segment('tooth:last');
The traps got fences. Two hazards this series stepped on
never bit the published samples only because a style guideline banned
the ammunition. Both are now language rules instead of etiquette.
Single-letter variables that shadow path commands (let m = 25; then
L m 40) used to fail with a Missing ';' pointed at punctuation
nowhere near the mistake; the compiler and the editor now say what
actually happened ('m' is a path command here — write calc(m), or rename the variable), with a one-click calc() wrap in the playground.
And the angle-suffix names pi, deg, and rad are now
reserved words: suffix only, never a
variable, so calc(pi) explains itself instead of reporting an
undefined variable two spellings away from three working ones
(0.5pi, PI(), deg(x)).
Where to go next
- Jigsaw: pieces that know their own edges — part 2 cuts with wavy knives and sorts pieces by the rim label they kept.
- Cutting Paths — the full
cut()tour this series builds on: cookie cutters, donuts, open subjects. - PathBlock parametric sampling
—
get,normal, andpartition, the seam-walking tools used here. - Reference: labels survive derived paths and cutting paths in the docs.